diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 0e16185a5..df7a528ef 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -13,7 +13,7 @@ import { readState, writeState } from "../b_page_state"; -/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */ +/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */ export const PIPE_MATCH_M = 0.51; export interface DesignSelection { diff --git a/B04_PreProcess/B04_PreProcess_UI_Basins.ts b/B04_PreProcess/B04_PreProcess_UI_Basins.ts index 251369cf8..39b93d257 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Basins.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Basins.ts @@ -40,6 +40,7 @@ import { resampleRoute, type RoutePoint, } from "./B04_PreProcess_UI_RouteSamples"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -296,7 +297,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl function selectBasinOfPipe(pipeIndex: number): void { const pipe = pipes[pipeIndex]; if (!pipe) return; - const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < PIPE_MATCH_M); selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null; say(summary()); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts index 4e9983648..f412bcb3a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Ford_Fill.ts @@ -12,6 +12,7 @@ import { FORD_PAVEMENT_DEFAULT_WIDTH_M, } from "@config/config_frontend"; import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; const FORD_KINDS: Record = { ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M, @@ -39,7 +40,7 @@ export function fillFordHeights( ): boolean { let filled = false; for (const wait of [...pending]) { - const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < 0.51); + const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < PIPE_MATCH_M); if (!point?.facility || !(point.facility in FORD_KINDS)) { pending.delete(wait); // 관이 사라졌거나 종류가 바뀜 continue; @@ -48,7 +49,9 @@ export function fillFordHeights( pending.delete(wait); // 사용자가 넣은 값 — 안 덮음 continue; } - const flow = basins.find((entry) => Math.abs(entry.chainage_m - point.chainage_m) < 0.51); + const flow = basins.find( + (entry) => Math.abs(entry.chainage_m - point.chainage_m) < PIPE_MATCH_M, + ); const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility]; const need = fordHeightM(flow?.design_flow_m3s ?? null, width); if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 1deeea3c5..d34b96d10 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -30,6 +30,8 @@ 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 { 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"; import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; @@ -571,15 +573,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra movePipe(fromChainage, toChainage) { const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < PIPE_MATCH_M); if (index < 0) return; - pipeEditor.moveTo(index, toChainage); + const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게) + facilityStore.move(pipeEditor.pipes()[index].chainage_m, to); + pipeEditor.moveTo(index, to); }, selectBasinByChainage(chainageM) { const picked = chainageM === null ? null - : (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < 0.51) ?? null); + : (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < PIPE_MATCH_M) ?? null); // 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단). selectBasin(picked ? picked.index : null, false); }, @@ -605,12 +609,16 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra scheduleDraw(); }, addPipe(chainageM, attributes) { + chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. facilityStore.set(chainageM, attributes ?? null); if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM); pipeEditor.addAtChainage(chainageM); }, updatePipeFacility(fromChainageM, toChainageM, attributes) { + // 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리 + if (Math.abs(toChainageM - fromChainageM) > 0.005) + toChainageM = snapStructureStation(toChainageM); // ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임. const previous = facilityStore.get(fromChainageM); facilityStore.set(fromChainageM, null); @@ -621,7 +629,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra // attach가 새 위치로 시설 정보를 승계한다. const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M); if (index >= 0) pipeEditor.moveTo(index, toChainageM); else void analyze(); } else { @@ -634,7 +642,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra removePipe(chainageM) { const index = pipeEditor .pipes() - .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); + .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M); if (index < 0) return; pipeEditor.select(index); pipeEditor.deleteSelected(); @@ -643,7 +651,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const index = chainageM === null ? null - : pipeEditor.pipes().findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); + : pipeEditor + .pipes() + .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M); const next = index === -1 ? null : index; if (pipeEditor.selected() === next) return; // select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 3a9d17fab..88036c07d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -21,6 +21,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -352,7 +353,7 @@ export function reconcilePipes( return null; } return next.map((chainage) => { - const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51); + const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < PIPE_MATCH_M); return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" }; }); } @@ -516,7 +517,7 @@ export function createProgressReporter(progress: { /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ export function pipeMarkerColor(basins: ReadonlyArray, chainageM: number): string { - const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < PIPE_MATCH_M); if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb"); return basinColor(basin.index).replace(/0\.45\)$/, "1)"); } @@ -527,7 +528,7 @@ export function basinIndexOfPipe( pipe: { chainage_m: number } | null, ): number | null { if (!pipe) return null; - const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51); + const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < PIPE_MATCH_M); return basin ? basin.index : null; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 5ac77fbf0..864188844 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -1,6 +1,7 @@ import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; // 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제. // 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선 @@ -74,6 +75,9 @@ export function createPipeEditor( /** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다 * (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */ let dragDownAt: { x: number; y: number } | null = null; + /** 노선 안 · 관 기준 측점 = 정수 m(일반 측점 1 m 안이면 그 측점 · 234-1). */ + const onRoute = (chainageM: number): number => + Math.min(snapStructureStation(Math.max(0, Math.min(totalChainage, chainageM))), totalChainage); /** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */ function screenToMetric( @@ -199,14 +203,14 @@ export function createPipeEditor( moveTo(index, chainageM) { const pipe = pipeList[index]; if (!pipe) return; - pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM)); + pipe.chainage_m = onRoute(chainageM); pipe.reason = "confirmed"; sortPipes(); onChange(); onCommit(); }, addAtChainage(chainageM) { - const clamped = Math.max(0, Math.min(totalChainage, chainageM)); + const clamped = onRoute(chainageM); pipeList.push({ chainage_m: clamped, reason: "confirmed" }); sortPipes(); selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6); @@ -224,18 +228,17 @@ export function createPipeEditor( if (!metric) return null; const nearest = nearestChainage(metric.x, metric.y); if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null; - return Number(nearest.chainage.toFixed(2)); + return onRoute(nearest.chainage); }, addAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; const nearest = nearestChainage(metric.x, metric.y); if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; - pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" }); + const at = onRoute(nearest.chainage); + pipeList.push({ chainage_m: at, reason: "confirmed" }); sortPipes(); - selectedIndex = pipeList.findIndex( - (pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6, - ); + selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - at) < 1e-6); onChange(); onCommit(); return true; @@ -271,8 +274,7 @@ export function createPipeEditor( if (!metric) return true; const nearest = nearestChainage(metric.x, metric.y); if (!nearest) return true; - const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage)); - pipeList[draggingIndex].chainage_m = clamped; + pipeList[draggingIndex].chainage_m = onRoute(nearest.chainage); pipeList[draggingIndex].reason = "confirmed"; dragMoved = true; onChange(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Report.ts b/B05_Profile/B05_Profile_UI_Drainage_Report.ts index d5596c4ca..ea809b3a2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Report.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Report.ts @@ -7,6 +7,7 @@ import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. * 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, @@ -20,7 +21,7 @@ export function buildPipeReports( ): DrainagePipeReport[] { return pipes.map((pipe) => { const chainage = Math.round(pipe.chainage_m * 100) / 100; - const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); + const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M); const attributes = facilityStore.get(chainage); return { chainage_m: chainage, diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index 798caaf8a..07e7b3e12 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -23,6 +23,7 @@ import { pickPipeDiameter, type StructureType, } from "@config/config_frontend"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; export interface IrregularStation { id: string; @@ -480,7 +481,9 @@ export function createIrregularStationsSection( // 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다. const restoredGhost = entry.structureType === undefined && - incomingChainages.some((chainage) => Math.abs(entry.chainage_m - chainage) < 0.51); + incomingChainages.some( + (chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M, + ); if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1); } pipes.forEach((pipe) => { @@ -488,7 +491,7 @@ export function createIrregularStationsSection( const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null); const { station, remainder } = splitChainage(chainage); const kept = previousPipes.find( - (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < 0.51, + (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M, ); const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE; const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective); @@ -530,7 +533,9 @@ export function createIrregularStationsSection( callbacks.onChange([...stations]); }, removeByChainage(chainageM) { - const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51); + const index = stations.findIndex( + (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, + ); if (index < 0) return null; const [removed] = stations.splice(index, 1); if (editingId === removed.id) loadForm(null); @@ -540,7 +545,7 @@ export function createIrregularStationsSection( }, moveByChainage(fromChainageM, toChainageM) { const index = stations.findIndex( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51, + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); if (index < 0) return false; const { station, remainder } = splitChainage(toChainageM); diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 8518459e2..70d0400fa 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -33,6 +33,7 @@ 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"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -114,7 +115,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ); const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3))); gone.forEach((station) => { - const same = followedStation(station, gone, born); + // 없으면 맞춘 자리의 새 측점 — [정수 맞춤]이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1) + const same = followedStation(station, gone, born) ?? followSnapped(station, born); deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m); }); irregularStations = stations; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 64127e8f2..f8a1a2950 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -31,8 +31,9 @@ import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { createFreeDraw } from "../A00_Common/design/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; +import { stepStructureStation } from "../common_util/common_util_structure_station"; -/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ +/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; /** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ @@ -234,8 +235,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해 // 0.1m 만 움직이고 멈춤(2026-09-04 실측). const fromM = holdChainageM ?? station.chainage_m; - const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3)); - if (next < 0) return false; + const next = stepStructureStation(fromM, key === "ArrowRight" ? 1 : -1); + if (next === fromM) return false; ctx.moveStation({ ...station, chainage_m: fromM }, next); holdStation = station; holdChainageM = next; diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 6e2f67b27..5649dbd79 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -17,6 +17,7 @@ import { } from "./B05_Profile_UI_IrregularStations"; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import type { PickGroup } from "./B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 사이드 「구조물 배치」 군 · 종류 드롭다운 목록(`section.pickGroups()`)을 그대로 구조물군 → 종류 * 2단 메뉴 항목으로(129-1 — 종단그래프 · 배수유역도 · 3D · B06 종단 공용 · 가린 군 · 종류는 뺌). @@ -118,7 +119,7 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti } /** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ -export const SAME_CHAINAGE_M = 0.51; +export const SAME_CHAINAGE_M = PIPE_MATCH_M; /** * 측점선 id ↔ 알약(구조물) id 짝짓기. diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 95181eb53..d1bb93c0d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -10,6 +10,7 @@ import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station"; import { DEFAULT_BASIN_SHAPE } from "../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; import { fillPipeHeights } from "../common_util/common_util_culvert_sets"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; export function field(labelText: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); @@ -141,7 +142,7 @@ export function stationFields( ): StationFields { const station = numberInput("1", "0"); station.placeholder = "측점"; - const remainder = numberInput("0.1", "0"); + const remainder = numberInput("1", "0"); // 기준 측점 = 정수 m(234-1) remainder.placeholder = "+m"; const pair = document.createElement("div"); pair.className = "b05-structure__station-pair"; @@ -195,7 +196,8 @@ export function stationFields( const valid = Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0; markInvalid(!valid); - return valid ? stationNo * intervalM + rest : null; + // 친 값은 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 안 고친 칸은 위에서 옛 값 그대로 + return valid ? snapStructureStation(stationNo * intervalM + rest) : null; }, write(chainageM, intervalM) { written = null; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 526d27113..b29c23e40 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -14,7 +14,6 @@ * ========================================================================== */ import { - structureAnchorM, type StructureInstance, type StructuresSectionOptions, type StructurePlacement, @@ -48,6 +47,7 @@ import { pickable, showsAnchor } from "./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, snapDock, snapStructureStation } from "./B05_Profile_UI_Structures_Snap"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ @@ -363,7 +363,7 @@ export function createStructuresSection( if (tempPipeChainage === null) return; const chainage = tempPipeChainage; tempPipeChainage = null; - if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.51) { + if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < PIPE_MATCH_M) { selectedPipeChainage = null; } callbacks.onPipeRemove(chainage); @@ -542,7 +542,13 @@ export function createStructuresSection( return { root, facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) - listRoot: list, + listRoot: snapDock(list, { + structures, + pipes: () => pipeFacilities, + emit, + movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes), + closeForm: () => resetForm(), + }), hasSelection: () => editingId !== null || selectedPipeChainage !== null, pickGroups: () => pickGroups(types, canPick), // 우클릭 메뉴 = 이 드롭다운 목록(129-1) setTypes(next, pick = () => true) { @@ -637,6 +643,7 @@ export function createStructuresSection( loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); }, addAt(chainageM, typeId) { + chainageM = snapStructureStation(chainageM); // 정수 m · 일반 측점 붙기(234-1) const type = typeMap().get(typeId); if (!type) return; // 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서 @@ -670,21 +677,8 @@ export function createStructuresSection( moveById(structureId, toChainageM) { const index = structures.findIndex((entry) => entry.structure_id === structureId); if (index < 0) return false; - const target = structures[index]; - if (target.placement === "interval") { - // 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다. - const length = (target.end_m ?? 0) - (target.start_m ?? 0); - const anchorOffset = structureAnchorM(target) - (target.start_m ?? 0); - const start = toChainageM - anchorOffset; - structures[index] = { - ...target, - chainage_m: toChainageM, - start_m: start, - end_m: start + length, - }; - } else { - structures[index] = { ...target, chainage_m: toChainageM }; - } + // 구간은 길이를 유지한 채 통째로 — 기준 · 시작 · 끝 모두 정수 m(234-1) + structures[index] = movedStructure(structures[index], toChainageM); emit(); return true; }, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 5490be851..66e2b3966 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -22,6 +22,10 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures import { asRoad } from "./B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { + isSnappedStation, + snapStructureStation, +} from "../common_util/common_util_structure_station"; import { readGroupItems, readSummaryItem, @@ -32,6 +36,7 @@ import type { PipeFacilityItem, StructuresCallbacks, } from "./B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 패널이 쥔 값 중 폼 왕복·저장에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface StructuresCommitContext { @@ -168,7 +173,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte // 관으로 본다(다른 곳의 관 매칭 기준과 동일). const isTemp = ctx.tempPipeChainage() !== null && - Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < 0.51; + Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < PIPE_MATCH_M; if (isTemp) { ctx.setTempPipeChainage(pipe.chainage_m); ctx.setSelectedPipeChainage(pipe.chainage_m); @@ -304,8 +309,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis beforeInput?.focus(); return; } - start = Math.max(anchor - before, 0); - end = start + length; + // 시작 · 끝도 정수 m(234-1) — 안 고친 옛 소수 기준이면 옛 셈 그대로([정수 맞춤] 몫) + const fit = (value: number): number => + isSnappedStation(anchor as number) ? snapStructureStation(value) : value; + start = fit(Math.max(anchor - before, 0)); + end = Math.max(fit(start + length), start + Math.min(length, 1)); } else if (placement === "interval") { start = ctx.startFields.read(step, true); end = ctx.endFields.read(step, true); diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts new file mode 100644 index 000000000..3f29a57ac --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Snap.ts + * 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과 + * 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌). + * + * 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤 + * 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 + * 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다. + * ========================================================================== */ + +import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { + isSnappedStation, + snapStructureStation, +} from "../common_util/common_util_structure_station"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; + +export { 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"; + +/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */ +export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance { + const anchor = snapStructureStation(toChainageM); + 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))); + const snappedEnd = snapStructureStation(start + length); + const end = snappedEnd > start ? snappedEnd : start + length; + return { + ...target, + chainage_m: Math.min(Math.max(anchor, start), end), + start_m: start, + end_m: end, + }; +} + +/** 사라진 측점의 계획고 편집이 따라갈 새 측점 — 맞춘 자리(0.006m 안 · 213-2 와 같은 폭)의 것. */ +export function followSnapped( + station: T, + born: ReadonlyArray, +): T | null { + const to = snapStructureStation(station.chainage_m); + return born.find((next) => Math.abs(next.chainage_m - to) < 0.006) ?? null; +} + +const offGrid = (value: number | null | undefined): boolean => + typeof value === "number" && !isSnappedStation(value); + +/** 기준 · 시작 · 끝 중 하나라도 맞춤 밖(소수)인 구조물인가. */ +export const structureOffGrid = (structure: StructureInstance): boolean => + offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m); + +export interface SnapPlan { + /** 목록 자리 · 옮긴 모양. */ + structures: Array<{ index: number; next: StructureInstance }>; + pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>; + /** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */ + skipped: number; +} + +/** 옮길 것 셈 — 구조물은 옮긴 모양, 관은 (옛 자리 → 새 자리) · 자리 겹친 관은 건너뜀. */ +export function snapPlan( + structures: ReadonlyArray, + pipes: ReadonlyArray, +): SnapPlan { + const moved = structures.flatMap((structure, index) => + structureOffGrid(structure) + ? [{ index, next: movedStructure(structure, structureAnchorM(structure)) }] + : [], + ); + const taken = pipes.filter((pipe) => !offGrid(pipe.chainage_m)).map((pipe) => pipe.chainage_m); + const plan: SnapPlan = { structures: moved, pipes: [], skipped: 0 }; + [...pipes] + .filter((pipe) => offGrid(pipe.chainage_m)) + .sort((left, right) => left.chainage_m - right.chainage_m) + .forEach((pipe) => { + const to = snapStructureStation(pipe.chainage_m); + if (taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) { + plan.skipped += 1; + return; + } + taken.push(to); + plan.pipes.push({ from: pipe.chainage_m, to, pipe }); + }); + return plan; +} + +/** 관 한 건의 지금 속성 그대로 — 폼 [수정]과 같은 꼴(비운 칸 = 지움 규칙에 옛 값이 다 실림). */ +export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({ + facility: pipe.facility, + ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), + ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), + options: { ...(pipe.options ?? {}) }, + summary_item: pipe.summary_item ?? null, + group_items: pipe.group_items ?? null, +}); + +export interface SnapDockContext { + /** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */ + structures: StructureInstance[]; + pipes: () => ReadonlyArray; + /** 목록이 바뀜 알림(한 번). */ + emit: () => void; + /** 관 한 건 옮김 — 폼 [수정]과 같은 길. */ + movePipe: (from: number, to: number, attributes: FacilityAttributes) => void; + /** 열린 폼을 닫음 — 옛 측점을 쥔 폼이 다음 [수정]에 되돌리지 않게. */ + closeForm: () => void; +} + +/** [정수 맞춤] 단추를 얹은 목록 뿌리 — 아래 고정 영역에 목록 대신 놓인다. */ +export function snapDock(list: HTMLElement, ctx: SnapDockContext): HTMLElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-route__irregular-btn b05-structure__snap"; + button.textContent = "정수 맞춤"; + button.title = "소수 기준 측점(관 · 구조물)을 정수 m 로 — 일반 측점 1 m 안이면 그 측점"; + button.addEventListener("click", () => void runSnap(ctx)); + const head = document.createElement("div"); + head.className = "b05-structure__snap-row"; + head.append(button); + const dock = document.createElement("div"); + dock.className = "b05-structure__list-dock"; + dock.append(head, list); + return dock; +} + +export async function runSnap(ctx: SnapDockContext): Promise { + const plan = snapPlan(ctx.structures, ctx.pipes()); + const count = plan.structures.length + plan.pipes.length; + if (!count) { + showToast( + plan.skipped + ? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.` + : "소수 기준 측점이 없습니다.", + "info", + ); + return 0; + } + const ok = await showConfirmDialog( + `소수 기준 측점 ${count}건(관 ${plan.pipes.length} · 구조물 ${plan.structures.length})을 ` + + "정수 m(일반 측점 1 m 안이면 그 측점)로 옮깁니다. 계획고 편집도 따라가며, [저장]을 눌러야 남습니다.", + "정수 맞춤", + ); + if (!ok) return 0; + ctx.closeForm(); + if (plan.structures.length) { + plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next)); + ctx.emit(); + } + plan.pipes.forEach(({ from, to, pipe }) => ctx.movePipe(from, to, pipeAttributes(pipe))); + showToast( + `정수 맞춤 ${count}건` + + (plan.skipped ? ` · 자리 겹친 관 ${plan.skipped}건은 그대로` : "") + + " — [저장]을 눌러 남기세요.", + "success", + ); + return count; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index ed8512694..846d3623c 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -650,3 +650,15 @@ .b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area { color: var(--color-text-secondary); } + +/* [정수 맞춤] 단추 줄 — 구조물 목록 머리(234-1) */ +.b05-structure__snap-row { + display: flex; + justify-content: flex-end; + margin-bottom: var(--spacing-4); +} + +.b05-structure__snap { + flex: 0 0 auto; + padding: var(--spacing-4) var(--spacing-8); +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 85fd95747..d194c8c29 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -39,6 +39,7 @@ import { pavementSpecsFrom, type PavementStructureInput, } from "@util/common_util_structure_pavement"; +import { setGeneralStations } from "../common_util/common_util_structure_station"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; @@ -63,6 +64,7 @@ async function withDraftWalls( detail: SectionDetailResponse, projectId: string, ): Promise { + setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) const drafts = readPendingStructures(projectId); // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 @@ -214,6 +216,7 @@ export function replaceSectionDetail( ): void { cache.set(keyOf(projectId, routeId), detail); writeState("section-detail", detail, projectId, routeId); + setGeneralStations(detail.longitudinal?.stations); } /** diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index fc0d5a3ae..29bca82ca 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -48,6 +48,7 @@ import { type DesignSelection, } from "../A00_Common/design/design_selection"; import { showToast } from "@ui/ui_template_elements"; +import { snapStructureStation } from "../common_util/common_util_structure_station"; import { adjustDockRoot, refreshAdjustSlots, @@ -239,7 +240,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast(PIPE_ADD_GUIDE, "error"); return; } - const at = Number(chainageM.toFixed(2)); + const at = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) { showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error"); return; @@ -263,11 +264,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 // (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다. onPipeUpdate: (fromChainageM, toChainageM, attributes) => { - // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 - // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 - // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). - if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M) - movePipeTo(fromChainageM, toChainageM); + // 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1) + if (Math.abs(fromChainageM - toChainageM) > 0.005) movePipeTo(fromChainageM, toChainageM); if (attributes.summary_item !== undefined || attributes.group_items !== undefined) { // 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다. const mine = pipeFacilities.find( @@ -480,7 +478,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast(PIPE_MOVE_GUIDE, "error"); return; } - const to = Number(toChainageM.toFixed(2)); + const to = snapStructureStation(toChainageM); // 정수 m · 일반 측점 붙기(234-1) const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index cfddee991..36f17d038 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -15,6 +15,7 @@ import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { pickGroups } from "../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */ export interface SectionStructureEdit { @@ -57,7 +58,7 @@ export function moveMarkById( if (!station) return; // 측점선 id 로 온 경우 — 그 측점 자리에 선 구조물을 찾는다(관 매칭과 같은 0.51m). const near = input.structures.find( - (entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < 0.51, + (entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < PIPE_MATCH_M, ); if (near) moveStructureMark(input.structures, input.edit, String(near.structure_id), toChainageM); } diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 0df0e0f12..3cac3c62d 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,7 +15,7 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; -import { coversChainage } from "../A00_Common/design/design_selection"; +import { coversChainage, PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -40,7 +40,7 @@ import { } from "./B06_Section_UI_Section_View_Menu"; /** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ -const MATCH_M = 0.51; +const MATCH_M = PIPE_MATCH_M; /** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */ const LEGACY_TYPE_IDS: Record = { 기성막이: "revetment", diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 8e2a2f40b..7ae990503 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -47,6 +47,7 @@ from common_util.common_util_route_geometry import ( interpolate_vertex, is_uphill_at, ) +from common_util.common_util_structure_station import snap_candidates from common_util.common_util_wamis_rainfall import idf_intensity from config.config_system import ( DRAINAGE_BOX_THRESHOLD_MM, @@ -198,7 +199,8 @@ def build_detail( ) for pipe in routing.base_pipes ] - pipes = place_pipes(vertices, base, routing.strength_curve) + # 자동 관 자리 = 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 확정본(위)은 단추로만 + pipes = snap_candidates(place_pipes(vertices, base, routing.strength_curve), vertices) detail = DrainageDetail( route_lonlat=routing.route_lonlat, diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 2a3c67f3e..1b8454b1c 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -35,6 +35,10 @@ from shapely.geometry import LineString, Point from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex +from common_util.common_util_structure_station import ( + route_general_stations, + snap_structure_station, +) from common_util.common_util_summary_item import parse_group_items, parse_summary_item from config.config_system import ( DRAINAGE_CACHE_DIRNAME, @@ -216,10 +220,12 @@ def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> line = LineString([(vertex.x, vertex.y) for vertex in vertices]) if line.length <= 0: return points + # 새 노선 위 자리도 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 시설 속성은 관과 함께 옮겨 감 + stations = route_general_stations(vertices[-1].chainage_m) for point in points: if point.x is None or point.y is None: continue - moved = float(line.project(Point(point.x, point.y))) + moved = snap_structure_station(float(line.project(Point(point.x, point.y))), stations) shift = moved - float(point.chainage_m) point.chainage_m = moved if point.start_m is not None: diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py new file mode 100644 index 000000000..79eb2cdd3 --- /dev/null +++ b/common_util/common_util_structure_station.py @@ -0,0 +1,80 @@ +"""구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1). + +TS 짝 = `common_util/common_util_structure_station.ts`(화면 입력 · 끌기 · 방향키 · 정수 맞춤 단추). +거울 시험 = `resources/tester/test_234_1_structure_station.py`. +일반 측점 = 격자(간격) · 시종점 — 서버 자동 배치 · 노선 재투영이 쓴다. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import replace +from typing import TypeVar + +from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex +from config.config_system_design import SECTION_STATION_INTERVAL_M + +_Candidate = TypeVar("_Candidate") + +# 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). +GENERAL_SNAP_M = 1.0 +_EPS = 1e-6 + + +def general_stations(total_m: float, interval_m: float) -> list[float]: + """격자 측점 + 끝 측점(`Sections_Core._chainages` 와 같은 줄).""" + if not (total_m > 0 and interval_m > 0): + return [0.0] + values = [index * interval_m for index in range(int(total_m // interval_m) + 1)] + if total_m - values[-1] > _EPS: + values.append(float(total_m)) + return values + + +def _near_general(value: float, stations: Sequence[float]) -> float | None: + best: float | None = None + for station in stations: + gap = abs(station - value) + if gap < GENERAL_SNAP_M - _EPS and (best is None or gap < abs(best - value)): + best = float(station) + return best + + +def snap_structure_station(chainage_m: float, stations: Sequence[float]) -> float: + """기준 측점 맞춤 — 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m(.5 는 올림) · 정수가 일반 + 측점 1 m 안이면 그 측점. 두 번 맞춰도 같다.""" + value = float(chainage_m) + if not math.isfinite(value): + return value + direct = _near_general(value, stations) + if direct is not None: + return direct + whole = float(math.floor(value + 0.5)) + near = _near_general(whole, stations) + return whole if near is None else near + + +def route_general_stations(total_m: float) -> list[float]: + """노선 길이의 일반 측점 — 서버 측점 간격(`SECTION_STATION_INTERVAL_M`).""" + return general_stations(total_m, SECTION_STATION_INTERVAL_M) + + +def snap_candidates( + candidates: Sequence[_Candidate], vertices: Sequence[RouteVertex] +) -> list[_Candidate]: + """자동 배치 관 자리 맞춤 — 좌표는 맞춘 자리로 다시 찍고, 같은 자리로 모이면 앞선 것만. + + `candidates` 는 `chainage_m · x · y` 를 가진 dataclass(`StructureCandidate`).""" + total_m = float(vertices[-1].chainage_m) + stations = route_general_stations(total_m) + snapped: list[_Candidate] = [] + taken: set[float] = set() + for candidate in candidates: + at = min(snap_structure_station(candidate.chainage_m, stations), total_m) + if at in taken: + continue + taken.add(at) + x, y, _ = interpolate_vertex(list(vertices), at) + snapped.append(replace(candidate, chainage_m=at, x=x, y=y)) + return snapped diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts new file mode 100644 index 000000000..4c5cc21d2 --- /dev/null +++ b/common_util/common_util_structure_station.ts @@ -0,0 +1,69 @@ +/* ============================================================================= + * common_util/common_util_structure_station.ts + * 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안이면 그 측점에 붙음(234-1). + * + * 파이썬 짝 = `common_util/common_util_structure_station.py`(서버 자동 배치 · 노선 재투영). + * 거울 시험 = `resources/tester/test_234_1_structure_station.py`. + * 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 상세를 읽을 때 한 번 둔다. + * ========================================================================== */ + +/** 일반 측점에 붙는 폭(m) — 이 안이면 그 측점 값(끝 측점처럼 정수가 아니어도). */ +export const GENERAL_SNAP_M = 1; +const EPS = 1e-6; + +let general: number[] = []; + +/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */ +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); +} + +export const generalStations = (): readonly number[] => general; + +function nearGeneral(value: number, stations: readonly number[]): number | null { + let best: number | null = null; + for (const station of stations) { + const gap = Math.abs(station - value); + if (gap < GENERAL_SNAP_M - EPS && (best === null || gap < Math.abs(best - value))) + best = station; + } + return best; +} + +/** 기준 측점 맞춤 — 일반 측점 1 m 안이면 그 측점 · 아니면 정수 m(.5 는 올림) · 정수가 일반 측점 + * 1 m 안이면 그 측점(끝 119.8 옆 119 → 119.8). 두 번 맞춰도 같다. */ +export function snapStructureStation( + chainageM: number, + stations: readonly number[] = general, +): number { + if (!Number.isFinite(chainageM)) return chainageM; + const direct = nearGeneral(chainageM, stations); + if (direct !== null) return direct; + const whole = Math.floor(chainageM + 0.5); + return nearGeneral(whole, stations) ?? whole; +} + +/** 방향키 한 걸음(1 m) — 붙기가 제자리로 되돌리면 한 걸음 더(끝 119.8 → 118). 노선 밖이면 제자리. */ +export function stepStructureStation( + fromM: number, + direction: 1 | -1, + stations: readonly number[] = general, +): number { + const end = stations.length ? Math.max(...stations) : Infinity; + for (let steps = 1; steps <= 3; steps += 1) { + const next = snapStructureStation(fromM + direction * steps, stations); + if (next < 0 || next > end + EPS) return fromM; + if ((next - fromM) * direction > EPS) return next; + } + return fromM; +} + +/** 이미 맞춘 값인가(정수 맞춤 단추가 옮길 것을 가름). */ +export const isSnappedStation = ( + chainageM: number, + stations: readonly number[] = general, +): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS; diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 209ea2d1f..f9fc900b3 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -148,6 +148,10 @@ const panelModule = loadTs( "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": { readStructurePick: () => null }, "../A00_Common/design/design_selection": selection, "@ui/ui_template_elements": { showToast() {} }, + // 관 기준 측점 정수 맞춤(234-1) — 일반 측점 없으면 정수 m 만 + "../common_util/common_util_structure_station": { + snapStructureStation: (value) => Math.floor(value + 0.5), + }, "./B06_Section_UI_Adjust_Dock": { adjustDockRoot: () => ({}), refreshAdjustSlots() {}, diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 24d17d3e8..0f8153e01 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -167,6 +167,10 @@ const panelModule = loadTs( "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": pickSession, "../A00_Common/design/design_selection": selection, "@ui/ui_template_elements": { showToast() {} }, + // 관 기준 측점 정수 맞춤(234-1) — 일반 측점 없으면 정수 m 만 + "../common_util/common_util_structure_station": { + snapStructureStation: (value) => Math.floor(value + 0.5), + }, "./B06_Section_UI_Adjust_Dock": { adjustDockRoot: () => ({}), refreshAdjustSlots() {}, diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs new file mode 100644 index 000000000..e6717ba84 --- /dev/null +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -0,0 +1,192 @@ +/* 234-1 구조물 기준 측점 = 정수 m · 일반 측점 붙기 · [정수 맞춤]. + * 진짜 `common_util_structure_station` · `_Structures_Snap` · `_Structures_Fields` · `_Drainage_Pipes` · + * `followedStation` 을 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; +} + +/* 가짜 DOM — 폼 칸(input · label · span)만. */ +function fakeElement(tag) { + const listeners = {}; + const classes = new Set(); + return { + tagName: tag, + value: "", + className: "", + style: {}, + children: [], + classList: { + toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)), + add: (name) => classes.add(name), + remove: (name) => classes.delete(name), + contains: (name) => classes.has(name), + }, + append(...nodes) { + this.children.push(...nodes); + }, + addEventListener(type, fn) { + (listeners[type] ??= []).push(fn); + }, + fire(type) { + (listeners[type] ?? []).forEach((fn) => fn({ type })); + }, + focus() {}, + }; +} +global.document = { createElement: fakeElement }; + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const station = loadTs("common_util/common_util_structure_station.ts"); +const STATIONS = input.stations; +const out = {}; + +// ① 맞춤 · 걸음 — 파이썬과 같은 값(거울) +out.snap = input.samples.map((value) => station.snapStructureStation(value, STATIONS)); +out.step = input.steps.map(([from, dir]) => station.stepStructureStation(from, dir, STATIONS)); +out.snapTwice = input.samples.every( + (value) => + station.snapStructureStation(station.snapStructureStation(value, STATIONS), STATIONS) === + station.snapStructureStation(value, STATIONS), +); + +// 일반 측점 등록 — 구조물 측점(irregular)은 뺌 · 등록값이 기본으로 쓰임 +station.setGeneralStations( + STATIONS.map((value) => ({ chainage_m: value, kind: "regular" })).concat([ + { chainage_m: 80.17, kind: "irregular" }, + ]), +); +out.general = [...station.generalStations()]; +out.defaultSnap = station.snapStructureStation(80.4); + +// ② 좌측 폼 칸 — 친 값 80.17 → 80 · 안 고친 옛 값은 그대로 +const fields = loadTs("B05_Profile/B05_Profile_UI_Structures_Fields.ts", { + "./B05_Profile_Util_Station": loadTs("B05_Profile/B05_Profile_Util_Station.ts"), + "../B06_Section/B06_Section_UI_Cross_Culvert_Basin": { DEFAULT_BASIN_SHAPE: {} }, + "../common_util/common_util_culvert_sets": { fillPipeHeights: () => {} }, + "../common_util/common_util_structure_station": station, +}); +const anchor = fields.stationFields("기준 측점", () => 20); +out.remainderStep = anchor.remainder.step; +anchor.station.value = "4"; +anchor.remainder.value = "0.17"; +out.typed = anchor.read(20, true); +anchor.remainder.value = "0.6"; +out.typedNear = anchor.read(20, true); +anchor.remainder.value = "1"; +out.typedOne = anchor.read(20, true); +anchor.write(80.17, 20); +out.untouched = anchor.read(20, true); + +// ③ 구조물 옮김 · [정수 맞춤] 셈 · 단추 +const toasts = []; +let confirmText = null; +const snap = loadTs("B05_Profile/B05_Profile_UI_Structures_Snap.ts", { + "@ui/ui_template_elements": { + showToast: (message, kind) => toasts.push({ message, kind }), + showConfirmDialog: async (message) => ((confirmText = message), 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, + }, +}); +out.movedPoint = snap.movedStructure({ placement: "point", chainage_m: 127.86 }, 127.86); +out.movedWall = snap.movedStructure( + { placement: "interval", chainage_m: 80.17, start_m: 75.17, end_m: 95.67 }, + 80.17, +); +out.movedDrag = snap.movedStructure( + { placement: "interval", chainage_m: 50, start_m: 50, end_m: 62 }, + 133.4, +); + +const structures = input.structures.map((entry) => ({ ...entry })); +const pipes = input.pipes.map((chainage_m) => ({ + chainage_m, + facility: "pipe", + options: { pipe_kind: "흄관" }, + summary_item: null, + group_items: null, +})); +const plan = snap.snapPlan(structures, pipes); +out.plan = { + structures: plan.structures.map(({ index, next }) => ({ index, next })), + pipes: plan.pipes.map(({ from, to }) => [from, to]), + skipped: plan.skipped, +}; +const moves = []; +let emits = 0; +let closed = 0; +snap + .runSnap({ + structures, + pipes: () => pipes, + emit: () => (emits += 1), + movePipe: (from, to, attributes) => moves.push({ from, to, attributes }), + closeForm: () => (closed += 1), + }) + .then(async (count) => { + out.run = { count, emits, closed, moves, structures, confirmText, toasts: [...toasts] }; + // 두 번째 누름 = 옮길 것 없음(관은 위 moves 로 옮겨졌다고 보고 목록을 맞춤) + moves.forEach(({ from, to }) => { + const hit = pipes.find((pipe) => pipe.chainage_m === from); + if (hit) hit.chainage_m = to; + }); + toasts.length = 0; + const again = { structures, pipes: () => pipes, emit() {}, movePipe() {}, closeForm() {} }; + out.second = { count: await snap.runSnap(again), toasts: [...toasts] }; + + // ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘) + const alignment = loadTs("B05_Profile/B05_Profile_UI_Profile_Alignment.ts"); + const gone = [ + { id: "pipe-80.17", chainage_m: 80.17 }, + { id: "pipe-263.31", chainage_m: 263.31 }, + ]; + const born = [ + { id: "pipe-80.00", chainage_m: 80 }, + { id: "pipe-263.00", chainage_m: 263 }, + ]; + out.follow = gone.map((entry) => { + const old = alignment.followedStation(entry, gone, born); + const next = old ?? snap.followSnapped(entry, born); + return { old: old?.chainage_m ?? null, next: next?.chainage_m ?? null }; + }); + + // ⑤ 배수유역도 관 지점 — 옮김 · 우클릭 추가 = 정수 m + const pipesEditor = loadTs("B05_Profile/B05_Profile_UI_Drainage_Pipes.ts", { + "@ui/ui_template_palette": { themeColor: () => "#000" }, + "../common_util/common_util_structure_station": station, + }); + const editor = pipesEditor.createPipeEditor(() => {}); + editor.setContext(null, [ + { x: 0, y: 0, chainage_m: 0 }, + { x: 300, y: 0, chainage_m: 300 }, + ]); + editor.setPipes([{ chainage_m: 80.17, reason: "stream" }]); + editor.moveTo(0, 141.37); + editor.addAtChainage(200.6); + out.editor = editor.pipes().map((pipe) => pipe.chainage_m); + + process.stdout.write(JSON.stringify(out)); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/helper_b05_drainage_report.cjs b/resources/tester/helper_b05_drainage_report.cjs index 402b32bba..c8aac2cfa 100644 --- a/resources/tester/helper_b05_drainage_report.cjs +++ b/resources/tester/helper_b05_drainage_report.cjs @@ -8,23 +8,24 @@ const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); +// 찾는 폭 한 값(234-1 · `PIPE_MATCH_M`) — 세션 저장소까지 끌고 오지 않게 값만 줌 +const localRequire = (request) => + request === "../A00_Common/design/design_selection" ? { PIPE_MATCH_M: 0.51 } : require(request); +new Function("exports", "module", "require", js)(box.exports, box, localRequire); const { buildPipeReports } = box.exports; const store = { get: (chainage) => - chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null, + chainage === 100.5 + ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } + : null, }; const basins = [ { chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false }, { chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true }, ]; const out = buildPipeReports( - [ - { chainage_m: 40.004, reason: "auto" }, - { chainage_m: 100.499 }, - { chainage_m: 200 }, - ], + [{ chainage_m: 40.004, reason: "auto" }, { chainage_m: 100.499 }, { chainage_m: 200 }], basins, store, ); diff --git a/resources/tester/test_219_1_arrow_owner.py b/resources/tester/test_219_1_arrow_owner.py index f9884020d..cd6ae78f9 100644 --- a/resources/tester/test_219_1_arrow_owner.py +++ b/resources/tester/test_219_1_arrow_owner.py @@ -63,9 +63,10 @@ def test_횡단_카드를_누른_뒤_좌우키는_관을_안_옮김(out) -> None @needs_node def test_종단도를_누른_뒤_좌우키는_지금처럼_이동(out) -> None: - assert out["profileThenArrows"]["move"] == [[263.21, 263.31]] - assert out["popoutThenArrows"]["move"] == [[263.21, 263.11]] # 새 창 종단도도 - assert out["chuteThenProfile"]["move"] == [[263.21, 263.31]] + # 좌우 한 걸음 = 1 m · 정수 m(234-1 · 옛 0.1 m) + assert out["profileThenArrows"]["move"] == [[263.21, 264]] + assert out["popoutThenArrows"]["move"] == [[263.21, 262]] # 새 창 종단도도 + assert out["chuteThenProfile"]["move"] == [[263.21, 264]] @needs_node diff --git a/resources/tester/test_234_1_structure_station.py b/resources/tester/test_234_1_structure_station.py new file mode 100644 index 000000000..885ec58af --- /dev/null +++ b/resources/tester/test_234_1_structure_station.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +"""234-1 구조물 · 관 기준 측점 = 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추. + +9-29 「저장값 소수 · 표기만 정수」(20장)를 「저장값도 정수 m」로 바꿈 +(사용자 확정 10-10 · 조사 AF 7절 A+B+C). +맞춤 규칙 한 벌 = 파이썬 `common_util_structure_station.py` ↔ +TS `common_util_structure_station.ts`(거울). +화면 쪽 진짜 TS(폼 칸 · 구조물 옮김 · 단추 · 배수유역도 관 · 계획고 편집 따라감)는 +`helper_234_1_structure_station.cjs` 가 Node 로 돌린다. 서버 자동 배치 · 노선 재투영은 파이썬으로. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402 +from common_util.common_util_route_geometry import RouteVertex, StructureCandidate # noqa: E402 +from common_util.common_util_structure_station import ( # noqa: E402 + general_stations, + snap_candidates, + snap_structure_station, +) + +TOTAL = 279.8 +STATIONS = general_stations(TOTAL, 20.0) +# (입력, 기대) — 소수 → 정수 · 일반 측점 1 m 안 붙기 · 끝 측점(소수) 붙기 +CASES = [ + (80.17, 80.0), # 관 80.17 = 카드 80 한 자리 + (80.6, 80.0), # 1 m 안 → 일반 측점 + (80.99, 80.0), + (81.0, 81.0), # 꼭 1 m 는 밖 — 81 에 둘 수 있음(방향키가 갇히지 않음) + (50.5, 51.0), # .5 는 올림(파이썬 · TS 같은 꼴) + (50.4, 50.0), + (263.31, 263.0), + (263.206, 263.0), + (127.86, 128.0), + (0.3, 0.0), + (279.3, 279.8), # 끝 측점 1 m 안 → 끝 측점(정수가 아닌 일반 측점) + (278.6, 279.8), # 반올림 279 가 끝 측점 1 m 안 + (277.4, 277.0), +] +STEPS = [ + ((80.0, 1), 81.0), + ((80.17, 1), 81.0), + ((80.17, -1), 80.0), # 79.17 은 일반 측점 80 의 1 m 안 + ((279.8, -1), 278.0), # 279 는 끝 측점에 붙어 제자리 → 한 걸음 더 + ((279.8, 1), 279.8), # 노선 밖은 제자리 + ((0.0, -1), 0.0), +] +STRUCTURES = [ + {"structure_id": "sign", "placement": "point", "chainage_m": 127.86}, + { + "structure_id": "wall", + "placement": "interval", + "chainage_m": 80.17, + "start_m": 75.17, + "end_m": 95.67, + }, + {"structure_id": "ok", "placement": "point", "chainage_m": 40.0}, + {"structure_id": "near", "placement": "point", "chainage_m": 20.4}, +] +PIPES = [80.17, 120.0, 120.3, 263.31, 140.0] + +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("s234") / "in.json" + source.write_text( + json.dumps( + { + "stations": STATIONS, + "samples": [value for value, _ in CASES], + "steps": [list(args) for args, _ in STEPS], + "structures": STRUCTURES, + "pipes": PIPES, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_234_1_structure_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_general_stations() -> None: + assert STATIONS == [float(index * 20) for index in range(14)] + [279.8] + + +@pytest.mark.parametrize(("value", "expected"), CASES) +def test_snap_python(value: float, expected: float) -> None: + assert snap_structure_station(value, STATIONS) == expected + # 두 번 맞춰도 같음 + assert snap_structure_station(expected, STATIONS) == expected + + +def test_mirror_python_ts(ts) -> None: + assert ts["snap"] == [snap_structure_station(value, STATIONS) for value, _ in CASES] + assert ts["snapTwice"] is True + + +def test_arrow_step_one_metre(ts) -> None: + assert ts["step"] == [expected for _, expected in STEPS] + + +def test_general_registry_skips_irregular(ts) -> None: + assert ts["general"] == STATIONS # 구조물 측점 80.17(irregular)은 일반 측점 아님 + assert ts["defaultSnap"] == 80 + + +def test_form_input_integer(ts) -> None: + assert ts["remainderStep"] == "1" + assert ts["typed"] == 80 # 입력 4+0.17 = 80.17 → 80 + assert ts["typedNear"] == 80 # 4+0.6 → 일반 측점 80 에 붙음 + assert ts["typedOne"] == 81 + assert ts["untouched"] == 80.17 # 안 고친 옛 값은 그대로(단추 몫) + + +def test_moved_structure_range_ends_integer(ts) -> None: + assert ts["movedPoint"]["chainage_m"] == 128 + wall = ts["movedWall"] + assert (wall["chainage_m"], wall["start_m"], wall["end_m"]) == (80, 75, 96) + drag = ts["movedDrag"] + assert (drag["chainage_m"], drag["start_m"], drag["end_m"]) == (133, 133, 145) + + +def test_snap_button_plan_and_run(ts) -> None: + plan = ts["plan"] + assert [entry["index"] for entry in plan["structures"]] == [0, 1, 3] + assert plan["pipes"] == [[80.17, 80], [263.31, 263]] + assert plan["skipped"] == 1 # 120.3 → 120 자리에 관이 이미 있음 + run = ts["run"] + assert run["count"] == 5 and run["emits"] == 1 and run["closed"] == 1 + assert [s["chainage_m"] for s in run["structures"]] == [128, 80, 40, 20] + assert (run["structures"][1]["start_m"], run["structures"][1]["end_m"]) == (75, 96) + # 관은 하나씩 폼 [수정]과 같은 길 — 시설 속성이 그대로 실림 + assert [(m["from"], m["to"]) for m in run["moves"]] == [(80.17, 80), (263.31, 263)] + assert run["moves"][0]["attributes"]["options"] == {"pipe_kind": "흄관"} + assert run["moves"][0]["attributes"]["facility"] == "pipe" + assert "5건" in run["confirmText"] + assert run["toasts"][-1]["message"].startswith("정수 맞춤 5건") + assert "1건은 그대로" in run["toasts"][-1]["message"] + # 두 번째 누름 — 옮길 것 없음(겹친 관만 알림) + assert ts["second"]["count"] == 0 + assert "1건은 맞출 자리에 다른 관" in ts["second"]["toasts"][0]["message"] + + +def test_plan_edit_follows_batch_move(ts) -> None: + # 213-2 규칙만으로는 여럿을 한꺼번에 옮기면 못 따라감(null) → 맞춘 자리로 따라감 + assert ts["follow"] == [{"old": None, "next": 80}, {"old": None, "next": 263}] + + +def test_drainage_map_pipe_integer(ts) -> None: + assert ts["editor"] == [141, 200] # 옮김 141.37 → 141 · 추가 200.6 → 일반 측점 200 + + +def _vertices() -> list[RouteVertex]: + return [RouteVertex(x=0.0, y=0.0, z=0.0, chainage_m=0.0), RouteVertex(TOTAL, 0.0, 0.0, TOTAL)] + + +def test_server_auto_placement_integer() -> None: + vertices = _vertices() + placed = [ + StructureCandidate(chainage_m=80.374, x=80.374, y=0.0, reason="stream"), + StructureCandidate(chainage_m=150.6, x=150.6, y=0.0, reason="spacing"), + StructureCandidate(chainage_m=150.9, x=150.9, y=0.0, reason="spacing"), + ] + snapped = snap_candidates(placed, vertices) + assert [(p.chainage_m, p.x, p.reason) for p in snapped] == [ + (80.0, 80.0, "stream"), + (151.0, 151.0, "spacing"), # 같은 자리로 모인 둘째는 버림 + ] + + +def test_server_reprojection_integer() -> None: + points = [PipePoint(chainage_m=70.0, x=80.374, y=3.0, start_m=75.0, end_m=85.0)] + moved = project_pipe_points(points, _vertices()) + assert moved[0].chainage_m == 80.0 + assert (moved[0].start_m, moved[0].end_m) == (85.0, 95.0) # 구간은 같은 만큼 밀림 + + +def test_wiring_source() -> None: + """받는 곳마다 맞춤이 걸렸는지(짝 머리 · 폼 · 끌기 · 방향키 · 관 · 단추).""" + + def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + assert "common_util_structure_station.py" in read( + "common_util/common_util_structure_station.ts" + ) + assert "common_util_structure_station.ts" in read( + "common_util/common_util_structure_station.py" + ) + assert "movedStructure(structures[index], toChainageM)" in read( + "B05_Profile/B05_Profile_UI_Structures_Panel.ts" + ) + assert "snapDock(list" in read("B05_Profile/B05_Profile_UI_Structures_Panel.ts") + assert "stepStructureStation(fromM" in read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + b06 = read("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") + assert "const to = snapStructureStation(toChainageM)" in b06 + assert "Math.abs(fromChainageM - toChainageM) > 0.005" in b06 + assert "setGeneralStations" in read("B06_Section/B06_Section_Section_Store.ts") + assert "snap_candidates(" in read("common_util/common_util_drainage_detail.py") + for rel in ( + "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", + "B05_Profile/B05_Profile_UI_IrregularStations.ts", + "B05_Profile/B05_Profile_UI_Drainage_Parts.ts", + "B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts", + ): + assert "< 0.51" not in read(rel), rel # 찾는 폭 = PIPE_MATCH_M 한 값 diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py index 22d113943..6c8ad7142 100644 --- a/resources/tester/test_b05_station_roundtrip.py +++ b/resources/tester/test_b05_station_roundtrip.py @@ -42,7 +42,8 @@ def test_화면_표기는_거리를_정수로(results): def test_칸을_고치면_친_값(results): - assert results["edited"] == pytest.approx(125.2) + # 친 값 125.2 → 정수 m 125(234-1 · 저장값도 정수 m — 9-29 「소수 그대로」를 바꿈) + assert results["edited"] == 125 assert results["normalized"] == ["2", "10.0", 50] assert results["cleared"] is None