From 5098318c969a58ef55797603dcebe5fc71bd79a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 20:16:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(513-3=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=82=AD=EC=A0=9C):=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=C2=B7=20=ED=8F=89=EB=A9=B4=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=E3=80=8C=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=E3=80=8D=20=3D=20=EA=B4=80=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=EB=8F=84=20=E2=80=94=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=A3=BC=EC=9D=B8=20=EA=B0=80=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20removeStationOwner(design=5Fpipe=5Fids=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=20=EA=B8=B0=EC=A4=80=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=3D=20=EA=B4=80=20=EC=A7=80=EC=A0=90=20=EC=A0=95=EB=B3=B8=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=20=EA=B5=AC=EA=B0=84=20=EB=81=9D=20=3D=20?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=20=EC=9E=90=EB=A6=AC=20=EA=B4=80=20=C2=B7=20?= =?UTF-8?q?=EA=B7=B8=20=EB=B0=96=20=3D=20=EC=A2=8C=EC=B8=A1=20=EC=B9=B8=20?= =?UTF-8?q?[=EC=82=AD=EC=A0=9C]=EC=99=80=20=EA=B0=99=EC=9D=80=20removeById?= =?UTF-8?q?)=20=C2=B7=20B05=20=EC=A2=85=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20B06=20=ED=9A=A1=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B8=B8=20=C2=B7=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=EB=95=8C?= =?UTF-8?q?=20=EB=82=98=EA=B0=90=20=C2=B7=20=EC=8B=9C=ED=97=98=20test=5F51?= =?UTF-8?q?3=5F3=20=E2=80=94=20=EC=9A=94=EA=B5=AC=20327-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_pipe_ids.ts | 14 ++++++ A00_Common/design/design_view_profile.ts | 11 +++-- .../B06_Section_UI_Section_View_Profile.ts | 8 +++- .../helper_513_3_remove_station_owner.cjs | 38 ++++++++++++++++ .../tester/test_513_3_remove_station_owner.py | 44 +++++++++++++++++++ 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_513_3_remove_station_owner.cjs create mode 100644 resources/tester/test_513_3_remove_station_owner.py 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/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 From 2f06993c7d1adde27f12f977e1771fb34d42b61c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 20:16:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(513-3=20=EB=8D=A7=20RouteEdit=20?= =?UTF-8?q?=EC=A4=84=20=EC=88=98):=20=EC=83=81=ED=83=9C=EC=A4=84=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EB=93=B1?= =?UTF-8?q?=EA=B3=A0=EC=84=A0=20=EA=B8=80=20=EC=A7=93=EA=B8=B0=20=3D=20rou?= =?UTF-8?q?teHeadText(=5FRender)=20=C2=B7=20contourHintText(=5FContour)=20?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B9=80=20=E2=80=94=20RouteEdit.ts=20699?= =?UTF-8?q?=20=E2=86=92=20691=20=EC=A4=84=20=C2=B7=20test=5F430=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EA=B8=B0=20=EB=B0=B0=EC=84=A0=20=C2=B7=20tes?= =?UTF-8?q?t=5F399=20=EC=A4=84=20=EC=88=98=20=ED=95=9C=EB=8F=84=20695=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 18 +++++------------- .../B05_Profile_UI_RouteEdit_Contour.ts | 7 +++++++ B05_Profile/B05_Profile_UI_RouteEdit_Render.ts | 10 ++++++++++ 3 files changed, 22 insertions(+), 13 deletions(-) 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. */