feat(B05·B06 측점): 추가 측점 자료 · 심기 · [저장] · 좌표 따라감 (368-2)

- 측점 계획 짝 planStations(plus) ↔ plan_stations(plus=) — 주인 plus-{m} · 이름 "" · 같은 m 은 관 · 구조물이 이김
- 정본 B05_Profile/route/plus_stations.json(x · y) · GET/PUT /section/plus-stations · PUT 뒤 측점 맞춤
- 초안 plus-stations(설계 가게 plusStations) · [저장] 차례 구조물 뒤 · 계획고 앞
- 노선 저장 = 좌표로 따라감 · 되살림 대상 · B05 종단 · B06 목록 바뀜 신호
- 종단 세로선 plus 표시(점선 · 끌기 없음 자리)
- 거울 시험 272_1 · 322_3 · 344_3 에 추가 측점 경우 · 322_3 관 id 고정(347) 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
2026-10-17 12:02:26 +09:00
co-authored by Claude Opus 5.5
parent 84ead5a561
commit ae515ee778
28 changed files with 461 additions and 24 deletions
+2
View File
@@ -124,6 +124,8 @@ export const STATE_REGISTRY = {
"detail-table": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
/** 아직 정본에 안 넣은 추가 측점(정수 m 목록 · 368-2) — 정본 `plus_stations.json` · [저장]이 내보냄. */
"plus-stations": { bucket: "draft", scope: "project" },
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` },
/** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림.
+6 -1
View File
@@ -2,7 +2,7 @@
* A00_Common/design/design_save.ts
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
*
* 노선 → 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
* 노선 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
*
* 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan`
* (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고
@@ -28,6 +28,7 @@ import {
import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { rebasePipeDraft } from "../../common_util/common_util_route_follow";
import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures";
import { flushPlusStations } from "../../B05_Profile/B05_Profile_Api_PlusStations";
import {
flushUphillOverrides,
saveProfileAlignment,
@@ -51,6 +52,7 @@ export const DESIGN_SAVE_ORDER = [
"route",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
@@ -130,6 +132,7 @@ async function flushAlignmentDraft(
const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
pipes: "배수관",
structures: "구조물",
plusStations: "추가 측점",
alignment: "계획선",
};
@@ -145,6 +148,8 @@ function defaultStep(
return () => flushPendingPipes(projectId);
case "structures":
return () => flushPendingStructures(projectId);
case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2)
return () => flushPlusStations(projectId);
case "alignment":
return () => flushAlignmentDraft(projectId, routeId, fromRouteId);
case "culvertOptions":
+3
View File
@@ -42,6 +42,8 @@ export interface DesignSelection {
export interface DesignStoreValues {
/** 미저장 구조물 목록(정본 `structures.json`). */
structures: StructureInstance[] | null;
/** 미저장 추가 측점(정수 m · 정본 `plus_stations.json` · 368-2). */
plusStations: number[] | null;
/** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */
pipes: DetailPipeInput[] | null;
/** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */
@@ -119,6 +121,7 @@ function slotOf<K extends DesignStoreKey>(
const name = (
{
structures: "structures",
plusStations: "plus-stations",
pipes: "pipes",
sectionDetail: "section-detail",
selection: "selection",
+2 -1
View File
@@ -45,7 +45,7 @@ export function plannedIrregular(
}
/**
* 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
* 바뀜 신호 하나(323-6) — 관 · 구조물 · 추가 측점 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
* 돌려주는 함수로 끊음.
*/
@@ -66,6 +66,7 @@ export function watchDrafts(projectId: string | null, onChange: () => void): ()
let seen = layout(shop.get("pipes"));
const offs = [
shop.subscribe("structures", onChange),
shop.subscribe("plusStations", onChange), // 추가 측점(368-2)
shop.subscribe("pipes", (pipes) => {
const key = layout(pipes);
if (key === seen) return;
+4
View File
@@ -419,6 +419,10 @@ export async function mountProfileView(
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
const reloadIfShown = (): void => void (host.visible() && !state.restoring && bridge.load());
host.onDispose(onStaleStructureTypes(reloadIfShown));
// 추가 측점(368-2) — 초안 · 저장분이 바뀌면 측점 계획을 다시 맞춤(종단 측점선 · 3D)
host.onDispose(
useDesignStore(activeProjectId, null).subscribe("plusStations", () => bridge.resyncStations()),
);
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
bindProfileViewLife(host, {
projectId: activeProjectId,
@@ -0,0 +1,78 @@
/* =============================================================================
* B05_Profile_Api_PlusStations.ts
* 추가 측점(368-2 · 요구 240-21) — 종단 원지반선에서 넣은 일반 측점(정수 m · 이름 없음).
*
* 초안 = 설계 가게 `plusStations`(세션 · 되돌리기 사진첩에 듦) · 저장분 = 서버 `plus_stations.json`
* (`GET/PUT /section/plus-stations`) · [저장] 차례 = 구조물 뒤 · 계획고 앞(`design_save`).
* 측점 계획(`planStations`)이 읽는 곳 = 초안 ?? 저장분(`setPlusStations` · 부를 때마다 읽음).
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS } from "../config/config_frontend";
import { useDesignStore } from "../A00_Common/design/design_store";
const saved = new Map<string, number[]>();
const loaded = new Map<string, Promise<void>>();
async function request(projectId: string, init: RequestInit = {}): Promise<number[]> {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/section/plus-stations`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
...init,
});
const payload = await response.json().catch(() => null);
if (!response.ok)
throw new Error(payload?.message ?? `요청이 실패했습니다 (${response.status}).`);
return Array.isArray(payload?.chainages) ? payload.chainages.map(Number) : [];
} finally {
window.clearTimeout(timer);
}
}
export const readPlusDraft = (projectId: string): number[] | null =>
useDesignStore(projectId, null).get("plusStations");
/** 지금 추가 측점 — 초안이 있으면 초안(빈 목록 = 다 지움) · 없으면 저장분. */
export const plusStationsOf = (projectId: string): number[] =>
readPlusDraft(projectId) ?? saved.get(projectId) ?? [];
/** 초안을 씀(정렬 · 같은 m 하나) — 바뀜 신호로 B05 종단 · B06 목록이 다시 맞춤. */
export function writePlusDraft(projectId: string, next: number[] | null): void {
const list = next && [...new Set(next.map(Math.round))].sort((a, b) => a - b);
useDesignStore(projectId, null).set("plusStations", list);
}
/** 저장분이 바뀜 — 듣는 쪽을 부름(초안 칸 값 그대로 다시 씀). */
function notify(projectId: string): void {
useDesignStore(projectId, null).set("plusStations", readPlusDraft(projectId));
}
/** 저장분을 한 번 받음(못 받으면 빈 목록) — 읽는 곳 두기(`setPlusStations`)는 횡단 상세 가게 몫. */
export function loadSavedPlusStations(projectId: string): Promise<void> {
if (!loaded.has(projectId))
loaded.set(
projectId,
request(projectId)
.then((list) => {
saved.set(projectId, list);
if (list.length) notify(projectId);
})
.catch(() => undefined),
);
return loaded.get(projectId)!;
}
/** [저장] 차례 — 초안이 있으면 정본에 씀 · 없으면 저장분만 다시 받음(노선 저장이 좌표로 옮겼을 수 있음). */
export async function flushPlusStations(projectId: string): Promise<void> {
const draft = readPlusDraft(projectId);
const list = draft
? await request(projectId, { method: "PUT", body: JSON.stringify({ chainages: draft }) })
: await request(projectId);
saved.set(projectId, list);
loaded.set(projectId, Promise.resolve());
if (draft) useDesignStore(projectId, null).set("plusStations", null);
else notify(projectId);
}
+4 -1
View File
@@ -282,7 +282,10 @@ def resolve_extra_stations(
(anchor = 기준 한 곳 · span3 = 시작 · 기준 · 끝) · 카드 이름 · 같은 m(0.005) 는 하나(이름 = 관이
먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다.
거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`.
추가 측점(368-2) = `plus_stations.json` · 이름 "".
"""
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
try:
structures = load_structures(str(project_root))[1]
except Exception:
@@ -290,7 +293,7 @@ def resolve_extra_stations(
structures = []
return tuple(
(float(station["chainage_m"]), str(station["title"]))
for station in plan_stations(pipes, structures)
for station in plan_stations(pipes, structures, plus_chainages(project_root))
)
+93
View File
@@ -0,0 +1,93 @@
"""추가 측점 정본(`B05_Profile/route/plus_stations.json`) — 구조물 정본 곁(368-2 · 요구 240-21).
사용자가 종단 원지반선에서 넣은 일반 측점(정수 m) · 이름 없음. 자리 정본 = 좌표(x · y) — 노선이
바뀌면 관 · 구조물처럼 새 노선에 투영해 누가거리를 다시 매김(`follow_structures` 재사용).
측점 계획에는 `resolve_extra_stations` → `plan_stations(plus=)` 로 들어감(TS `planStations` 짝).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Sequence
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_follow import follow_structures
PLUS_STATIONS_FILE_NAME = "plus_stations.json"
def plus_stations_path(project_root: str | Path) -> Path:
return Path(project_root) / "B05_Profile" / "route" / PLUS_STATIONS_FILE_NAME
def load_plus_stations(project_root: str | Path) -> list[dict[str, Any]]:
"""[{chainage_m, x, y}] — 없거나 깨졌으면 빈 목록."""
path = plus_stations_path(project_root)
if not path.is_file():
return []
try:
rows = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
except (OSError, ValueError, AttributeError):
return []
return [
row
for row in rows
if isinstance(row, dict) and isinstance(row.get("chainage_m"), (int, float))
]
def plus_chainages(project_root: str | Path) -> list[float]:
return sorted({float(row["chainage_m"]) for row in load_plus_stations(project_root)})
def save_plus_stations(
project_root: str | Path, chainages: Sequence[float], xy: Sequence[Sequence[float]] | None
) -> list[float]:
"""정수 m 로 맞춰(0 ~ 노선 끝 · 같은 m 하나) 좌표와 함께 씀 → 쓴 누가거리."""
from shapely.geometry import LineString
line = LineString([(float(p[0]), float(p[1])) for p in xy]) if xy and len(xy) > 1 else None
end = float(line.length) if line is not None else float("inf")
values = sorted({float(round(c)) for c in chainages if 0 <= round(c) <= end})
rows = []
for value in values:
point = line.interpolate(value) if line is not None else None
rows.append(
{
"chainage_m": value,
"x": round(float(point.x), 4) if point is not None else None,
"y": round(float(point.y), 4) if point is not None else None,
}
)
path = plus_stations_path(project_root)
if not rows and not path.exists():
return []
os.makedirs(path.parent, exist_ok=True)
atomic_write_json(str(path), {"stations": rows})
return values
def follow_plus_stations(project_root: str | Path, new_xy: Sequence[Sequence[float]]) -> int:
"""노선이 바뀔 때 좌표를 새 노선에 투영해 누가거리를 다시 매김 → 옮긴 수(같은 m 는 하나)."""
rows = load_plus_stations(project_root)
items = [
SimpleNamespace(chainage_m=float(r["chainage_m"]), x=r.get("x"), y=r.get("y")) for r in rows
]
if not items:
return 0
before = [item.chainage_m for item in items]
follow_structures(items, new_xy)
moved = sum(a != item.chainage_m for a, item in zip(before, items))
if moved:
seen: set[float] = set()
kept = []
for item in sorted(items, key=lambda i: i.chainage_m):
if item.chainage_m in seen:
continue
seen.add(item.chainage_m)
kept.append({"chainage_m": item.chainage_m, "x": item.x, "y": item.y})
atomic_write_json(str(plus_stations_path(project_root)), {"stations": kept})
return moved
+8
View File
@@ -356,6 +356,7 @@ async def run_route_save_chain(
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request
from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR
from B05_Profile.B05_Profile_Plus_Stations import follow_plus_stations, plus_stations_path
from B05_Profile.B05_Profile_Router import (
_section_options,
confirm_latest_route,
@@ -399,6 +400,7 @@ async def run_route_save_chain(
project_root / str(old_route.get("route_data_path") or "B05_Profile/route/x"),
pipe_points_path(stored_path),
Path(structures_file_path(str(project_root))),
plus_stations_path(project_root),
],
[stage_dir / "longitudinal", stage_dir / "cross_sections"],
)
@@ -412,6 +414,12 @@ async def run_route_save_chain(
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
structures = {"moved": 0, "far": 0}
try: # 추가 측점도 땅 위 자리 따라감(368-2 · 실패 = 옛 누가거리 그대로)
await asyncio.to_thread(
follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points]
)
except Exception: # noqa: BLE001
logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
request = redesign_solve_request(params, selection, surface_model_id)
solved: Any = await solve_route_record(project_id, request, generate_sections=False)
if isinstance(solved, JSONResponse):
@@ -335,6 +335,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
onPick: (chainage, typeId) => deps.panel().structures.addAt(chainage, typeId),
});
},
/** 측점 계획을 다시 맞춤 — 추가 측점이 바뀜(368-2). */
resyncStations: () => syncCrossDrainStations(),
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
irregularStations: () => irregularStations,
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
@@ -16,6 +16,7 @@ import {
irregularStationId,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
import { PLUS_OWNER_PREFIX } from "../common_util/common_util_structure_station";
/** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서
* 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */
@@ -62,6 +63,8 @@ export function irregularGraphStations(
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
// 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음
...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}),
center_z: null,
azimuth_deg: null,
center_x: 0,
+2
View File
@@ -121,6 +121,8 @@ export interface SectionStation {
structure?: string;
/** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */
grid?: boolean;
/** 추가 측점(368-2 · 사용자가 원지반선에서 넣은 일반측점) — 일반측점 색 점선 · 끌기 없음. */
plus?: boolean;
center_z: number | null;
azimuth_deg: number | null;
/** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */
@@ -347,6 +347,56 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse:
)
class PlusStationsRequest(BaseModel):
"""추가 측점 목록(정수 m · 368-2) — 통째로 갈음."""
model_config = ConfigDict(extra="forbid")
chainages: list[float] = Field(default_factory=list, max_length=500)
@router.get("/{project_id}/section/plus-stations")
async def get_plus_stations(project_id: UUID) -> JSONResponse:
"""추가 측점 정본(누가거리 목록) — 없으면 빈 목록."""
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
paths = await _project_paths(project_id)
chainages = await asyncio.to_thread(plus_chainages, paths[0]) if paths else []
return JSONResponse(content={"status": "success", "chainages": chainages})
@router.put("/{project_id}/section/plus-stations")
async def put_plus_stations(project_id: UUID, request: PlusStationsRequest) -> JSONResponse:
"""추가 측점 정본을 씀([저장] 차례 · 계획고 앞) — 좌표(x · y) = 지금 노선 위 · 측점을 맞춤
(`ensure_structure_stations` — 새 자리 세움 · 지운 자리 걷음)."""
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
from B05_Profile.B05_Profile_Plus_Stations import save_plus_stations
chainages = [c for c in request.chainages if math.isfinite(c)]
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 노선이 없습니다."},
)
project_root, route = paths
def _save() -> list[float]:
xy = _load_route_polyline(project_root, str(route["route_data_path"]))
return save_plus_stations(project_root, chainages, xy)
saved = await asyncio.to_thread(_save)
await ensure_structure_stations(project_id)
except Exception:
logger.exception("B06 추가 측점 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "추가 측점을 저장하지 못했습니다."},
)
return JSONResponse(content={"status": "success", "chainages": saved})
#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게.
PREVIEW_MAX_STATIONS = 60
+3 -1
View File
@@ -21,6 +21,7 @@ import {
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { readPlusDraft } from "../B05_Profile/B05_Profile_Api_PlusStations";
import { registryDefaults } from "@util/common_util_culvert_sets";
import { attachShapes } from "@util/common_util_structure_attach";
import {
@@ -184,7 +185,8 @@ export async function withDraftStations(
let live = all;
let stale: CrossSection[] = [];
let fresh: CrossSection[] = [];
if (pipesDraft || structuresDraft) {
// 추가 측점 초안(368-2)도 측점 계획을 바꿈 — 계획은 그 초안을 스스로 읽음
if (pipesDraft || structuresDraft || readPlusDraft(projectId)) {
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
if (inputs) {
const [pipes, structures, types] = inputs;
+4 -1
View File
@@ -19,7 +19,8 @@
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { setGeneralStations } from "../common_util/common_util_structure_station";
import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station";
import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations";
import {
forgetDraftStations,
withDraftShapes,
@@ -65,6 +66,8 @@ async function withDraftWalls(
routeId: number,
): Promise<SectionDetailResponse> {
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
setPlusStations(() => plusStationsOf(projectId)); // 측점 계획이 읽을 추가 측점(368-2)
await loadSavedPlusStations(projectId);
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
await withDraftShapes(detail, projectId);
return withStructureAreas(detail);
+2 -2
View File
@@ -408,7 +408,7 @@ export function createLongitudinalProfile(
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
});
// 구조물 측점선만 끌 수 있다 — 규칙 측점은 격자라 옮길 대상이 아니다.
if (onDragStation && station.kind === "irregular") {
if (onDragStation && station.kind === "irregular" && !station.plus) {
attachStationDrag(marker, svg, station, x, xInverse, onDragStation);
}
marker.append(
@@ -424,7 +424,7 @@ export function createLongitudinalProfile(
y1: LONG_PAD.top,
x2: stationX,
y2: heightPx - padBottom,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.plus ? "plus" : station.kind}`,
}),
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
+23 -2
View File
@@ -5,7 +5,7 @@
234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167).
TS 짝 = `common_util/common_util_structure_station.ts`
(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김 ·
측점 계획 `plan_stations` ↔ `planStations` · 322-3).
측점 계획 `plan_stations` ↔ `planStations` · 322-3 · 추가 측점 `plus` 368-2).
거울 시험 = `resources/tester/test_234_1_structure_station.py`.
일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀.
"""
@@ -106,12 +106,20 @@ def _station_marks(
]
def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list[dict[str, object]]:
#: 추가 측점 주인 id 앞머리(368-2) — TS `PLUS_OWNER_PREFIX` 짝
PLUS_OWNER_PREFIX = "plus-"
def plan_stations(
pipes: Sequence[object], structures: Sequence[object], plus: Sequence[float] = ()
) -> list[dict[str, object]]:
"""관 · 구조물(현황) + 구조물 표 → 측점 계획 `[{chainage_m, role, title, owners, blank}]`.
오름차순 · 같은 m(0.005) 는 하나 — 이름 = 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름)
· 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상
부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌.
`plus` = 추가 측점(정수 m · 368-2) — 주인 `plus-{m}` · 이름 "" · 같은 m 에 관 · 구조물이
있으면 뺌.
"""
from common_util.common_util_structure_catalog import card_title, catalog_row
@@ -164,6 +172,19 @@ def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list
"pipe": False,
}
)
own = list(marks)
for at in plus:
if all(abs(float(at) - mark["chainage_m"]) >= SAME_STATION_M for mark in own):
marks.append(
{
"chainage_m": float(at),
"role": "anchor",
"title": "",
"owner": f"{PLUS_OWNER_PREFIX}{round(float(at))}",
"blank": [],
"pipe": False,
}
)
marks.sort(key=lambda mark: mark["chainage_m"])
out: list[dict[str, object]] = []
for mark in marks:
+31 -2
View File
@@ -21,6 +21,10 @@
* `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 =
* `resources/tester/test_272_1_station_list.py`. 종단 그래프 · 3D · 계획선 측점 줄(표 · ▲▼)이
* 이 목록 하나를 씀.
*
* 추가 측점(368-2 · 요구 240-21) — 사용자가 원지반선에서 넣은 정수 m(`plusStations`) · 측점 계획 끝에
* 붙임(주인 `plus-{m}` · 이름 "" · 같은 m 에 관 · 구조물이 있으면 그쪽이 이김) · 파이썬 `plan_stations(plus=)`
* 짝 · 읽는 곳은 화면이 `setPlusStations` 로 둠(초안 · 저장분 · 서버 묶음 = 없음).
* ========================================================================== */
import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog";
@@ -48,6 +52,18 @@ export function setGeneralStations(
export const generalStations = (): readonly number[] => general;
let plusSource: () => readonly number[] = () => [];
/** 추가 측점 읽는 곳을 둔다(368-2) — 부를 때마다 읽음(되돌리기 · 다른 보기가 바꿔도 같은 값). */
export function setPlusStations(source: (() => readonly number[]) | null): void {
plusSource = source ?? (() => []);
}
export const plusStations = (): readonly number[] => plusSource();
/** 추가 측점 주인 id 앞머리 — 측점 계획 · 종단 측점 id 가 `plus-{m}`. */
export const PLUS_OWNER_PREFIX = "plus-";
/** 기준 측점 = 정수 m(.5 는 올림) · 0 ~ 노선 끝(소수 끝은 그 아래 정수) 안. 두 번 맞춰도 같다. */
export function snapStructureStation(
chainageM: number,
@@ -187,13 +203,14 @@ export function planStations(
structures: ReadonlyArray<PlanStructure>,
types: ReadonlyArray<PlanType>,
registry?: Readonly<Record<string, Record<string, unknown>>>,
plus: ReadonlyArray<number> = plusSource(),
): PlannedStation[] {
const byId = new Map(types.map((type) => [type.type_id, type]));
const defaults = (typeId: string): Record<string, unknown> =>
registry?.[typeId] ??
Object.fromEntries((byId.get(typeId)?.options ?? []).map((o) => [o.key, o.default]));
type Mark = StationMark & { title: string; owner: string; blank: string[]; pipe: boolean };
const marks: Mark[] = [
const own: Mark[] = [
...pipes.flatMap((pipe) => {
const facility = pipe.facility ?? "pipe";
if (!catalogRow(facility)) return [];
@@ -210,7 +227,19 @@ export function planStations(
const blank = blankKeys(structure.type_id, structure.options);
return marks.map((m) => ({ ...m, title, owner, blank, pipe: false }));
}),
].sort((a, b) => a.chainage_m - b.chainage_m);
];
// 추가 측점 — 같은 m 에 관 · 구조물이 있으면 뺌(그쪽이 이김)
const added: Mark[] = plus
.filter((at) => own.every((m) => Math.abs(m.chainage_m - at) >= SAME_STATION_M))
.map((at) => ({
chainage_m: at,
role: "anchor",
title: "",
owner: `${PLUS_OWNER_PREFIX}${Math.round(at)}`,
blank: [],
pipe: false,
}));
const marks = [...own, ...added].sort((a, b) => a.chainage_m - b.chainage_m);
const out: Array<PlannedStation & { pipe: boolean }> = [];
for (const mark of marks) {
const last = out[out.length - 1];
@@ -28,4 +28,7 @@ const out = {
list: station.oneStationList(input.server, input.fresh),
nodes: station.followMovedNodes(input.nodes, input.gone, input.born),
};
// 368-2 추가 측점 — 화면이 둔 읽는 곳(setPlusStations)으로 심는 자리에 듦
station.setPlusStations(() => input.plus);
out.plantedPlus = station.plantedChainages(input.pipes, input.structures, input.types);
process.stdout.write(JSON.stringify(out));
@@ -45,6 +45,7 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
readPendingStructures: () => null,
},
"../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => draftPipes },
"../B05_Profile/B05_Profile_Api_PlusStations": { readPlusDraft: () => null },
"@util/common_util_culvert_sets": {
registryDefaults: () => ({ pipe: { pipe_kind: "파형강관", pipe_diameter_mm: 800 } }),
buildCulvertSets: (points) => new Map(points.map((p) => [p.chainage_m, { kind: "pipe" }])),
+3 -3
View File
@@ -1,6 +1,6 @@
/* PLAN 322-3 측점 계획 한 벌 거울 헬퍼 — 진짜 `common_util_structure_station.ts`(planStations) ·
* `common_util_structure_catalog.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON.
* 입력 = argv[2] JSON `{definition, types, cases: {이름: {pipes, structures}}}`. 짝 = `test_322_3_plan_mirror.py`.
* 입력 = argv[2] JSON `{definition, types, cases: {이름: {pipes, structures, plus?}}}`(plus = 추가 측점 368-2). 짝 = `test_322_3_plan_mirror.py`.
*/
const fs = require("fs");
const path = require("path");
@@ -38,6 +38,6 @@ const catalog = load(path.join(ROOT, "common_util", "common_util_structure_catal
const station = load(path.join(ROOT, "common_util", "common_util_structure_station.ts"));
catalog.useCatalogDefinition({ types: input.types, ...input.definition });
const out = {};
for (const [name, { pipes, structures }] of Object.entries(input.cases))
out[name] = station.planStations(pipes, structures, input.types);
for (const [name, { pipes, structures, plus }] of Object.entries(input.cases))
out[name] = station.planStations(pipes, structures, input.types, undefined, plus ?? []);
process.stdout.write(JSON.stringify(out));
@@ -29,6 +29,7 @@ const logic = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Sectio
const stage = loadTs(
path.join(ROOT, "A00_Common", "design", "logic", "design_pipeline_stations.ts"),
);
const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_station.ts"));
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = {
@@ -40,15 +41,17 @@ const out = {
arm: logic.PLAN_RADIUS_ARM_M,
straight: logic.PLAN_RADIUS_STRAIGHT_M,
},
cases: job.cases.map((c) =>
logic.buildStations(c.line, c.curves, job.pipes, job.structures, {
cases: job.cases.map((c) => {
station.setPlusStations(() => c.plus ?? []); // 368-2 추가 측점 — 화면이 둔 읽는 곳
return logic.buildStations(c.line, c.curves, job.pipes, job.structures, {
types: job.types,
stationSnap: c.snap,
}),
),
});
}),
text: [0, 12.345, 40, 699.996].map(logic.stationText),
};
station.setPlusStations(null);
// 노선 한 점 옮김 — 단계가 범위 밖 측점을 앞 객체 그대로 · 열쇠 그대로
const run = (line, curves, prev, range) =>
stage.createStationsStage({ types: () => job.types })({
@@ -63,6 +63,9 @@ const STUBS = {
"../../B05_Profile/B05_Profile_Api_Structures": {
flushPendingStructures: () => record("structures", failStructures)(),
},
"../../B05_Profile/B05_Profile_Api_PlusStations": {
flushPlusStations: () => record("plusStations")(),
},
"../../B05_Profile/B05_Profile_Api_Fetch": {
flushUphillOverrides: () => record("uphill")(),
saveProfileAlignment: async (_p, routeId, edits) =>
@@ -68,6 +68,8 @@ STRUCTURES = [
{"type_id": "evac_road", "placement": "point", "chainage_m": 720.0},
]
EXPECTED = [95, 160, 230, 300, 395, 400, 410, 500, 510, 530, 545, 560]
# 368-2 추가 측점 — 47 만 듦(95 관 · 300 세월교 자리는 그쪽이 이김)
PLUS = [47.0, 95.0, 300.0]
# 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) ·
# 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35
@@ -128,6 +130,7 @@ def ts(tmp_path_factory) -> dict:
"nodes": fixture["profile_alignment"]["base_pvi"],
"gone": [s["chainage_m"] for s in fixture["stations"] if s.get("kind") == "irregular"],
"born": [s["chainage_m"] for s in _snapped(fixture)],
"plus": PLUS,
}
source.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
@@ -154,6 +157,21 @@ def test_planted_mirror(tmp_path: Path, ts) -> None:
assert ts["planted"] == EXPECTED
def test_planted_mirror_with_plus(tmp_path: Path, ts) -> None:
route = tmp_path / "B05_Profile/route"
route.mkdir(parents=True)
(route / "structures.json").write_text(
json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False),
encoding="utf-8",
)
rows = [{"chainage_m": at, "x": None, "y": None} for at in PLUS]
(route / "plus_stations.json").write_text(json.dumps({"stations": rows}), encoding="utf-8")
extras = resolve_extra_stations(tmp_path, parse_pipe_points(PIPES))
assert sorted(mark for mark, _label in extras) == sorted(EXPECTED + [47])
assert dict(extras)[47.0] == ""
assert ts["plantedPlus"] == sorted(EXPECTED + [47])
def test_one_list_mirror(tmp_path: Path, ts) -> None:
rel = "B06_Section/longitudinal/longitudinal.json"
path = tmp_path / rel
+49 -3
View File
@@ -81,16 +81,42 @@ CASES = {
"structures": [],
},
"empty": {"pipes": [], "structures": []},
# 368-2 추가 측점 — 이름 "" · 주인 plus-{m} · 같은 m 에 관 · 구조물이 있으면 그쪽이 이김
"plus": {
"pipes": [{"chainage_m": 95.0, "source": "user"}],
"structures": [
_s(
"retaining_wall",
"s2",
start_m=500.0,
chainage_m=510.0,
end_m=530.0,
options={"height_m": 3},
)
],
"plus": [47.0, 12.0, 95.0, 510.0, 47.0],
},
}
@pytest.fixture(scope="module")
def ts() -> dict:
definition = load_definition()
# 관 id = 파이썬이 읽을 때 매기는 고정 id(347) — TS 에도 같은 id 로 넘김
cases = {
name: {
**case,
"pipes": [
{**pipe, "id": parsed.id}
for pipe, parsed in zip(case["pipes"], parse_pipe_points(case["pipes"]))
],
}
for name, case in CASES.items()
}
payload = {
"definition": definition,
"types": with_visible().model_dump()["types"],
"cases": CASES,
"cases": cases,
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "input.json"
@@ -109,7 +135,7 @@ def ts() -> dict:
def _py(case: dict) -> list[dict]:
pipes = parse_pipe_points(case["pipes"])
structures = [StructureInstance.model_validate(s) for s in case["structures"]]
return plan_stations(pipes, structures)
return plan_stations(pipes, structures, case.get("plus", ()))
def test_mirror_python_equals_ts(ts: dict) -> None:
@@ -133,7 +159,7 @@ def test_plan_rules(ts: dict) -> None:
560.0,
]
first = mixed[0] # 관 95 + 맹암거 95.003 = 한 측점 · 이름 = 관 · 빈칸 = 관경 + 맹암거 높이
assert first["owners"] == ["pipe-95.00", "s6"]
assert first["owners"] == ["p9500", "s6"]
assert first["title"] == "파형강관 D1000"
assert first["blank"] == ["pipe_diameter_mm", "culvert_height_m"]
joint = next(s for s in mixed if s["chainage_m"] == 530.0) # 옹벽 끝 = 소단 시작
@@ -145,6 +171,26 @@ def test_plan_rules(ts: dict) -> None:
assert ts["empty"] == []
def test_plus_stations_rules(ts: dict) -> None:
plus = ts["plus"]
assert [(s["chainage_m"], s["title"], s["owners"]) for s in plus][:3] == [
(12.0, "", ["plus-12"]),
(47.0, "", ["plus-47"]),
(95.0, "파형강관 D1000", ["p9500"]),
]
assert next(s for s in plus if s["chainage_m"] == 510.0)["owners"] == ["s2"]
def test_server_supply_reads_plus_file(tmp_path: Path) -> None:
"""서버 공급자 = 추가 측점 정본(plus_stations.json)까지 — 이름 ""."""
route = tmp_path / "B05_Profile" / "route"
route.mkdir(parents=True)
rows = [{"chainage_m": 47.0, "x": 1, "y": 2}, {"chainage_m": 12.0, "x": 0, "y": 0}]
(route / "plus_stations.json").write_text(json.dumps({"stations": rows}), encoding="utf-8")
extras = resolve_extra_stations(tmp_path, parse_pipe_points([{"chainage_m": 95.0}]))
assert list(extras) == [(12.0, ""), (47.0, ""), (95.0, "파형강관 D1000")]
def test_server_supply_is_plan(tmp_path: Path) -> None:
case = CASES["mixed"]
route = tmp_path / "B05_Profile" / "route"
@@ -112,11 +112,13 @@ def _types() -> list[dict]:
]
def _server(line, curves, snap):
def _server(line, curves, snap, plus=()):
extras = tuple(
(float(s["chainage_m"]), str(s["title"]))
for s in plan_stations(
parse_pipe_points(PIPES), [SimpleNamespace(options=None, **item) for item in STRUCTURES]
parse_pipe_points(PIPES),
[SimpleNamespace(options=None, **item) for item in STRUCTURES],
plus,
)
)
data = core.generate_sections(
@@ -141,6 +143,8 @@ CASES = [
{"name": "곡선표", "line": LINE, "curves": CURVES, "snap": False},
{"name": "스냅", "line": LINE, "curves": CURVES, "snap": True},
{"name": "곡선표없음", "line": LINE, "curves": [], "snap": False},
# 368-2 추가 측점 — 77 = 새 측점(주인 plus-77) · 57 = 관 자리(관이 이김)
{"name": "추가측점", "line": LINE, "curves": CURVES, "snap": False, "plus": [77.0, 57.0]},
]
@@ -194,7 +198,7 @@ _KIND = {"bp": {"end"}, "ep": {"end"}, "regular": {"grid"}, "irregular": {"pipe"
@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES])
def test_서버_측점_목록과_같음(r, index):
case = CASES[index]
server = _server(case["line"], case["curves"], case["snap"])
server = _server(case["line"], case["curves"], case["snap"], case.get("plus", ()))
ours = r["cases"][index]
assert [s["chainage_m"] for s in server] == pytest.approx(
[s["chainageM"] for s in ours], abs=_TOL
@@ -238,6 +242,14 @@ def test_열쇠(r):
assert r["text"] == ["0.00", "12.35", "40.00", "700.00"]
def test_추가_측점_열쇠(r):
ours = r["cases"][3]
keys = {s["key"]: s for s in ours}
assert keys["structure:plus-77"]["chainageM"] == 77.0
assert "structure:plus-57" not in keys and keys["pipe:p57"]["chainageM"] == 57.0
assert len(ours) == len(r["cases"][0]) + 1
def test_스냅_측점은_원래_격자_번호(r):
plain = {s["key"]: s["chainageM"] for s in r["cases"][0] if s["kind"] == "grid"}
snapped = {s["key"]: s["chainageM"] for s in r["cases"][1] if s["kind"] == "grid"}
@@ -0,0 +1,39 @@
"""368-2 추가 측점 정본(`plus_stations.json`) — 정수 m · 좌표 · 노선 바뀌면 좌표로 따라감 · 저장 차례."""
from __future__ import annotations
from pathlib import Path
from B05_Profile.B05_Profile_Plus_Stations import (
follow_plus_stations,
load_plus_stations,
plus_chainages,
save_plus_stations,
)
ROOT = Path(__file__).resolve().parents[2]
LINE = [(0.0, 0.0, 0.0), (100.0, 0.0, 0.0)]
def test_저장_정수_m_좌표_노선_안(tmp_path: Path) -> None:
saved = save_plus_stations(tmp_path, [12.4, 47.0, 47.2, -3.0, 140.0], LINE)
assert saved == [12.0, 47.0] # 반올림 · 같은 m 하나 · 노선 밖 버림
rows = load_plus_stations(tmp_path)
assert [(r["chainage_m"], r["x"], r["y"]) for r in rows] == [
(12.0, 12.0, 0.0),
(47.0, 47.0, 0.0),
]
assert save_plus_stations(tmp_path, [], LINE) == [] and plus_chainages(tmp_path) == []
def test_노선이_바뀌면_좌표로_따라감(tmp_path: Path) -> None:
save_plus_stations(tmp_path, [12.0, 47.0], LINE)
# 시점을 10 m 앞으로 늘린 새 노선 — 같은 땅 자리 = 누가거리 +10
moved = follow_plus_stations(tmp_path, [(-10.0, 0.0), (100.0, 0.0)])
assert moved == 2 and plus_chainages(tmp_path) == [22.0, 57.0]
def test_저장_차례_계획고_앞() -> None:
save = (ROOT / "A00_Common/design/design_save.ts").read_text(encoding="utf-8")
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
assert order.index('"structures"') < order.index('"plusStations"') < order.index('"alignment"')
@@ -52,6 +52,7 @@ def test_저장_차례(out):
"route",
"pipes",
"structures",
"plusStations", # 368-2 추가 측점 — 계획고 앞
"alignment",
"culvertOptions",
"uphill",
@@ -64,6 +65,7 @@ def test_저장_차례(out):
"pipes",
"pipes:screen",
"structures",
"plusStations",
'alignment:7:{"20.000":0.1}',
"culvert:aislo:draft:culvertopt:p1:7",
"uphill",
@@ -98,6 +100,7 @@ def test_노선이_바뀌면_노선_먼저_뒤_차례는_새_노선(out):
"route",
"pipes",
"structures",
"plusStations",
'alignment:9:{"30.000":0.2}',
"culvert:aislo:draft:culvertopt:p1:7",
"uphill",