diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index df7a528ef..3f83c04d1 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -188,3 +188,17 @@ export function selectDesign( dispatching = false; } } + +/** 관을 옮김 — 그 관을 골라 둔 고름이면 새 자리로(322-3 · 관 id = 자리 이름 `pipe-{누가거리}` 라 id 도 + * 새 자리로 · 옮겨도 고름 유지 · 부재키 그대로). 다른 것을 골랐으면 그대로. */ +export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void { + const picked = readSelection(projectId); + if (!picked || picked.chainageM === null || !isNear(picked.chainageM, fromM)) return; + const sid = picked.structureId; + if (sid !== null && !sid.startsWith("pipe-")) return; + selectDesign( + projectId, + { chainageM: toM, structureId: sid === null ? null : `pipe-${toM.toFixed(2)}` }, + { partKey: picked.partKey }, + ); +} diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts new file mode 100644 index 000000000..b61eec9e9 --- /dev/null +++ b/A00_Common/design/design_structure_status.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * A00_Common/design/design_structure_status.ts + * 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션) + * → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) → + * 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴). + * + * 구조물 id = 구조물은 structure_id · 관은 자리 이름 `pipe-{누가거리 2자리}`(파일 모양 그대로 · 저장 안 함). + * ========================================================================== */ + +import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; +import type { PlannedStation } from "../../common_util/common_util_structure_station"; + +/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */ +export function structureStatus( + saved: readonly [ReadonlyArray

, ReadonlyArray], + drafts: readonly [ReadonlyArray

| null, ReadonlyArray | null], +): { pipes: P[]; structures: S[] } { + return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] }; +} + +/** 관 id(현황 · 알약 · 고름이 같은 값) — 자리 이름. */ +export const pipeId = (chainageM: number): string => `pipe-${chainageM.toFixed(2)}`; + +/** + * 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` · + * 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 = + * 「배관」 꼴(관 끌기 · 지우기 길). + */ +export function plannedIrregular( + planned: ReadonlyArray, + intervalM: number, +): IrregularStation[] { + const interval = intervalM > 0 ? intervalM : 20; + return planned.map((station) => { + const owner = station.owners[0]; + const pipe = owner.startsWith("pipe-") && station.role === "anchor"; + const number = Math.floor(station.chainage_m / interval); + return { + id: station.role === "anchor" ? owner : `${owner}:${station.role}`, + station: number, + remainder: station.chainage_m - number * interval, + chainage_m: station.chainage_m, + structure: station.title, + ...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}), + }; + }); +} diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index fe849d939..419c455fc 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -17,6 +17,7 @@ import { leaveForDashboard } from "../b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../b_page_state"; import { showToast } from "@ui/ui_template_elements"; import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations"; +import type { PlanPipe } from "../../common_util/common_util_structure_station"; import { onStaleStructureTypes, type StructureInstance, @@ -136,7 +137,12 @@ export async function mountCrossView( // 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b). let structureMarksSink: - ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; + | (( + structures: StructureInstance[], + types: StructureType[], + pipes: ReadonlyArray, + ) => void) + | null = null; /** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */ let pipeReloadTimer = 0; let wallAdjustTimer = 0; @@ -144,8 +150,8 @@ export async function mountCrossView( projectId, shared, // 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4). - onMarks: (structures, types) => { - structureMarksSink?.(structures, types); + onMarks: (structures, types, pipes) => { + structureMarksSink?.(structures, types, pipes); syncBermSpans(structures); }, // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06). @@ -338,7 +344,8 @@ export async function mountCrossView( shared, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). - structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); + structureMarksSink = (structures, types, pipes) => + sectionView.setStructureMarks(structures, types, pipes); // 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙. // 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로 diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index ae637bc1b..b9e354689 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -25,7 +25,6 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import ( ) from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import ( - is_station_planting_type, structure_type_map, ) from common_util.common_util_drainage_pipes import ( @@ -41,7 +40,8 @@ from common_util.common_util_route_geometry import ( planned_route_initial_path, planned_route_working_path, ) -from common_util.common_util_structure_catalog import card_title, catalog, catalog_row +from common_util.common_util_structure_catalog import card_title, catalog +from common_util.common_util_structure_station import plan_stations from common_util.common_util_surface_sampler import build_surface_sampler from config.config_system import ( DRAINAGE_CACHE_DIRNAME, @@ -259,52 +259,20 @@ def resolve_extra_stations( 물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1). - 라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를 - 되짚기 때문이다(B05_Profile_Structures_Migration). - TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) · - 거울 시험 = `resources/tester/test_272_1_station_list.py`. + 측점 · 이름 = 측점 계획 한 벌(322-3 · `plan_stations` · TS `planStations` 짝) — 표 횡단도 + (anchor = 기준 한 곳 · span3 = 시작 · 기준 · 끝) · 카드 이름 · 같은 m(0.005) 는 하나(이름 = 관이 + 먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다. + 거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`. """ - types = structure_type_map() - derived: list[tuple[float, str]] = [] - for pipe in pipes: - label = pipe_station_label(pipe, types) - if label is None: - continue - # 표 횡단도(322-1) — span3(독립 기슭막이) = **시작·기준·종료** 세 측점(길수록 횡단도가 - # 여러 장) · anchor(그 밖 관 시설) = 기준 한 곳(관이 노선을 가로지르는 점형) · none = 없음. - station = (catalog_row(pipe.facility) or {}).get("station", "anchor") - if station == "none": - continue - if station == "span3": - marks = (pipe.start_m, pipe.chainage_m, pipe.end_m) - else: - marks = (pipe.chainage_m,) - for mark in marks: - if mark is not None: - derived.append((float(mark), label)) - # 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은 - # structures.json에 저장되지 않아 중복되지 않는다. - # · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳. - # · 구간형(D군 기슭막이): **시작·기준·종료** — 길이가 길수록 횡단도가 여러 장 - # 나와야 한다(2026-08-28 사용자). 격자와 같은 정수 미터면 generate_sections가 - # 격자로 스냅해 파일이 겹치지 않는다. try: - for structure in load_structures(str(project_root))[1]: - definition = types.get(structure.type_id) - if definition is None or definition.managed_by: - continue - if not is_station_planting_type(definition): - continue - if catalog_row(structure.type_id)["station"] == "span3": - marks = (structure.start_m, structure.chainage_m, structure.end_m) - else: - marks = (structure.anchor_m(),) - for mark in marks: - if mark is not None: - derived.append((float(mark), definition.name)) + structures = load_structures(str(project_root))[1] except Exception: logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)") - return tuple(sorted(derived)) + structures = [] + return tuple( + (float(station["chainage_m"]), str(station["title"])) + for station in plan_stations(pipes, structures) + ) def _append_design_profiles( diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index cbdcb9202..5d37d7eae 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -152,11 +152,6 @@ def is_station_planting_type(item: StructureType) -> bool: return row is not None and row["station"] != "none" -def station_planting_labels() -> set[str]: - """서버가 심는 측점 라벨 집합 — 구조물로 되옮기면 정본이 이중화된다.""" - return {item.name for item in load_structure_types() if is_station_planting_type(item)} - - #: 지난 판 정의(`판_N.json`) 폴더 — 정본 옆(M02 `M02_SO_Definition.HISTORY` 와 같은 이름) HISTORY_DIRNAME = "구조물정의_이력" diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 74f3c0574..6b4e43dad 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -30,7 +30,7 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; -import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { snapStructureStation } from "../common_util/common_util_structure_station"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; @@ -584,6 +584,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (index < 0) return; const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게) facilityStore.move(pipeEditor.pipes()[index].chainage_m, to); + followMovedPipe(projectId, pipeEditor.pipes()[index].chainage_m, to); // 고름 유지(322-3) pipeEditor.moveTo(index, to); }, selectBasinByChainage(chainageM) { @@ -637,6 +638,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const index = pipeEditor .pipes() .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M); + followMovedPipe(projectId, fromChainageM, toChainageM); // 고름 유지(322-3) if (index >= 0) pipeEditor.moveTo(index, toChainageM); else void analyze(); } else { diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 1dc736ccc..ee5004ade 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -11,7 +11,6 @@ * 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다. * ========================================================================== */ -import { PIPE_DEFAULT_TYPE, pickPipeDiameter } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -25,25 +24,17 @@ import { writePendingStructures, StructureConflictError, type StructureInstance, + type StructureType, } from "./B05_Profile_Api_Structures"; -import { - isPipeStation, - structureLabel, - type IrregularStation, -} from "./B05_Profile_UI_IrregularStations"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; -import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers"; import { followedStation } from "./B05_Profile_UI_Profile_Alignment"; import { followSnapped } from "./B05_Profile_UI_Structures_Snap"; import { plantsStation } from "./B05_Profile_UI_Profile_Structures"; import { listPick } from "../common_util/common_util_structure_catalog"; -import { - pipeStationMarks, - SAME_STATION_M, - structureStationMarks, - type StationMark, -} from "../common_util/common_util_structure_station"; +import { planStations } from "../common_util/common_util_structure_station"; +import { plannedIrregular } from "../A00_Common/design/design_structure_status"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -68,56 +59,12 @@ export interface StructuresBridgeDeps { } export function createStructuresBridge(deps: StructuresBridgeDeps) { - /** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */ + /** 비정규 측점(그래프 세로선·3D 라벨) — 측점 계획 한 벌(관 · 측점 심는 구조물 · 322-3). */ let irregularStations: IrregularStation[] = []; - /** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */ - function stationAt( - chainage: number, - ): Pick { - const interval = deps.panel().values().stationInterval || 20; - const station = Math.floor(chainage / interval); - return { station, remainder: chainage - station * interval, chainage_m: chainage }; - } - - /** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서 - * 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */ - const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] => - marks - .filter((mark) => mark.role !== "anchor") - .map((mark) => ({ - id: `${id}:${mark.role}`, - ...stationAt(mark.chainage_m), - structure: label, - })); - - function pipesToStations(pipes: ReadonlyArray): IrregularStation[] { - return pipes.flatMap((pipe) => { - const label = - pipe.facility === "pipe" - ? structureLabel({ - structureType: "배관", - pipeType: PIPE_DEFAULT_TYPE, - diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm), - }) - : FACILITY_NAMES[pipe.facility]; - const id = `pipe-${pipe.chainage_m.toFixed(2)}`; - return [ - { - id, - ...stationAt(pipe.chainage_m), - structure: label, - structureType: "배관" as const, - origin: "pipe" as const, - }, - ...spanStations(id, label, pipeStationMarks(pipe)), - ]; - }); - } - /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ function clearProjectedStations(): void { - pipeStations = []; + planPipes = []; irregularStations = []; deps.renderStationLines(); deps.profilePanel().setIrregularStations([]); @@ -142,11 +89,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { irregularStations = stations; deps.renderStationLines(); deps.profilePanel().setIrregularStations(stations); - // 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다. - // 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다. - deps - .profilePanel() - .drainage.setPipeChainages(stations.filter(isPipeStation).map((entry) => entry.chainage_m)); + // 배수유역도로 되먹이지 않음(322-3) — 관 고침 · 지움은 폼 콜백이 배수 패널로 바로 감(관 정본이 원천) } /* ── 구조물 정본(structures.json) ──────────────────────────────────── @@ -162,41 +105,22 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { * 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅 * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ - let pipeStations: IrregularStation[] = []; + /** 측점 계획 입력 — 관(배수 패널이 알려 줌) · 종류 표(정의 기본값 · 카드 이름). */ + let planPipes: PipeProjection[] = []; + let planTypes: StructureType[] = []; let structureTypeMap = new Map< string, { type_id: string; group: string; name: string; placement: string; managed_by: string | null } >(); - /** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations` - * 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */ - function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] { - return structures.flatMap((structure) => { - const type = structureTypeMap.get(structure.type_id); - const marks = structureStationMarks(structure, type); - const anchor = marks.find((mark) => mark.role === "anchor"); - if (!anchor) return []; - const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`; - const label = type?.name ?? structure.type_id; - return [ - { id, ...stationAt(anchor.chainage_m), structure: label }, - ...spanStations(id, label, marks), - ]; - }); - } - - /** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이 - * 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을 - * 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */ + /** 그래프 · 3D 측점 = 측점 계획 한 벌(322-3 · 카드 · B06 종단 · 서버와 같은 목록 · 같은 m 하나 · 관이 먼저). + * 종류 표를 받기 전이면 미룸(받으면 다시 맞춤). */ function syncCrossDrainStations(): void { - const kept: IrregularStation[] = []; - for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) { - const taken = - station.origin !== "pipe" && - kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M); - if (!taken) kept.push(station); - } - applyIrregularStations(kept); + if (!planTypes.length) return; + const interval = deps.panel().values().stationInterval || 20; + applyIrregularStations( + plannedIrregular(planStations(planPipes, ownStructures, planTypes), interval), + ); } /** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null). @@ -356,6 +280,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ]); deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1) deps.profilePanel().setStructureTypes(allTypes); + planTypes = allTypes; // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( allTypes.map((type) => [ @@ -410,7 +335,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ setPipes(pipes: PipeProjection[]): void { - pipeStations = pipesToStations(pipes); + planPipes = [...pipes]; syncCrossDrainStations(); pipeMarks = pipesToMarks(pipes); syncGraphStructures(); diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index 9956f49b5..f0e7fc6db 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -138,11 +138,12 @@ def _station_chainages(project_root: Path) -> list[float]: def _stale_marks(project_root: Path, route_data_path: str) -> int: """종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이 - 떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음.""" + 떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272) · 같은 자리 다른 종류 · 이름(322-3 · + 관 공식 · 정의 판이 바뀐 이름 포함 — 이름이 지금 정의 판에서 옴). 0 이면 맞음.""" import json - planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)} - standing: set[float] = set() + planted = {(round(value, 2), label) for value, label in _planted(project_root, route_data_path)} + standing: set[tuple[float, str]] = set() for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")): try: data = json.loads(path.read_text(encoding="utf-8")) @@ -150,7 +151,8 @@ def _stale_marks(project_root: Path, route_data_path: str) -> int: continue for station in data.get("stations") or []: if station.get("kind") == "irregular" or station.get("structure"): - standing.add(round(float(station.get("chainage_m") or 0.0), 2)) + at = round(float(station.get("chainage_m") or 0.0), 2) + standing.add((at, str(station.get("structure") or ""))) return len(standing ^ planted) diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index c72eb4631..9eb580c75 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -7,8 +7,9 @@ * · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB * 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음. * - * 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙 - * (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`. + * 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) · + * 관 그림 = 짝 산식 `common_util_culvert_sets`. 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 + * 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3). * ========================================================================== */ import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -27,11 +28,11 @@ import { } from "@util/common_util_culvert_sets"; import { pipeStationMarks, - plantedChainages, + planStations, SAME_STATION_M, structureStationMarks, + type PlannedStation, } from "../common_util/common_util_structure_station"; -import { cardTitle } from "../common_util/common_util_structure_catalog"; import { previewStructureSections, type CrossSection, @@ -122,31 +123,15 @@ function bareOf(section: CrossSection): CrossSection { function decorate( fresh: CrossSection[], pipes: Pipes, - structures: StructureInstance[], + planned: readonly PlannedStation[], types: StructureType[], ): void { const registry = registryDefaults(types); - const byId = new Map(types.map((type) => [type.type_id, type])); - // 카드 이름 = 표 카드 이름(322-1 · 배관 = 관종 D관경 · 빈칸 = 그 종류 정의 기본값 · 그 밖 = 종류 이름) - const labels: Array<[number, string]> = [ - ...pipes.flatMap((pipe) => { - const facility = pipe.facility ?? "pipe"; - const label = cardTitle(facility, pipe.options, registry[facility]); - return pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label]); - }), - ...structures.flatMap((structure) => { - const type = byId.get(structure.type_id); - const label = cardTitle(structure.type_id, structure.options); - 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]; + // 카드 이름 = 측점 계획 이름(322-3 · 같은 m 는 관이 먼저) + const hit = planned.find((station) => near(station.chainage_m, section.chainage_m)); + if (hit) section.structure = hit.title; else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217) } const own = pipes.filter((pipe) => @@ -192,7 +177,8 @@ export async function withDraftStations( ]).catch(() => null); if (inputs) { const [pipes, structures, types] = inputs; - const planted = plantedChainages(pipes, structures, types); + const planned = planStations(pipes, structures, types, registryDefaults(types)); + const planted = planned.map((station) => station.chainage_m); // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 const [oldPipes, oldStructures] = await savedOf(projectId).catch( @@ -222,16 +208,25 @@ export async function withDraftStations( (section.grid ? replaced(section) : planted.every((at) => !near(at, section.chainage_m)) || replaced(section)); - stale = all.filter(isStale); - const bare = stale.filter((section) => section.grid).map(bareOf); - live = [...all.filter((section) => !isStale(section)), ...bare]; + // 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물 = 그 자리 구조물 없는 사본에 이름 · 형상(219) — + // 구조물 측점(irregular)은 위 종류 대조가 맡음 · 옛 것은 빼 둠(초안이 없어지면 돌아옴) + const retitled = all.filter( + (section) => + section.kind !== "irregular" && + planned.some((station) => near(station.chainage_m, section.chainage_m)), + ); + stale = [...all.filter(isStale), ...retitled]; + const bare = [...all.filter(isStale).filter((section) => section.grid), ...retitled].map( + bareOf, + ); + live = [...all.filter((section) => !stale.includes(section)), ...bare]; 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 || bare.length) decorate([...fresh, ...bare], pipes, structures, types); + if (fresh.length || bare.length) decorate([...fresh, ...bare], pipes, planned, types); } } if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 58fdee889..d013f7967 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -45,6 +45,7 @@ import { coversChainage, NO_SELECTION, PIPE_MATCH_M, + followMovedPipe, readSelection, resolveSelection, selectDesign, @@ -97,7 +98,12 @@ export interface B06StructuresPanelDeps { /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */ markStructure?: (structureId: string | null, span?: boolean) => void; /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ - onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; + /** `pipes` = 관 지점(구간 포함) — 종단 띠 측점 계획(322-3). */ + onMarks?: ( + structures: StructureInstance[], + types: StructureType[], + pipes: ReadonlyArray, + ) => void; /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ applyPipeOptions?: (chainageM: number, patch: Record) => void; @@ -193,7 +199,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => - deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); + deps.onMarks?.( + [...structures, ...pipesToStructureMarks(pipeFacilities)], + markTypes, + pipeFacilities, + ); // 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은 // 한 벌이 보기에 따라 그린다(횡단 보기에서만). const section = sharedStructures(deps.shared); @@ -507,6 +517,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M) currentChainageM = to; + followMovedPipe(deps.projectId, fromChainageM, to); // 고른 관이면 고름도 새 자리로(322-3) pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); persistPipes(); section.setPipeFacilities(pipeFacilities); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index ebe180f12..88a6567b7 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -642,11 +642,11 @@ export function createSectionView( setStructureEdit(edit) { structureEdit = edit; }, - setStructureMarks(structures, types) { + setStructureMarks(structures, types, pipes) { spanMarks = structures; spanTypes = types; syncSpanHighlights(); - profile.setMarks(structures, types); + profile.setMarks(structures, types, pipes); // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 // (`__corridorBuild` 등과 같은 용도). (window as unknown as { __b06Marks?: unknown }).__b06Marks = { diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index a6ece2772..50611b2e8 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -47,6 +47,8 @@ import { moveMarkById, type SectionStructureEdit, } from "./B06_Section_UI_Section_View_Menu"; +import { planStations, type PlanPipe } from "../common_util/common_util_structure_station"; +import { plannedIrregular } from "../A00_Common/design/design_structure_status"; /** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ const MATCH_M = PIPE_MATCH_M; @@ -79,6 +81,7 @@ export interface SectionProfile { setMarks: ( structures: ReadonlyArray, types: ReadonlyArray, + pipes?: ReadonlyArray, ) => void; /** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */ redraw: () => void; @@ -88,25 +91,23 @@ export interface SectionProfile { selectMark: (structureId: string | null) => void; } -/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */ -function pipeStations( +/** 종단 측점선 = 측점 계획 한 벌(322-3 · 관 · 측점 심는 구조물 모두 · 종단 보기 · 카드와 같은 목록). + * 관 구간(독립 기슭막이 시작 · 끝)은 받은 관 목록에서 · 없으면 알약에서 기준만. */ +function planMarkStations( marks: ReadonlyArray, + pipes: ReadonlyArray | null, types: ReadonlyArray, intervalM: number, ): IrregularStation[] { - return marks.filter(isPipeMark).map((mark) => { - const chainage = structureAnchorM(mark); - const station = Math.floor(chainage / intervalM); - return { - id: String(mark.structure_id), - station, - remainder: chainage - station * intervalM, - chainage_m: chainage, - structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관", - structureType: "배관", - origin: "pipe", - } as IrregularStation; - }); + const pipeList = + pipes ?? + marks.filter(isPipeMark).map((mark) => ({ + chainage_m: structureAnchorM(mark), + facility: mark.type_id, + options: mark.options, + })); + const structures = marks.filter((mark) => !isPipeMark(mark)); + return plannedIrregular(planStations(pipeList, structures, types), intervalM); } /** @@ -141,6 +142,7 @@ export function createSectionProfile( let profile: DesignProfile | null = null; let detail: SectionDetailResponse | null = null; let marks: ReadonlyArray = []; + let markPipes: ReadonlyArray | null = null; /** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */ let markedId: string | null = null; let types: ReadonlyArray = []; @@ -180,7 +182,7 @@ export function createSectionProfile( if (!profile) return; profile.setStructureTypes([...types]); profile.setStructures([...marks]); - stations = pipeStations(marks, types, intervalM); + stations = planMarkStations(marks, markPipes, types, intervalM); profile.setIrregularStations(stations); // 판(배수유역도)이 없는 보기 — 관 목록을 직접 넘겨 최소 흙덮기를 판정한다. profile.setMinCoverPipes( @@ -302,9 +304,10 @@ export function createSectionProfile( } syncMarks(); }, - setMarks(nextMarks, nextTypes) { + setMarks(nextMarks, nextTypes, nextPipes) { marks = nextMarks; types = nextTypes; + markPipes = nextPipes ?? null; syncMarks(); }, redraw() { diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 5a96a7a4d..a4f4c7464 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -11,6 +11,7 @@ import type { } from "./B06_Section_Api_Fetch"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { PlanPipe } from "../common_util/common_util_structure_station"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; /** @@ -64,6 +65,8 @@ export interface SectionViewController { setStructureMarks: ( structures: ReadonlyArray, types: ReadonlyArray, + /** 관 지점(구간 포함) — 종단 띠 측점 계획(322-3 · 없으면 알약에서 기준만). */ + pipes?: ReadonlyArray, ) => void; /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */ setStructureEdit: (edit: SectionStructureEdit | null) => void; diff --git a/common_util/common_util_structure_catalog.py b/common_util/common_util_structure_catalog.py index 289156c93..c58199897 100644 --- a/common_util/common_util_structure_catalog.py +++ b/common_util/common_util_structure_catalog.py @@ -99,6 +99,13 @@ def type_ids_with(**match: str) -> set[str]: return {key for key, row in catalog().items() if all(row[k] == v for k, v in match.items())} +def shape_needs(type_id: str) -> list[str]: + """형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — TS `shapeNeeds` 짝.""" + row = catalog_row(type_id) + part = load_parts()["parts"]["형상"].get(row["shape"]) if row else None + return list(part.get("필수 칸", [])) if part else [] + + def card_title(type_id: str, options: dict[str, Any] | None) -> str | None: """카드 · 측점 이름 — 카드이름 pipe = 「관종 D관경」(빈칸 = 그 종류 정의 기본값) · 그 밖 = 이름. diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index f26739e63..cd0a0a0c4 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -170,5 +170,12 @@ export function cardTitle( return `${value("pipe_kind") ?? ""} D${Math.trunc(Number(value("pipe_diameter_mm"))) || 0}`; } +/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */ +export function shapeNeeds(typeId: string): readonly string[] { + const shape = catalogRow(typeId)?.shape ?? "none"; + const box = (PARTS as unknown as Parts).parts.형상 as Record; + return Object.hasOwn(box, shape) ? (box[shape]["필수 칸"] ?? []) : []; +} + /** 관 공식 이름의 관종들(정의 선택지) — 표시 이름이 「배수관」으로 줄일 이름을 알아봄. */ export const pipeTitleKinds = (): readonly string[] => pipeKinds; diff --git a/common_util/common_util_structure_parts.json b/common_util/common_util_structure_parts.json index ceef3a46b..4eab9edb8 100644 --- a/common_util/common_util_structure_parts.json +++ b/common_util/common_util_structure_parts.json @@ -1,5 +1,5 @@ { - "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", + "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", "parts": { "횡단도": { "anchor": { @@ -48,7 +48,8 @@ "outlet_revet_height_m" ], "자리꼴": ["point"], - "정본": ["pipe_points"] + "정본": ["pipe_points"], + "필수 칸": ["pipe_diameter_mm"] }, "box": { "이름": "BOX 본체 · 날개벽", @@ -93,7 +94,8 @@ "이름": "물넘이 포장(파인 노면 · 도수로)", "읽는 칸": ["ford_width_m", "ford_height_m", "ford_slope_deg", "ford_slope_pct"], "자리꼴": ["point"], - "정본": ["pipe_points"] + "정본": ["pipe_points"], + "필수 칸": ["ford_height_m"] }, "hidden_revet": { "이름": "독립 기슭막이(관 숨김 벽)", @@ -110,7 +112,8 @@ "face_slope_ratio" ], "자리꼴": ["interval"], - "정본": ["pipe_points"] + "정본": ["pipe_points"], + "필수 칸": ["height_m"] }, "wall": { "이름": "벽(옹벽 · 흙막이 · 돌쌓기)", @@ -126,7 +129,8 @@ "face_slope_ratio" ], "자리꼴": ["interval"], - "정본": ["structures"] + "정본": ["structures"], + "필수 칸": ["height_m"] }, "berm": { "이름": "소단(사면 계단)", @@ -138,7 +142,8 @@ "이름": "맹암거", "읽는 칸": ["culvert_height_m", "culvert_slope_pct"], "자리꼴": ["point"], - "정본": ["structures"] + "정본": ["structures"], + "필수 칸": ["culvert_height_m"] }, "pavement": { "이름": "포장층", @@ -289,7 +294,10 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "콘크리트", "weep": true }, + "형상칸": { + "form": "콘크리트", + "weep": true + }, "3D": "wall" }, "masonry_wet": { @@ -297,7 +305,9 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "돌쌓기(찰)" }, + "형상칸": { + "form": "돌쌓기(찰)" + }, "3D": "wall" }, "masonry_dry": { @@ -305,7 +315,9 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "돌쌓기(메)" }, + "형상칸": { + "form": "돌쌓기(메)" + }, "3D": "wall" }, "soil_guard": { @@ -313,7 +325,9 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "통나무·목재틀" }, + "형상칸": { + "form": "통나무·목재틀" + }, "3D": "wall" }, "boulder_masonry": { @@ -321,7 +335,9 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "돌쌓기(메)" }, + "형상칸": { + "form": "돌쌓기(메)" + }, "3D": "wall" }, "berm": { @@ -395,7 +411,13 @@ "형상": "none", "3D": "none" }, - "curb": { "목록": "both", "횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none" }, + "curb": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, "hazard_sign": { "목록": "both", "횡단도": "none", @@ -480,6 +502,12 @@ "형상": "pavement", "3D": "surface" }, - "etc": { "목록": "both", "횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none" } + "etc": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + } } } diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 63d2b8622..de0a50429 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -4,7 +4,8 @@ 만들기가 한 측점으로 합침(구조물 측점이 위 · `B05_Profile_Engine_Sections_Core`). 234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167). TS 짝 = `common_util/common_util_structure_station.ts` -(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김). +(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김 · +측점 계획 `plan_stations` ↔ `planStations` · 322-3). 거울 시험 = `resources/tester/test_234_1_structure_station.py`. 일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀. """ @@ -67,3 +68,100 @@ def snap_candidates( x, y, _ = interpolate_vertex(list(vertices), at) snapped.append(replace(candidate, chainage_m=at, x=x, y=y)) return snapped + + +# ── 측점 계획 한 벌(322-3 · TS `planStations` 짝 · 거울 = test_322_3_plan_mirror.py) ── + +#: 같은 측점으로 볼 거리(m) — TS `SAME_STATION_M` 짝 +SAME_STATION_M = 0.005 + + +def _empty(value: object) -> bool: + return value is None or value == "" + + +def _station_marks( + start: object, anchor: object, end: object, span3: bool +) -> list[tuple[str, float]]: + """TS `marksOf` 짝 — 수인 자리만 (역할, 누가거리).""" + marks = [("start", start), ("anchor", anchor), ("end", end)] if span3 else [("anchor", anchor)] + return [ + (role, float(at)) + for role, at in marks + if isinstance(at, (int, float)) and not isinstance(at, bool) and math.isfinite(at) + ] + + +def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list[dict[str, object]]: + """관 · 구조물(현황) + 구조물 표 → 측점 계획 `[{chainage_m, role, title, owners, blank}]`. + + 오름차순 · 같은 m(0.005) 는 하나 — 이름 = 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름) + · 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상 + 부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌. + """ + from common_util.common_util_structure_catalog import card_title, catalog_row, shape_needs + + def blank(type_id: str, options: dict | None) -> list[str]: + """형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝).""" + return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))] + + marks: list[dict[str, object]] = [] + for pipe in pipes: + facility = getattr(pipe, "facility", None) or "pipe" + row = catalog_row(facility) + if row is None: + continue + options = getattr(pipe, "options", None) + chainage = float(getattr(pipe, "chainage_m")) + station = row["station"] + for role, at in ( + [] + if station == "none" + else _station_marks( + getattr(pipe, "start_m", None), + chainage, + getattr(pipe, "end_m", None), + station == "span3", + ) + ): + marks.append( + { + "chainage_m": at, + "role": role, + "title": card_title(facility, options), + "owner": getattr(pipe, "id", None) or f"pipe-{chainage:.2f}", + "blank": blank(facility, options), + "pipe": True, + } + ) + for item in structures: + row = catalog_row(item.type_id) + if row is None or row["owner"] == "pipe_points" or row["station"] == "none": + continue + anchor = item.chainage_m if item.chainage_m is not None else item.start_m + found = _station_marks(item.start_m, anchor, item.end_m, row["station"] == "span3") + base = next((at for role, at in found if role == "anchor"), 0.0) + for role, at in found: + marks.append( + { + "chainage_m": at, + "role": role, + "title": card_title(item.type_id, item.options), + "owner": item.structure_id or f"structure-{base:.2f}", + "blank": blank(item.type_id, item.options), + "pipe": False, + } + ) + marks.sort(key=lambda mark: mark["chainage_m"]) + out: list[dict[str, object]] = [] + for mark in marks: + last = out[-1] if out else None + if last is not None and abs(mark["chainage_m"] - last["chainage_m"]) < SAME_STATION_M: + if mark["pipe"] and not last["pipe"]: + last["title"], last["pipe"] = mark["title"], True + if mark["owner"] not in last["owners"]: + last["owners"].append(mark["owner"]) + last["blank"] += [key for key in mark["blank"] if key not in last["blank"]] + continue + out.append({**mark, "owners": [mark["owner"]], "blank": list(mark["blank"])}) + return [{k: v for k, v in station.items() if k not in ("pipe", "owner")} for station in out] diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 43bd4c06e..80c594972 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -11,6 +11,11 @@ * * 심는 측점 = 구조물 표(catalog) 횡단도 부품(322-1) — anchor = 기준 하나 · span3 = 시작 · 기준 · 끝 · none = 없음. * + * 측점 계획 한 벌(322-3 · `planStations`) — 관 · 구조물(현황) + 표 → 측점 · 역할 · 카드 이름 · 덮는 구조물 id · + * 빈칸 경고 · 같은 m 는 하나(관이 먼저 · 이름 = 관 → 앞선 것). 파이썬 짝 = `common_util_structure_station.py` + * `plan_stations`(서버 `resolve_extra_stations`) · 거울 시험 = `resources/tester/test_322_3_plan_mirror.py`. + * 카드 목록 · B05 종단 · B06 종단 띠 · 3D 막대 · 미리 뜬 횡단 이름이 이 목록 하나를 씀. + * * 측점 한 벌(272) — 측점을 심는 관 · 구조물(`plantedChainages`) = 서버 `resolve_extra_stations` * (B05_Profile_Engine_Sections.py) 짝 · 같은 m 면 측점 하나 · 구조물이 위(`oneStationList`) = 서버 * `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 = @@ -18,7 +23,7 @@ * 이 목록 하나를 씀. * ========================================================================== */ -import { catalogRow } from "./common_util_structure_catalog"; +import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog"; const EPS = 1e-6; @@ -133,21 +138,100 @@ export function structureStationMarks( return marksOf(structure.start_m, anchor, structure.end_m, row.station === "span3"); } -/** 측점을 심는 자리 전부(오름차순 · 겹침 그대로) — 서버 `resolve_extra_stations` 의 누가거리. */ -export function plantedChainages( - pipes: ReadonlyArray, - structures: ReadonlyArray, - types: ReadonlyArray, -): number[] { +/* ── 측점 계획 한 벌(322-3 · 파이썬 `plan_stations` 짝) ── */ + +export interface PlanPipe extends PlantingPipe { + /** 관 세션 id — 없으면 자리로 지음(`pipe-{누가거리 2자리}`). */ + id?: string | null; + options?: Record | null; +} + +export interface PlanStructure extends PlantingStructure { + structure_id?: string | null; + options?: Record | null; +} + +export interface PlanType extends PlantingType { + options?: ReadonlyArray<{ key: string; default?: unknown }>; +} + +/** 측점 한 곳 — 같은 m 에 놓인 관 · 구조물을 함께 덮음. */ +export interface PlannedStation { + chainage_m: number; + role: StationMark["role"]; + /** 카드 · 측점 이름 — 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름). */ + title: string; + /** 덮는 구조물 id(관 = 세션 id · 구조물 = structure_id). */ + owners: string[]; + /** 빈칸 경고 — 형상 부품 필수 칸 중 값이 빈 옵션 키(비면 그 그림이 안 섬). */ + blank: string[]; +} + +const empty = (value: unknown): boolean => value === undefined || value === null || value === ""; + +/** 형상 부품 필수 칸 중 값이 빈 키(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 — 정의 기본값으로 안 채움). */ +const blankKeys = (typeId: string, options: Record | null | undefined): string[] => + shapeNeeds(typeId).filter((key) => empty(options?.[key])); + +/** 관 · 구조물 → 측점 계획(오름차순 · 같은 m 하나). 표에 없는 종류는 뺌. `registry` = 종류 → 옵션 + * 기본값(카드 이름 빈칸 · 없으면 `types` 정의 기본값 · 횡단 관 세트 `registryDefaults` 와 같은 표). */ +export function planStations( + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, + registry?: Readonly>>, +): PlannedStation[] { const byId = new Map(types.map((type) => [type.type_id, type])); - return [ - ...pipes.flatMap(pipeStationMarks), - ...structures.flatMap((structure) => - structureStationMarks(structure, byId.get(structure.type_id)), - ), - ] - .map((mark) => mark.chainage_m) - .sort((a, b) => a - b); + const defaults = (typeId: string): Record => + registry?.[typeId] ?? + Object.fromEntries((byId.get(typeId)?.options ?? []).map((o) => [o.key, o.default])); + type Mark = StationMark & { title: string; owner: string; blank: string[]; pipe: boolean }; + const marks: Mark[] = [ + ...pipes.flatMap((pipe) => { + const facility = pipe.facility ?? "pipe"; + if (!catalogRow(facility)) return []; + const title = cardTitle(facility, pipe.options, defaults(facility)); + const owner = pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`; + const blank = blankKeys(facility, pipe.options); + return pipeStationMarks(pipe).map((m) => ({ ...m, title, owner, blank, pipe: true })); + }), + ...structures.flatMap((structure) => { + const marks = structureStationMarks(structure, byId.get(structure.type_id)); + const anchor = marks.find((m) => m.role === "anchor")?.chainage_m ?? 0; + const title = cardTitle(structure.type_id, structure.options, defaults(structure.type_id)); + const owner = structure.structure_id ?? `structure-${anchor.toFixed(2)}`; + const blank = blankKeys(structure.type_id, structure.options); + return marks.map((m) => ({ ...m, title, owner, blank, pipe: false })); + }), + ].sort((a, b) => a.chainage_m - b.chainage_m); + const out: Array = []; + for (const mark of marks) { + const last = out[out.length - 1]; + if (last && Math.abs(mark.chainage_m - last.chainage_m) < SAME_STATION_M) { + if (mark.pipe && !last.pipe) Object.assign(last, { title: mark.title, pipe: true }); + if (!last.owners.includes(mark.owner)) last.owners.push(mark.owner); + for (const key of mark.blank) if (!last.blank.includes(key)) last.blank.push(key); + continue; + } + out.push({ + chainage_m: mark.chainage_m, + role: mark.role, + title: mark.title, + owners: [mark.owner], + blank: [...mark.blank], + pipe: mark.pipe, + }); + } + return out.map(({ pipe: _pipe, ...station }) => station); +} + +/** 측점을 심는 자리 전부(오름차순 · 같은 m 하나) — 측점 계획의 누가거리(서버 `resolve_extra_stations`). */ +export function plantedChainages( + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, +): number[] { + return planStations(pipes, structures, types).map((station) => station.chainage_m); } /* ── 측점 한 벌(272 · 서버 `_merge_irregular_into_longitudinal` 짝) ── */ diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index 054576849..d69a3482d 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`); @@ -208,6 +209,7 @@ function makeLoader(real, stubs = {}) { const box = { exports: {} }; cache.set(full, box.exports); const need = (spec) => { + if (CATALOG.isCatalogRequest(spec)) return CATALOG; if (spec in stubs) return stubs[spec]; let target = null; if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`); diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs new file mode 100644 index 000000000..c4b15cf4e --- /dev/null +++ b/resources/tester/helper_322_3_flow.cjs @@ -0,0 +1,196 @@ +/* PLAN 322-3 카드 목록 흐름 헬퍼 — 진짜 `B06_Section_Section_Drafts.ts`(withDraftStations) + 측점 계획 · + * 구조물 표 · 관 세트 산식을 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 세션만 흉내) · 결과 = 표준출력 JSON. + * 흐름 일곱 = 놓기 · 일반 측점에 놓기(219) · 옮기기 · 같은 군 바꿈 · 정본 넘는 바꿈 · 지우기 · 저장 뒤 다시 열기. + * 짝 = `test_322_3_flow.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const DEF = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const TYPES = DEF.types.map((t) => ({ managed_by: null, enabled: true, ...t })); + +const state = { savedPipes: [], savedStructures: [], draftPipes: null, draftStructures: null }; +const memory = new Map(); +const store = { + getItem: (k) => (memory.has(k) ? memory.get(k) : null), + setItem: (k, v) => memory.set(k, String(v)), + removeItem: (k) => memory.delete(k), + key: (i) => [...memory.keys()][i] ?? null, + get length() { + return memory.size; + }, +}; +globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} }; +globalThis.sessionStorage = store; +globalThis.localStorage = store; +const file = (...parts) => path.resolve(ROOT, ...parts); +const STUBS = new Map([ + [ + file("B04_PreProcess", "B04_PreProcess_Api_Fetch.ts"), + { fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }) }, + ], + [ + file("B05_Profile", "B05_Profile_Api_Structures.ts"), + { + fetchStructures: async () => ({ structures: state.savedStructures }), + fetchStructureTypes: async () => TYPES, + readPendingStructures: () => state.draftStructures, + }, + ], + [ + file("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts"), + { readPendingPipes: () => state.draftPipes }, + ], + [ + file("B06_Section", "B06_Section_Api_Fetch.ts"), + { + previewStructureSections: async (_p, chainages) => + chainages.map((at) => ({ chainage_m: at, kind: "irregular", station_id: `p${at}` })), + }, + ], +]); +const cache = new Map(); +function load(target) { + const full = path.resolve(target); + if (STUBS.has(full)) return STUBS.get(full); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + const alias = spec.startsWith("@util/") ? path.join(ROOT, "common_util", spec.slice(6)) : null; + const base = alias ?? path.join(path.dirname(full), spec); + const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find( + (p) => + (STUBS.has(path.resolve(p)) || fs.existsSync(p)) && + !fs.statSync(p, { throwIfNoEntry: false })?.isDirectory(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +load(file("common_util", "common_util_structure_catalog.ts")).useCatalogDefinition(DEF); +const drafts = load(file("B06_Section", "B06_Section_Section_Drafts.ts")); + +const grid = () => + Array.from({ length: 11 }, (_v, i) => ({ + chainage_m: i * 20, + kind: "regular", + station_id: `g${i * 20}`, + })); +const irregular = (at, structure, extra = {}) => ({ + chainage_m: at, + kind: "irregular", + station_id: `i${at}`, + structure, + ...extra, +}); +const wall = (type_id, id = "w1") => ({ + structure_id: id, + type_id, + placement: "interval", + start_m: 93, + chainage_m: 101, + end_m: 113, + options: { height_m: 3 }, +}); +const summary = (detail) => + detail.cross_sections.map((s) => [ + s.chainage_m, + s.structure ?? null, + !!s.preview, + ["culvert", "ford", "box"].filter((k) => s[k]), + ]); + +let route = 0; +async function run(setup, detail) { + Object.assign(state, { + savedPipes: [], + savedStructures: [], + draftPipes: null, + draftStructures: null, + ...setup, + }); + drafts.forgetDraftStations("P"); + route += 1; + await drafts.withDraftStations(detail, "P", route); + return summary(detail); +} + +(async () => { + const out = {}; + out.place = await run({ draftStructures: [wall("retaining_wall")] }, { cross_sections: grid() }); + out.gridPlace = await run( + { + draftStructures: [ + { + structure_id: "u1", + type_id: "cross_underdrain", + placement: "point", + chainage_m: 100, + options: {}, + }, + ], + draftPipes: [{ chainage_m: 120, options: { pipe_diameter_mm: "800" } }], + }, + { cross_sections: grid() }, + ); + const pipeSet = { culvert: { type: "pipe" } }; + out.move = await run( + { + savedPipes: [{ chainage_m: 61, options: { pipe_diameter_mm: "1000" } }], + draftPipes: [{ chainage_m: 75, options: { pipe_diameter_mm: "1000" } }], + }, + { cross_sections: [...grid(), irregular(61, "파형강관 D1000", pipeSet)] }, + ); + const walls = () => [irregular(93, "옹벽"), irregular(101, "옹벽"), irregular(113, "옹벽")]; + out.sameGroup = await run( + { savedStructures: [wall("retaining_wall")], draftStructures: [wall("soil_guard")] }, + { cross_sections: [...grid(), ...walls()] }, + ); + out.crossOwner = await run( + { + savedPipes: [{ chainage_m: 101, options: { pipe_diameter_mm: "1000" } }], + draftPipes: [], + draftStructures: [ + { + structure_id: "u2", + type_id: "cross_underdrain", + placement: "point", + chainage_m: 101, + options: {}, + }, + ], + }, + { cross_sections: [...grid(), irregular(101, "파형강관 D1000", pipeSet)] }, + ); + const kept = { cross_sections: [...grid(), ...walls()] }; + out.remove = await run({ savedStructures: [wall("retaining_wall")], draftStructures: [] }, kept); + // 저장 뒤 다시 열기 — 초안 없음 = 서버 목록 그대로(빼 둔 옛 자리 돌아옴) + out.reopen = await run({ savedStructures: [wall("retaining_wall")] }, kept); + // 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · 다른 구조물 고름은 그대로 + const sel = load(file("A00_Common", "design", "design_selection.ts")); + sel.selectDesign("P", { chainageM: 61, structureId: "pipe-61.00" }); + sel.followMovedPipe("P", 61, 75); + const moved = sel.readSelection("P"); + sel.selectDesign("P", { chainageM: 101, structureId: "w1" }); + sel.followMovedPipe("P", 101, 120); + out.follow = [moved, sel.readSelection("P")]; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/helper_322_3_plan.cjs b/resources/tester/helper_322_3_plan.cjs new file mode 100644 index 000000000..5c10edd8c --- /dev/null +++ b/resources/tester/helper_322_3_plan.cjs @@ -0,0 +1,43 @@ +/* PLAN 322-3 측점 계획 한 벌 거울 헬퍼 — 진짜 `common_util_structure_station.ts`(planStations) · + * `common_util_structure_catalog.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON. + * 입력 = argv[2] JSON `{definition, types, cases: {이름: {pipes, structures}}}`. 짝 = `test_322_3_plan_mirror.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + const base = path.join(path.dirname(full), spec); + const hit = [base, `${base}.ts`, base.replace(/\.js$/, ".ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const catalog = load(path.join(ROOT, "common_util", "common_util_structure_catalog.ts")); +const station = load(path.join(ROOT, "common_util", "common_util_structure_station.ts")); +catalog.useCatalogDefinition({ types: input.types, ...input.definition }); +const out = {}; +for (const [name, { pipes, structures }] of Object.entries(input.cases)) + out[name] = station.planStations(pipes, structures, input.types); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_272_1_station_list.py b/resources/tester/test_272_1_station_list.py index 61c6c61af..f7b7cc380 100644 --- a/resources/tester/test_272_1_station_list.py +++ b/resources/tester/test_272_1_station_list.py @@ -50,7 +50,7 @@ STRUCTURES = [ "start_m": 500.0, "end_m": 530.0, }, - { # 옹벽 끝 530 과 같은 자리에서 시작 — 겹친 자리도 그대로 셈(합치기는 측점 만들기 몫) + { # 옹벽 끝 530 과 같은 자리에서 시작 — 같은 자리는 측점 하나(322-3 측점 계획 · 이름 = 앞선 옹벽) "type_id": "berm", "placement": "interval", "chainage_m": 545.0, @@ -67,7 +67,7 @@ STRUCTURES = [ }, {"type_id": "evac_road", "placement": "point", "chainage_m": 720.0}, ] -EXPECTED = [95, 160, 230, 300, 395, 400, 410, 500, 510, 530, 530, 545, 560] +EXPECTED = [95, 160, 230, 300, 395, 400, 410, 500, 510, 530, 545, 560] # 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) · # 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35 diff --git a/resources/tester/test_322_3_flow.py b/resources/tester/test_322_3_flow.py new file mode 100644 index 000000000..c16d9d59e --- /dev/null +++ b/resources/tester/test_322_3_flow.py @@ -0,0 +1,88 @@ +"""PLAN 322-3 카드 목록 흐름 일곱 — 초안 측점 맞춤(`withDraftStations`)이 측점 계획 한 벌을 따름. + +놓기 · 일반 측점에 놓기(219 흠 — No.N+0 자리 새 구조물 = 이름 칩 · 관 그림) · 옮기기 · 같은 군 바꿈 · +정본 넘는 바꿈(관 → 맹암거) · 지우기 · 저장 뒤 다시 열기(빼 둔 옛 자리 돌아옴). +각 줄 = [측점, 카드 이름, 미리 뜬 사본, 관 계열 그림 열쇠]. TS 쪽 = `helper_322_3_flow.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def flows() -> dict: + out = subprocess.run( + ["node", str(HERE / "helper_322_3_flow.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def _marked(rows: list) -> list: + return [row for row in rows if row[1] or row[2] or row[3]] + + +def test_place(flows: dict) -> None: + assert _marked(flows["place"]) == [ + [93, "옹벽", True, []], + [101, "옹벽", True, []], + [113, "옹벽", True, []], + ] + + +def test_place_on_general_station_gets_name_and_shape(flows: dict) -> None: + # 219 흠 — 일반 측점 자리(100 · 120)에 새로 놓은 맹암거 · 관 = 그 자리 사본에 이름 칩 · 관 그림 + assert _marked(flows["gridPlace"]) == [ + [100, "맹암거", True, []], + [120, "파형강관 D800", True, ["culvert"]], + ] + assert len(flows["gridPlace"]) == 11 # 측점 수 그대로(사본으로 갈음) + + +def test_move_hides_old_and_adds_new(flows: dict) -> None: + assert _marked(flows["move"]) == [[75, "파형강관 D1000", True, ["culvert"]]] + assert all(row[0] != 61 for row in flows["move"]) + + +def test_same_group_switch_renames(flows: dict) -> None: + assert _marked(flows["sameGroup"]) == [ + [93, "흙막이", True, []], + [101, "흙막이", True, []], + [113, "흙막이", True, []], + ] + + +def test_cross_owner_switch_drops_pipe_drawing(flows: dict) -> None: + assert _marked(flows["crossOwner"]) == [[101, "맹암거", True, []]] + + +def test_pick_follows_moved_pipe(flows: dict) -> None: + moved, other = flows["follow"] + assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-75.00") + assert (other["chainageM"], other["structureId"]) == (101, "w1") # 구조물 고름은 그대로 + + +def test_remove_and_reopen(flows: dict) -> None: + assert _marked(flows["remove"]) == [] + assert [row[0] for row in flows["remove"]] == list(range(0, 201, 20)) + assert _marked(flows["reopen"]) == [ + [93, "옹벽", False, []], + [101, "옹벽", False, []], + [113, "옹벽", False, []], + ] diff --git a/resources/tester/test_322_3_plan_mirror.py b/resources/tester/test_322_3_plan_mirror.py new file mode 100644 index 000000000..ae2e51d0b --- /dev/null +++ b/resources/tester/test_322_3_plan_mirror.py @@ -0,0 +1,178 @@ +"""PLAN 322-3 측점 계획 한 벌 — 파이썬 `plan_stations` = TS `planStations`(거울) · 서버 공급자가 같은 목록. + +- 측점 = 표 횡단도(anchor 기준 · span3 시작 · 기준 · 끝 · none 없음) · 카드 이름 = 표 카드 이름. +- 같은 m(0.005) 는 하나 — 이름 = 관이 먼저 · 아니면 앞선 것 · 덮는 구조물 id 모두. +- 빈칸 경고 = 형상 부품 필수 칸 중 값이 빈 것(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이). +- 서버 `resolve_extra_stations` = 계획의 (측점, 이름) — 겹친 자리는 한 번(옛 「마지막 이름」 규칙 걷음). +TS 쪽 = `helper_322_3_plan.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Engine_Sections import resolve_extra_stations +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_definition +from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_structure_station import plan_stations +from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _s(type_id: str, sid: str, **at) -> dict: + placement = "interval" if "start_m" in at else "point" + return { + "structure_id": sid, + "type_id": type_id, + "placement": placement, + "options": at.pop("options", {}), + **at, + } + + +CASES = { + "mixed": { + "pipes": [ + {"chainage_m": 95.0, "source": "user"}, + {"chainage_m": 160.0, "source": "user", "facility": "box_culvert"}, + {"chainage_m": 230.0, "source": "user", "facility": "ford_pavement"}, + { + "chainage_m": 400.0, + "source": "user", + "facility": "revetment", + "start_m": 395.0, + "end_m": 410.0, + "options": {"height_m": 2}, + }, + ], + "structures": [ + _s("cross_drain_exposed", "s1", chainage_m=120.0), + _s( + "retaining_wall", + "s2", + start_m=500.0, + chainage_m=510.0, + end_m=530.0, + options={"height_m": 3}, + ), + _s("berm", "s3", start_m=530.0, chainage_m=545.0, end_m=560.0), + _s("erosion_check", "s4", chainage_m=600.0), + _s("pavement_concrete", "s5", start_m=650.0, chainage_m=650.0, end_m=700.0), + _s("cross_underdrain", "s6", chainage_m=95.003), + ], + }, + "pipe_kinds": { + "pipes": [ + {"chainage_m": 10.0, "options": {"pipe_kind": "흄관", "pipe_diameter_mm": "800"}}, + {"chainage_m": 20.0, "options": {"pipe_diameter_mm": ""}}, + {"chainage_m": 30.0, "facility": "ford_bridge"}, + ], + "structures": [], + }, + "empty": {"pipes": [], "structures": []}, +} + + +@pytest.fixture(scope="module") +def ts() -> dict: + definition = load_definition() + payload = { + "definition": definition, + "types": with_visible().model_dump()["types"], + "cases": CASES, + } + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "input.json" + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_322_3_plan.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def _py(case: dict) -> list[dict]: + pipes = parse_pipe_points(case["pipes"]) + structures = [StructureInstance.model_validate(s) for s in case["structures"]] + return plan_stations(pipes, structures) + + +def test_mirror_python_equals_ts(ts: dict) -> None: + for name, case in CASES.items(): + assert _py(case) == ts[name], name + + +def test_plan_rules(ts: dict) -> None: + mixed = ts["mixed"] + assert [round(s["chainage_m"], 3) for s in mixed] == [ + 95.0, + 160.0, + 230.0, + 395.0, + 400.0, + 410.0, + 500.0, + 510.0, + 530.0, + 545.0, + 560.0, + ] + first = mixed[0] # 관 95 + 맹암거 95.003 = 한 측점 · 이름 = 관 · 빈칸 = 관경 + 맹암거 높이 + assert first["owners"] == ["pipe-95.00", "s6"] + assert first["title"] == "파형강관 D1000" + assert first["blank"] == ["pipe_diameter_mm", "culvert_height_m"] + joint = next(s for s in mixed if s["chainage_m"] == 530.0) # 옹벽 끝 = 소단 시작 + assert joint["owners"] == ["s2", "s3"] and joint["title"] == "옹벽" and joint["role"] == "end" + assert all(s["title"] != "노출형 횡단수로" for s in mixed) # ★1 횡단도 none + kinds = ts["pipe_kinds"] + assert [s["title"] for s in kinds] == ["흄관 D800", "파형강관 D1000", "세월교"] + assert kinds[1]["blank"] == ["pipe_diameter_mm"] and kinds[0]["blank"] == [] + assert ts["empty"] == [] + + +def test_server_supply_is_plan(tmp_path: Path) -> None: + case = CASES["mixed"] + route = tmp_path / "B05_Profile" / "route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": case["structures"]}, ensure_ascii=False), + encoding="utf-8", + ) + extras = resolve_extra_stations(tmp_path, parse_pipe_points(case["pipes"])) + plan = _py(case) + assert list(extras) == [(s["chainage_m"], s["title"]) for s in plan] + assert len({round(c, 3) for c, _ in extras}) == len(extras) # 같은 자리 한 번 + + +def test_stale_marks_compare_position_and_name(tmp_path: Path, monkeypatch) -> None: + """서버 저장 낡음 = 자리 + 이름(종류 · 관 공식 · 정의 판 이름) — 같은 자리라도 이름이 다르면 맞춤.""" + from B06_Section import B06_Section_Router_Stations as stations + + folder = tmp_path / "B06_Section" / "longitudinal" + folder.mkdir(parents=True) + rows = [ + {"chainage_m": 10.0, "kind": "irregular", "structure": "파형강관 D800"}, + {"chainage_m": 20.0, "kind": "regular"}, + {"chainage_m": 30.0, "kind": "irregular", "structure": "옹벽"}, + ] + (folder / "l.json").write_text(json.dumps({"stations": rows}), encoding="utf-8") + planted = [(10.0, "파형강관 D1000"), (30.0, "옹벽")] + monkeypatch.setattr(stations, "_planted", lambda *_: planted) + assert stations._stale_marks(tmp_path, "") == 2 # 10 m 옛 이름 · 새 이름 + planted[0] = (10.0, "파형강관 D800") + assert stations._stale_marks(tmp_path, "") == 0