diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts index 1c8a2cf10..05228a613 100644 --- a/A00_Common/design/design_pipe_ids.ts +++ b/A00_Common/design/design_pipe_ids.ts @@ -129,3 +129,17 @@ export function followMovedPipe(projectId: string | null, fromM: number, toM: nu { partKey: picked.partKey }, ); } + +/** 종단 측점 하나를 지움(513-3 · 우클릭 「배관 · 구조물 삭제」 종단 · 평면 공용) — 측점 id = 주인 · `주인:역할` + * (`plannedIrregular`) · 관 기준 측점 = 그 자리 · 관 구간 끝 = 관 자리 · 그 밖 = 구조물 id(좌측 칸 [삭제]와 같은 길). */ +export function removeStationOwner( + station: { id: string; chainage_m: number }, + pipe: boolean, + remove: { pipe: (chainageM: number) => void; structure: (structureId: string) => void }, +): void { + if (pipe) return remove.pipe(station.chainage_m); + const owner = station.id.replace(/:(start|end)$/, ""); + const at = pipeChainageOf(owner); + if (at !== null) remove.pipe(at); + else remove.structure(owner); +} diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index bf8e88b5e..4aa105054 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; -import { pipeIdAt } from "./design_pipe_ids"; +import { pipeIdAt, removeStationOwner } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; import { pageSettingsPanelValues } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { setPlusStations } from "../../common_util/common_util_structure_station"; @@ -194,9 +194,12 @@ export async function mountProfileView( onStructureMove: (from, to, station) => { if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); }, - onStructureRemove: (station) => { - if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m); - }, + // 우클릭 삭제 — 관 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길(513-3) + onStructureRemove: (station) => + removeStationOwner(station, isPipeStation(station), { + pipe: (chainage) => profilePanel.drainage.removePipe(chainage), + structure: (structureId) => panel.structures.removeById(structureId), + }), // 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18). onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"), onStructureAdd: (chainage, type) => diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index fc9e6b628..85faf4c1d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -28,6 +28,7 @@ import { createSheetFollow, fetchRouteSheets, loadRouteEditBackground, + contourHintText, type RouteEditContours, type SheetLayers, } from "./B05_Profile_UI_RouteEdit_Contour"; @@ -39,7 +40,7 @@ import { } from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve"; -import { drawRouteEditScene, polylineLengthM, watchTheme } from "./B05_Profile_UI_RouteEdit_Render"; +import { drawRouteEditScene, routeHeadText, watchTheme } from "./B05_Profile_UI_RouteEdit_Render"; import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer"; import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; @@ -372,19 +373,10 @@ export async function mountRouteEditor( nodeInfo = built.nodes; } - /** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 - * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ + /** 상태줄 머리 · 고른 등고선 글 — 짓기는 `_Render` · `_Contour` 몫. */ const routeHead = (): string => - `예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` + - `계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` + - `노드 ${planned.length}개`; - - /** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */ - const contourHint = (): string => { - if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다."; - const level = contours?.layer.features[pickedContour]?.labelValue ?? null; - return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`; - }; + routeHeadText(expected, plannedLine.length ? plannedLine : planned, planned.length); + const contourHint = (): string => contourHintText(contours, pickedContour); /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ const curveHint = (): string => diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index 1ae97349d..84796ff7d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -48,6 +48,13 @@ export interface RouteEditContours { minor: (() => PreparedLayer) | null; } +/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */ +export function contourHintText(contours: RouteEditContours | null, picked: number): string { + if (picked < 0) return "등고선을 누르면 그 줄의 높이가 보입니다."; + const level = contours?.layer.features[picked]?.labelValue ?? null; + return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`; +} + interface ContourResponse { contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 6aedb6f1f..2f1658c37 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -66,6 +66,16 @@ export function polylineLengthM(points: ReadonlyArray): number { return total; } +/** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 + * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ +export const routeHeadText = ( + expected: ReadonlyArray, + line: ReadonlyArray, + nodeCount: number, +): string => + `예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` + + `계획노선 ${polylineLengthM(line).toFixed(1)}m · 노드 ${nodeCount}개`; + export interface RouteEditScene { view: ViewState; /** 사업지 좌표(m) → 캔버스 px. */ diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index b1a9bf0b2..71b5e9c2b 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -24,6 +24,7 @@ import { } from "../A00_Common/design/design_selection"; import { paintMapPick } from "../A00_Common/design/design_pick_profile"; import { ESCAPE_FROM } from "../A00_Common/design/design_pick_escape"; +import { removeStationOwner } from "../A00_Common/design/design_pipe_ids"; import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { fetchLatestRoute, @@ -156,7 +157,12 @@ export function createSectionProfile( if (isPipeStation(station)) host.edit()?.movePipe(from, to); }, onStructureRemove: (station) => { - if (isPipeStation(station)) host.edit()?.removePipe(station.chainage_m); + const edit = host.edit(); + if (edit) + removeStationOwner(station, isPipeStation(station), { + pipe: edit.removePipe, + structure: edit.removeStructure, + }); }, onPipeAdd: (chainage) => host.edit()?.addPipe(chainage), onStructureAdd: (chainage, type) => diff --git a/resources/tester/helper_513_3_remove_station_owner.cjs b/resources/tester/helper_513_3_remove_station_owner.cjs new file mode 100644 index 000000000..c1fdc5d9d --- /dev/null +++ b/resources/tester/helper_513_3_remove_station_owner.cjs @@ -0,0 +1,38 @@ +/* 513-3 우클릭 「구조물 삭제」 관 아닌 구조물도(요구 327-1) — `design_pipe_ids.removeStationOwner` 를 Node 로 + * 돌려 JSON 한 줄(`test_513_3_remove_station_owner.py` 가 판정). 관 장부 · 고름은 가짜 · 주인 가르기는 진짜. + * node helper_513_3_remove_station_owner.cjs + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const BOOK = { next: 4, ids: [{ id: "pipe-s3", at: 55 }] }; +const STUBS = { + "../b_page_state": { readState: () => BOOK, writeState() {} }, + "./design_selection": { + PIPE_MATCH_M: 0.51, + isNear: () => false, + nearestWithin: () => null, + readSelection: () => null, + selectDesign() {}, + }, + common_util_pick_tag: { setPipeTagSource() {} }, +}; +const { loadTs } = makeLoader({ root: path.join(__dirname, "..", ".."), stubs: STUBS }); +const ids = loadTs(path.join(__dirname, "..", "..", "A00_Common", "design", "design_pipe_ids.ts")); +ids.usePipeIds("p1"); + +const calls = []; +const remove = { + pipe: (m) => calls.push(["pipe", m]), + structure: (id) => calls.push(["structure", id]), +}; +const cases = [ + [{ id: "pipe-s1", chainage_m: 40 }, true], // 관 기준 측점 + [{ id: "wall-7", chainage_m: 80 }, false], // 구조물 기준 측점 + [{ id: "wall-8:start", chainage_m: 100 }, false], // 구간 구조물 시작 + [{ id: "wall-8:end", chainage_m: 130 }, false], // 구간 구조물 끝 + [{ id: "pipe-s3:end", chainage_m: 60 }, false], // 관 구간 끝 = 장부 자리 + [{ id: "pipe-12.50:start", chainage_m: 10 }, false], // 옛 자리 이름 관 +]; +for (const [station, pipe] of cases) ids.removeStationOwner(station, pipe, remove); +process.stdout.write(JSON.stringify({ calls })); diff --git a/resources/tester/test_513_3_remove_station_owner.py b/resources/tester/test_513_3_remove_station_owner.py new file mode 100644 index 000000000..84769522c --- /dev/null +++ b/resources/tester/test_513_3_remove_station_owner.py @@ -0,0 +1,44 @@ +"""513-3 종단 · 평면 우클릭 「구조물 삭제」 관 아닌 구조물도(요구 327-1) — 측점 주인을 갈라 관 = 관 지점 정본 · +그 밖 = 좌측 칸 [삭제]와 같은 `removeById`(공용 `removeStationOwner`). + +TS 셈은 `helper_513_3_remove_station_owner.cjs`(Node · 주인 가르기는 진짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_513_3_remove_station_owner.py -q +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_513_3_remove_station_owner.cjs") + + +def test_주인대로_지움() -> None: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout)["calls"] == [ + ["pipe", 40], + ["structure", "wall-7"], + ["structure", "wall-8"], + ["structure", "wall-8"], + ["pipe", 55], + ["pipe", 12.5], + ] + + +def test_두_보기_배선() -> None: + view = (ROOT / "A00_Common" / "design" / "design_view_profile.ts").read_text(encoding="utf-8") + assert "removeStationOwner(station, isPipeStation(station)" in view + assert "panel.structures.removeById(structureId)" in view + cross = (ROOT / "B06_Section" / "B06_Section_UI_Section_View_Profile.ts").read_text( + encoding="utf-8" + ) + assert "removeStationOwner(station, isPipeStation(station)" in cross + assert "structure: edit.removeStructure" in cross