Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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 되살림.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -325,6 +325,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,
|
||||
@@ -368,6 +369,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"],
|
||||
)
|
||||
@@ -381,6 +383,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,
|
||||
|
||||
@@ -554,7 +554,7 @@ const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
* 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가)
|
||||
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
|
||||
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
|
||||
* 누른 점이 꺾임이면 이웃 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1).
|
||||
* 누른 점 좌우 다음 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1) · 직선 안 점도 그 점이 새 꺾임(368-1).
|
||||
*/
|
||||
export function adjustStation(
|
||||
base: AlignmentBase,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Pivot.ts
|
||||
* 종단 ▲▼ 축 찾기(355-1 · 요구 235-15) — 누른 점이 꺾임(R 있는 점)이면 좌우로 같은
|
||||
* 기울기 마디를 건너 다음 꺾임(없으면 시점 · 종점)을 축으로 삼는다. 사이 측점은
|
||||
* 새 직선(축 → 누른 점 → 축) 위로 따라 돈다(`adjustStation`).
|
||||
* 종단 ▲▼ 축 찾기(355-1 · 368-1 · 요구 235-15 · 240-20) — 누른 점에서 좌우로 같은
|
||||
* 기울기 마디를 건너 다음 꺾임(R 있는 점 · 없으면 시점 · 종점)을 축으로 삼는다. 사이
|
||||
* 측점은 새 직선(축 → 누른 점 → 축) 위로 따라 돈다(`adjustStation`). 직선 안 점 ·
|
||||
* 꼭짓점 아닌 자리(추가 측점)도 같은 규칙 — 누른 점이 새 꺾임(기본 L 곡선)이 된다.
|
||||
*
|
||||
* 처음 계획선은 마디마다 꺾여 축 = 누른 점 ±1 → 사이 측점 없음(옛 동작 그대로).
|
||||
* `B05_Profile_UI_Profile_Alignment` 를 타입으로만 읽는다(순환 import 없음).
|
||||
@@ -23,21 +24,23 @@ export interface PivotAxes {
|
||||
inner: number[];
|
||||
}
|
||||
|
||||
/** 누른 점이 꺾임이면 두 축과 사이 변화점 — 직선 안쪽 · 시종점 · 변화점 아님이면 null. */
|
||||
/** 누른 점의 두 축과 사이 변화점 — 시종점 · 계획선 밖이면 null. */
|
||||
export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null {
|
||||
const { pvi, segments } = alignment;
|
||||
const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M);
|
||||
if (index <= 0 || index >= pvi.length - 1) return null;
|
||||
// 꼭짓점 아닌 자리 = 그 점을 품은 마디 하나가 좌우 마디
|
||||
const right = index >= 0 ? index : pvi.findIndex((node) => node.chainage_m > chainageM);
|
||||
if (right <= 0 || (index >= 0 && index >= pvi.length - 1)) return null;
|
||||
const [leftSeg, rightSeg] = index >= 0 ? [index - 1, index] : [right - 1, right - 1];
|
||||
const grade = (at: number): number => {
|
||||
const segment = segments[at];
|
||||
return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0;
|
||||
};
|
||||
const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON;
|
||||
if (same(index - 1, index)) return null;
|
||||
let from = index - 1;
|
||||
while (from > 0 && same(from - 1, index - 1)) from -= 1;
|
||||
let to = index + 1;
|
||||
while (to < pvi.length - 1 && same(to, index)) to += 1;
|
||||
let from = leftSeg;
|
||||
while (from > 0 && same(from - 1, leftSeg)) from -= 1;
|
||||
let to = rightSeg + 1;
|
||||
while (to < pvi.length - 1 && same(to, rightSeg)) to += 1;
|
||||
return {
|
||||
fromM: pvi[from].chainage_m,
|
||||
fromZ: pvi[from].elevation_m,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 사용자 지시).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" }])),
|
||||
|
||||
@@ -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 })({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* 355-1 종단 ▲▼ 이웃 꺾임 축 돌림 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` ·
|
||||
* `_Straighten.ts` 를 그 자리에서 트랜스파일해 세 경우를 Node 로 돌린다.
|
||||
* ① 처음 계획선 ▲ = 옛 값 ② 직선화 뒤 꺾임 ▲ = 사이 측점 한 직선 · 축 표고 그대로 · +0.1
|
||||
* ③ 직선 안쪽 ▲ = 옛 값. 옛 값 = 355-1 앞 adjustStation(아래 oldAdjust) 그대로.
|
||||
* ③ 직선 안 측점 ▲ = 그 점 새 꺾임 · 축 = 좌우 다음 꺾임(368-1) ④ 꼭짓점 아닌 자리 ▲ = 같은 규칙.
|
||||
* 옛 값 = 355-1 앞 adjustStation(아래 oldAdjust) 그대로.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -132,19 +133,41 @@ const out = {};
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 직선 안쪽(80 · 40~100 직선 위) — 꺾임 아님 → 옛 값.
|
||||
{
|
||||
/** 직선 안 점 ▲ — 축 40 · 100 그대로 · 사이 점 새 직선 위 · 누른 점 꺾임(368-1). */
|
||||
function tiltInside(pressM, innerS) {
|
||||
let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100);
|
||||
edits = straight.straightenBetween(base, edits, 100, 160);
|
||||
const next = align.adjustStation(base, edits, 80, 0.1);
|
||||
out.inside = {
|
||||
same:
|
||||
JSON.stringify(sorted(next.station_offsets)) ===
|
||||
JSON.stringify(sorted(oldAdjust(edits, 80, 0.1).station_offsets)),
|
||||
planRise:
|
||||
align.planElevationAt(align.buildAlignment(base, next), 80) -
|
||||
align.planElevationAt(align.buildAlignment(base, edits), 80),
|
||||
const before = align.buildAlignment(base, edits);
|
||||
const next = align.adjustStation(base, edits, pressM, 0.1);
|
||||
const after = align.buildAlignment(base, next);
|
||||
const p = pviZ(after, pressM);
|
||||
const z40 = pviZ(before, 40);
|
||||
const z100 = pviZ(before, 100);
|
||||
const line = (s) =>
|
||||
s < pressM
|
||||
? z40 + ((p - z40) * (s - 40)) / (pressM - 40)
|
||||
: z100 + ((p - z100) * (s - 100)) / (pressM - 100);
|
||||
return {
|
||||
planRise: align.planElevationAt(after, pressM) - align.planElevationAt(before, pressM),
|
||||
axisMove: [pviZ(after, 40) - z40, pviZ(after, 100) - z100],
|
||||
outsideMove: [20, 120, 140, 160].map((s) => pviZ(after, s) - pviZ(before, s)),
|
||||
innerResidual: innerS.map((s) => pviZ(after, s) - line(s)),
|
||||
innerMoved: innerS.map((s) => pviZ(after, s) - pviZ(before, s)),
|
||||
curveAtPress: after.curves.some((c) => Math.abs(c.chainage_m - pressM) < 1e-6),
|
||||
pressRadiusKey: Object.prototype.hasOwnProperty.call(
|
||||
next.curve_radii,
|
||||
align.chainageKey(pressM),
|
||||
),
|
||||
innerRadiusKeys: innerS.filter((s) =>
|
||||
Object.prototype.hasOwnProperty.call(next.curve_radii, align.chainageKey(s)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 직선 안 측점(80 · 40~100 직선 위) — 80 새 꺾임 · 축 40 · 100 · 60 새 직선 위.
|
||||
out.inside = tiltInside(80, [60]);
|
||||
|
||||
// ④ 꼭짓점 아닌 자리(70) — 같은 규칙 · 60 · 80 새 직선 위.
|
||||
out.between = tiltInside(70, [60, 80]);
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -55,9 +55,17 @@ def test_꺾임_누르면_사이_측점이_한_직선(out):
|
||||
assert kink["oldWouldBreak"] == [0, 0]
|
||||
|
||||
|
||||
def test_직선_안쪽은_옛_값(out):
|
||||
assert out["inside"]["same"]
|
||||
assert out["inside"]["planRise"] == pytest.approx(0.1, abs=1e-4)
|
||||
@pytest.mark.parametrize("case", ["inside", "between"])
|
||||
def test_직선_안_점은_새_꺾임_축은_좌우_다음_꺾임(out, case):
|
||||
# 368-1: 직선 안 측점(80) · 꼭짓점 아닌 자리(70) 모두 그 점에 R · 축 40 · 100
|
||||
got = out[case]
|
||||
assert got["planRise"] == pytest.approx(0.1, abs=1e-4)
|
||||
assert got["axisMove"] == [0, 0]
|
||||
assert got["outsideMove"] == [0, 0, 0, 0]
|
||||
assert all(abs(value) < 1e-6 for value in got["innerResidual"])
|
||||
assert all(value > 0 for value in got["innerMoved"])
|
||||
assert got["curveAtPress"] and not got["pressRadiusKey"]
|
||||
assert got["innerRadiusKeys"] == []
|
||||
|
||||
|
||||
def test_부르는_곳_셋이_같은_함수():
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user