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:
@@ -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 {
|
||||
|
||||
@@ -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; // 유량 아직 없음 — 다음 응답까지 빈칸
|
||||
|
||||
@@ -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를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user