feat(380-2): 사토장 · 토취장 자리 = 가장 가까운 기존 측점 · 새 측점 안 심음

- common_util_structure_station: 기존 측점(일반 · 구조물 · 추가) · snapExistingStation(가운데면 앞 측점) · snapStationFor(site 만)
- 폼 [추가] · 끌기(movedStructure) · 우클릭 추가 = 같은 맞춤 · 폼 칸에 맞춘 측점 다시 씀
- 옛 소수 자동 맞춤 = 기존 측점(끝 소수 · 옛 구조물 측점)에 선 site 안 옮김
- 상세 모달 CAD 글 = 맞춘 측점에서 종류 이름 + 「상세 내용: 사토장 부피」 줄(기존 길 그대로)
- 시험 test_380_2_site_station(아무 m 입력 = 가까운 측점 · CAD 글 줄)

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