From 8563da1c76f727e0a7462ec98100f95e231b5a75 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 15:03:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(380-2):=20=EC=82=AC=ED=86=A0=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20=ED=86=A0=EC=B7=A8=EC=9E=A5=20=EC=9E=90=EB=A6=AC=20=3D?= =?UTF-8?q?=20=EA=B0=80=EC=9E=A5=20=EA=B0=80=EA=B9=8C=EC=9A=B4=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=83=88=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=95=88=20=EC=8B=AC=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_structure_station: 기존 측점(일반 · 구조물 · 추가) · snapExistingStation(가운데면 앞 측점) · snapStationFor(site 만) - 폼 [추가] · 끌기(movedStructure) · 우클릭 추가 = 같은 맞춤 · 폼 칸에 맞춘 측점 다시 씀 - 옛 소수 자동 맞춤 = 기존 측점(끝 소수 · 옛 구조물 측점)에 선 site 안 옮김 - 상세 모달 CAD 글 = 맞춘 측점에서 종류 이름 + 「상세 내용: 사토장 부피」 줄(기존 길 그대로) - 시험 test_380_2_site_station(아무 m 입력 = 가까운 측점 · CAD 글 줄) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- .../B05_Profile_UI_Structures_Panel.ts | 8 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 10 +- B05_Profile/B05_Profile_UI_Structures_Snap.ts | 18 ++- common_util/common_util_structure_station.ts | 27 +++- .../tester/helper_380_2_site_station.cjs | 52 +++++++ resources/tester/test_380_2_site_station.py | 132 ++++++++++++++++++ 6 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 resources/tester/helper_380_2_site_station.cjs create mode 100644 resources/tester/test_380_2_site_station.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 8b6328978..436a16adc 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -47,11 +47,7 @@ import { showsAnchor } from "./logic/B05_Profile_UI_Structures_Define"; import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; -import { - movedStructure, - snapOldStations, - snapStructureStation, -} from "./B05_Profile_UI_Structures_Snap"; +import { movedStructure, snapOldStations, snapStationFor } from "./B05_Profile_UI_Structures_Snap"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ @@ -645,7 +641,7 @@ export function createStructuresSection( loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); }, addAt(chainageM, typeId) { - chainageM = snapStructureStation(chainageM); // 정수 m · 일반 측점 붙기(234-1) + chainageM = snapStationFor(chainageM, placementOf(typeId)); // 정수 m · site = 기존 측점(234-1 · 380-2) const type = typeMap().get(typeId); if (!type) return; // 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index a5d8d2110..dbd879ce4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -23,7 +23,10 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures import { asRoad } from "./logic/B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { specFor } from "../A00_Common/design/logic/design_structure_pipe"; -import { snapStructureStation } from "../common_util/common_util_structure_station"; +import { + snapExistingStation, + snapStructureStation, +} from "../common_util/common_util_structure_station"; import { readGroupItems, readSummaryItem, @@ -328,6 +331,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } else { anchor = ctx.anchorFields.read(step, true); if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); + if (placement === "site") { + // 사토장 · 토취장 = 친 m 에서 가장 가까운 기존 측점 · 새 측점 안 심음(380-2) + anchor = snapExistingStation(anchor); + ctx.anchorFields.write(anchor, step); + } } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts index 29ac14a58..1b16c8f2b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Snap.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -11,18 +11,21 @@ import { showToast } from "@ui/ui_template_elements"; import { isSnappedStation, + snapExistingStation, + snapStationFor, snapStructureStation, } from "../common_util/common_util_structure_station"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; -export { snapStructureStation }; +export { snapStationFor, snapStructureStation }; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types"; -/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */ +/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤 · + * site(사토장 · 토취장) = 가장 가까운 기존 측점(380-2). */ export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance { - const anchor = snapStructureStation(toChainageM); + const anchor = snapStationFor(toChainageM, target.placement); if (target.placement !== "interval") return { ...target, chainage_m: anchor }; const length = (target.end_m ?? 0) - (target.start_m ?? 0); const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0))); @@ -47,9 +50,16 @@ export function followSnapped( const offGrid = (value: number | null | undefined): boolean => typeof value === "number" && !isSnappedStation(value); +/** 기존 측점(끝 소수 · 옛 소수 구조물 측점 포함)에 선 site 인가 — 맞춤 밖으로 안 셈(380-2). */ +const onExisting = (structure: StructureInstance): boolean => + structure.placement === "site" && + typeof structure.chainage_m === "number" && + snapExistingStation(structure.chainage_m) === structure.chainage_m; + /** 기준 · 시작 · 끝 중 하나라도 맞춤 밖(소수)인 구조물인가. */ export const structureOffGrid = (structure: StructureInstance): boolean => - offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m); + !onExisting(structure) && + (offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m)); export interface SnapPlan { /** 목록 자리 · 옮긴 모양. */ diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 0bd1463b2..f0b2f8c99 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -40,14 +40,16 @@ const EPS = 1e-6; export const SAME_STATION_M = 0.005; let general: number[] = []; +let listed: number[] = []; -/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */ +/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). 구조물 측점까지 든 목록도 둠(380-2). */ export function setGeneralStations( stations: ReadonlyArray<{ chainage_m: number; kind?: string }> | null | undefined, ): void { general = (stations ?? []) .filter((station) => station.kind !== "irregular") .map((station) => station.chainage_m); + listed = (stations ?? []).map((station) => station.chainage_m); } export const generalStations = (): readonly number[] => general; @@ -94,6 +96,29 @@ export const isSnappedStation = ( stations: readonly number[] = general, ): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS; +/* ── 기존 측점 맞춤(380-2 · 요구 241-8) — 새 측점을 안 심는 자리 꼴(site · 사토장 · 토취장) ── */ + +/** 기존 측점(일반 · 구조물 · 추가) — 오름차순 · 같은 m 하나. + * ponytail: 구조물 측점 = 마지막으로 읽은 횡단 상세 것 — 세션에서 새로 놓은 관 측점은 다시 읽을 때까지 빠짐. */ +export const existingStations = (): number[] => + [...new Set([...listed, ...plusSource()])].sort((a, b) => a - b); + +/** 가장 가까운 기존 측점(가운데면 앞 측점) — 목록이 비면 정수 m 맞춤. 두 번 맞춰도 같다. */ +export function snapExistingStation( + chainageM: number, + stations: readonly number[] = existingStations(), +): number { + if (!Number.isFinite(chainageM) || !stations.length) return snapStructureStation(chainageM); + return stations.reduce((best, at) => { + const [gap, bestGap] = [Math.abs(at - chainageM), Math.abs(best - chainageM)]; + return gap < bestGap - EPS || (gap < bestGap + EPS && at < best) ? at : best; + }); +} + +/** 놓는 꼴 따라 맞춤 — site = 가장 가까운 기존 측점 · 그 밖 = 정수 m. */ +export const snapStationFor = (chainageM: number, placement: string | null | undefined): number => + placement === "site" ? snapExistingStation(chainageM) : snapStructureStation(chainageM); + /* ── 측점을 심는 자리(272 · 서버 `resolve_extra_stations` · `is_station_planting_type` 짝) ── */ export interface PlantingPipe { diff --git a/resources/tester/helper_380_2_site_station.cjs b/resources/tester/helper_380_2_site_station.cjs new file mode 100644 index 000000000..33eaf4a12 --- /dev/null +++ b/resources/tester/helper_380_2_site_station.cjs @@ -0,0 +1,52 @@ +/* 380-2 사토장 · 토취장(site) = 가장 가까운 기존 측점(일반 · 구조물 · 추가) · 새 측점 안 심음. + * 진짜 `common_util_structure_station` · `_Structures_Snap` 을 Node 로 돌림. 입력 argv[2] JSON · 표준출력 JSON. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const catalog = require("./helper_322_catalog_module.cjs"); +const station = loadTs("common_util/common_util_structure_station.ts", { + "./common_util_structure_catalog": catalog, +}); +const snap = loadTs("B05_Profile/B05_Profile_UI_Structures_Snap.ts", { + "@ui/ui_template_elements": { showToast: () => {}, showConfirmDialog: async () => true }, + "../common_util/common_util_structure_station": station, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (entry) => entry.chainage_m ?? entry.start_m ?? 0, + }, +}); + +const out = {}; +out.emptySnap = station.snapExistingStation(25.4); // 목록 전 = 정수 m +station.setGeneralStations(input.stations); +station.setPlusStations(() => input.plus); +out.existing = station.existingStations(); +out.snap = input.typed.map((value) => station.snapExistingStation(value)); +out.twice = out.snap.map((value) => station.snapExistingStation(value)); +out.siteFor = station.snapStationFor(25.4, "site"); +out.pointFor = station.snapStationFor(25.4, "point"); +out.movedSite = snap.movedStructure({ placement: "site", chainage_m: 20 }, 35.2).chainage_m; +out.movedPoint = snap.movedStructure({ placement: "point", chainage_m: 20 }, 35.2).chainage_m; +out.offGrid = input.offGrid.map((entry) => snap.structureOffGrid(entry)); +out.planned = snap + .snapPlan(input.offGrid, []) + .structures.map((entry) => [entry.index, entry.next.chainage_m]); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_380_2_site_station.py b/resources/tester/test_380_2_site_station.py new file mode 100644 index 000000000..ca6df0198 --- /dev/null +++ b/resources/tester/test_380_2_site_station.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""380-2 사토장 · 토취장(site) 자리 = 가장 가까운 기존 측점 · 새 측점 안 심음(요구 241-8). + +기존 측점 = 일반(격자 · 시종점) · 구조물(irregular) · 추가(plus). 아무 m 를 넣어도 그중 가장 가까운 +측점 · 가운데면 앞 측점 · 목록이 비면 정수 m. 폼 [추가] · 끌기 · 우클릭이 같은 맞춤 · +옛 소수 자동 맞춤은 기존 측점(끝 소수 포함)에 선 site 를 안 옮김. +진짜 TS 를 `helper_380_2_site_station.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +STATIONS = [ + {"chainage_m": 0.0, "kind": "bp"}, + {"chainage_m": 20.0, "kind": "regular"}, + {"chainage_m": 33.4, "kind": "irregular"}, # 옛 소수 구조물 측점 + {"chainage_m": 40.0, "kind": "regular"}, + {"chainage_m": 60.0, "kind": "regular"}, + {"chainage_m": 79.8, "kind": "ep"}, # 끝 소수 +] +PLUS = [47.0, 20.0] # 추가 측점(같은 m 는 하나) +# (친 m, 기대 측점) +TYPED = [ + (25.0, 20.0), + (27.0, 33.4), # 구조물 측점 + (43.0, 40.0), + (44.0, 47.0), # 추가 측점 + (10.0, 0.0), # 가운데 = 앞 측점 + (53.5, 47.0), # 가운데 = 앞 측점 + (78.0, 79.8), # 끝 소수 그대로 + (1000.0, 79.8), # 노선 밖 = 끝 + (-5.0, 0.0), + (60.0, 60.0), +] +OFF_GRID = [ + {"placement": "site", "chainage_m": 79.8}, # 기존 측점(끝 소수) — 안 옮김 + {"placement": "site", "chainage_m": 33.4}, # 기존 구조물 측점 — 안 옮김 + {"placement": "site", "chainage_m": 50.3}, # 소수 · 측점 아님 — 가까운 기존 측점으로 + {"placement": "site", "chainage_m": 55.0}, # 정수 — 옛 규칙대로 안 옮김 + {"placement": "point", "chainage_m": 79.8}, # site 아님 — 옛 규칙(정수 m) +] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s380") / "in.json" + source.write_text( + json.dumps( + { + "stations": STATIONS, + "plus": PLUS, + "typed": [value for value, _ in TYPED], + "offGrid": OFF_GRID, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_380_2_site_station.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_기존_측점_일반_구조물_추가(ts) -> None: + assert ts["existing"] == [0.0, 20.0, 33.4, 40.0, 47.0, 60.0, 79.8] + + +def test_아무_m_입력_가까운_기존_측점(ts) -> None: + assert ts["snap"] == [expected for _, expected in TYPED] + assert ts["twice"] == ts["snap"] # 두 번 맞춰도 같음 + assert ts["emptySnap"] == 25 # 목록 전 = 정수 m + + +def test_site_만_기존_측점_그_밖_정수(ts) -> None: + assert ts["siteFor"] == 20 and ts["pointFor"] == 25 + assert ts["movedSite"] == 33.4 # 끌기 = 가까운 기존 측점 + assert ts["movedPoint"] == 35 + + +def test_옛_자동_맞춤은_기존_측점_site_안_옮김(ts) -> None: + assert ts["offGrid"] == [False, False, True, False, True] + assert ts["planned"] == [[2, 47.0], [4, 79.0]] # 끝 79.8 아래 정수 + + +def test_상세_CAD_글_사토장_부피_줄() -> None: + """맞춘 기존 측점 = 상세 모달 CAD 글 측점(±0.51 m) — 종류 이름 + 「사토장 부피」 줄.""" + import sys + + if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( + structure_note_lines, + with_option_lines, + ) + + things = with_option_lines( + [ + { + "name": "유용토운반작업장(사토장)", + "pipe": False, + "at": 33.4, + "summary_item": None, + "group_items": None, + "type_id": "spoil_bank", + "options": {"capacity_m3": 150}, + } + ] + ) + assert structure_note_lines(things, 33.4) == [ + "유용토운반작업장(사토장)", + "상세 내용: 사토장 부피 150㎥", + ] + assert structure_note_lines(things, 40.0) == [] # 이웃 측점엔 없음