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