feat(B05·B06): 구조물 · 관 기준 측점 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추(234-1)

- 맞춤 규칙 한 벌 — common_util_structure_station(파이썬 · TS 짝 · 거울 시험)
- 입력 — 좌측 폼 칸(1 m 단계 · 친 값만) · 끌기 · 우클릭 · 방향키(1 m 걸음) · 배수유역도 관 · B06 관 옮김 · 추가
- 구간형 시작 · 끝도 정수 m · 일반 측점 = 횡단 상세를 읽을 때 등록
- 서버 자동 배치 · 노선 재투영 자리도 정수 m(확정본은 단추로만)
- [정수 맞춤] — 구조물 목록 머리 · 확인 창 · 관은 하나씩 폼 [수정] 길 · 계획고 편집 따라감 · 결과 n건 알림
- 찾는 폭 한 값 — 손으로 적은 0.51 을 PIPE_MATCH_M 으로
- 시험 — test_234_1 새로 · 1 m 걸음 · 정수 입력에 맞춘 기대값 고침(219_1 · station_roundtrip) · 헬퍼 대역 보탬

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 19:05:52 +09:00
co-authored by Claude Opus 5.5
parent 6a86bd0a2b
commit 994b91636c
31 changed files with 874 additions and 78 deletions
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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());
}
@@ -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<string, number> = {
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; // 유량 아직 없음 — 다음 응답까지 빈칸
+16 -6
View File
@@ -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를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다.
+4 -3
View File
@@ -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<DetailBasin>, 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;
}
+11 -9
View File
@@ -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();
@@ -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,
@@ -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);
@@ -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;
@@ -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;
@@ -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 짝짓기.
@@ -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;
+12 -18
View File
@@ -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;
},
@@ -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);
@@ -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<T extends { chainage_m: number }>(
station: T,
born: ReadonlyArray<T>,
): 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<StructureInstance>,
pipes: ReadonlyArray<PipeFacilityItem>,
): 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<PipeFacilityItem>;
/** 목록이 바뀜 알림(한 번). */
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<number> {
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;
}
@@ -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);
}
+3
View File
@@ -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<SectionDetailResponse> {
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);
}
/**
@@ -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,
);
@@ -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);
}
@@ -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<string, string> = {
기성막이: "revetment",
+3 -1
View File
@@ -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,
+7 -1
View File
@@ -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:
@@ -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
@@ -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;
@@ -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() {},
@@ -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() {},
@@ -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);
});
@@ -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,
);
+4 -3
View File
@@ -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
@@ -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 한 값
@@ -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