feat(b06): 횡단도 바로 반영 — 새로 놓거나 옮긴 관 · 구조물의 횡단 카드를 [저장] 전에 보임 (273-2)

- 서버: 읽기 전용 미리 뜨기 POST /section/preview-stations — 로그인 · 회사 · 프로젝트 권한 그대로 · 한 번에 60곳 · 파일 · DB 안 씀 · 저장과 같은 길(sample_structure_sections · compute_default_designs crosses)
- 화면: 초안이 심는 자리 중 횡단 없는 곳만 묻고 한 번 뜬 자리는 기억(B06_Section_Section_Drafts) · 카드 이름 · 관 그림 얹음 · 늦은 옛 응답 무시 · 배수 재계산이 관경을 정하면 카드 다시 얹음 · B06 관 옮김도 카드 다시
- [저장]은 지금처럼 서버가 같은 계산으로 정본에 씀 — 미리 본 카드와 같은 값
- 알림 글: 횡단도는 바로 보이고 세부 배수유역만 저장 뒤 다시 셈
- 시험: test_273_2 새로 · 177-3 헬퍼에 revealPick 자리 채움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 20:48:32 +09:00
co-authored by Claude Opus 5.5
parent 887c89bbf8
commit dfd7427c46
14 changed files with 642 additions and 105 deletions
+2 -3
View File
@@ -343,15 +343,14 @@ export async function mountCrossView(
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
removeStructure: (structureId) => {
structuresPanel.removeStructureById(structureId);
},
removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId),
movePipe: (fromChainageM, toChainageM) =>
structuresPanel.movePipeTo(fromChainageM, toChainageM),
moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM);
},
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
pipesChanged: () => void refreshDetailForStructures(),
});
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
+45 -18
View File
@@ -551,7 +551,7 @@ def run_section_generation(
}
def generate_irregular_sections(
def sample_structure_sections(
project_root: Path,
route_data_path: str,
filter_key: str,
@@ -561,17 +561,15 @@ def generate_irregular_sections(
extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
격자 측점과 똑같은 접선·오프셋
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
(`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
(`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
"""
if not extra_stations:
return []
return [], []
base = options or SectionGenerationOptions()
merged_options = SectionGenerationOptions(
station_interval_m=base.station_interval_m,
@@ -592,19 +590,48 @@ def generate_irregular_sections(
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
)
# 구조물 측점 전부 — 격자 · 시종점과 같은 m 라 그 측점에 얹힌 것(`grid` · 272)도 함께
irregular_stations = [
station
for station in result["longitudinal"]["stations"]
if station.get("kind") == "irregular" or station.get("structure")
]
def structural(entry: dict[str, Any]) -> bool:
return entry.get("kind") == "irregular" or bool(entry.get("structure"))
return (
[station for station in result["longitudinal"]["stations"] if structural(station)],
[cross for cross in result["cross_sections"] if structural(cross)],
)
def generate_irregular_sections(
project_root: Path,
route_data_path: str,
filter_key: str,
method: str,
smooth: bool,
*,
extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
"""
irregular_stations, crosses = sample_structure_sections(
project_root,
route_data_path,
filter_key,
method,
smooth,
extra_stations=extra_stations,
options=options,
crs=crs,
)
if not irregular_stations:
return []
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
cross_dir.mkdir(parents=True, exist_ok=True)
for cross_section in result["cross_sections"]:
if cross_section.get("kind") != "irregular" and not cross_section.get("structure"):
continue
for cross_section in crosses:
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
atomic_write_json(cross_file, cross_section)
return irregular_stations
+16
View File
@@ -22,6 +22,7 @@ import type {
CrossDesign,
CrossDesignRequest,
CrossDesignResponse,
CrossSection,
CrossSectionPatch,
SectionConfirmResponse,
SectionContextResponse,
@@ -120,6 +121,21 @@ export async function getSections(
}
/** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */
/**
* 초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 미리 떠 받는다 — 서버는 아무것도 안 씀 · 캐시에만 얹음
* ([저장] 때 서버가 같은 계산으로 정본에 씀 · 273-2).
*/
export async function previewStructureSections(
projectId: string,
chainages: number[],
): Promise<CrossSection[]> {
const result = await requestJson<{ cross_sections?: CrossSection[] }>(
`/projects/${projectId}/section/preview-stations`,
{ method: "POST", body: JSON.stringify({ chainages }) },
);
return result.cross_sections ?? [];
}
export async function fetchSectionDetail(
projectId: string,
routeId: number,
+2
View File
@@ -318,6 +318,8 @@ export interface BoxSet {
export interface CrossSection extends SectionStation {
samples: SectionSample[];
/** 초안 자리를 서버가 미리 떠 준 횡단 — 캐시에만 있음 · [저장]하면 서버가 정본으로 씀(273-2). */
preview?: boolean;
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
design?: CrossDesign;
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
+14 -3
View File
@@ -582,9 +582,13 @@ def compute_default_designs(
chainages: list[float],
standard: dict[str, Any] | None = None,
carried: dict[float, dict[str, Any]] | None = None,
crosses: dict[float, dict[str, Any]] | None = None,
) -> list[tuple[float, dict[str, Any]]]:
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
`crosses`(누가거리 round 3 → 횡단 dict)가 오면 파일 대신 그 횡단으로 계산한다 — 아직 정본에
없는 B06 미리 뜨기 자리(273-2) · 상단측도 그 횡단 값(종단 정본에 측점이 없음).
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
`carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` 값)가 오면 그 측점은 기본값 대신 그 입력으로
계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3). 결과 칸은 늘 새 계획고로 다시 낸다.
@@ -602,11 +606,13 @@ def compute_default_designs(
ford_drops = ford_surface_drops(root)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross = (crosses or {}).get(round(chainage_m, 3))
cross_path = find_cross_file(cross_dir, chainage_m)
if not cross_path.is_file():
if cross is None and not cross_path.is_file():
continue
try:
cross = json.loads(cross_path.read_text(encoding="utf-8"))
if cross is None:
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples")
if not isinstance(samples, list):
continue
@@ -617,7 +623,12 @@ def compute_default_designs(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type=str(user.get("ground_type") or "ripping_rock"),
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
section_mode=default_modes.get(
round(chainage_m, 3),
f"{cross['uphill_side']}_cut"
if cross.get("uphill_side") in ("left", "right")
else "left_cut",
),
ditch_type=str(user.get("ditch_type") or "standard"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage_m, paved_ranges),
+107 -13
View File
@@ -27,6 +27,7 @@ from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M
@@ -165,6 +166,26 @@ async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None
return Path(resolve_stored_project_path(stored)), route
async def _generation_inputs(
project_id: UUID, route: dict[str, Any], snapshot: dict[str, Any]
) -> tuple[Any, int | None, dict[str, Any] | None]:
"""측점을 뜰 때 쓰는 저장분 — (횡단 옵션, 좌표계 EPSG, 종단 행) · 만들기 · 미리 뜨기 한 벌."""
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
get_longitudinal_section,
)
async def _load(connection):
options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(
connection, project_id, snapshot.get("surface_model_id")
)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
return options, crs_epsg, longitudinal
return await run_with_connection(_load)
async def _regenerate_structure_stations(
project_id: UUID, project_root: Path, route: dict[str, Any], snapshot: dict[str, Any]
) -> int:
@@ -183,20 +204,8 @@ async def _regenerate_structure_stations(
_section_options_from_stored,
drop_rows_off_stations,
)
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
get_longitudinal_section,
)
async def _load(connection):
options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(
connection, project_id, snapshot.get("surface_model_id")
)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
return options, crs_epsg, longitudinal
stored_options, crs_epsg, longitudinal = await run_with_connection(_load)
stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot)
def _regenerate() -> tuple[int, dict[str, Any] | None]:
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
@@ -334,3 +343,88 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse:
"missing": missing,
}
)
#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게.
PREVIEW_MAX_STATIONS = 60
class PreviewStationsRequest(BaseModel):
"""미리 뜰 자리(누가거리 m) — 노선 밖 · 숫자 아닌 값은 서버가 버림."""
model_config = ConfigDict(extra="forbid")
chainages: list[float] = Field(default_factory=list, max_length=PREVIEW_MAX_STATIONS)
@router.post("/{project_id}/section/preview-stations")
async def preview_structure_sections(
project_id: UUID, request: PreviewStationsRequest
) -> JSONResponse:
"""초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 **미리 떠서 돌려만 준다**(273-2 · 요구 168).
파일 · DB 에 아무것도 안 쓴다 — 화면이 캐시에 얹어 B06 카드를 [저장] 전에 보이고, [저장] 때
서버가 같은 길(`sample_structure_sections` · `compute_default_designs`)로 다시 만들어 정본에 씀.
샘플링 조건은 측점 만들기와 같다(없으면 지어내지 않고 409).
"""
from B05_Profile.B05_Profile_Engine_Sections import sample_structure_sections
from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored
from B06_Section.B06_Section_Router_Design import compute_default_designs
chainages = sorted({round(c, 3) for c in request.chainages if math.isfinite(c) and c >= 0})
if not chainages:
return JSONResponse(content={"status": "success", "cross_sections": []})
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 노선이 없습니다."},
)
project_root, route = paths
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
return JSONResponse(
status_code=409, content={"status": "error", "message": SNAPSHOT_MISSING}
)
stored_options, crs_epsg, longitudinal = await _generation_inputs(
project_id, route, snapshot
)
if not longitudinal:
return JSONResponse(content={"status": "success", "cross_sections": []})
def _preview() -> list[dict[str, Any]]:
_stations, crosses = sample_structure_sections(
project_root,
str(route["route_data_path"]),
str(snapshot["filter_key"]),
str(snapshot["method"]),
bool(snapshot.get("smooth")),
extra_stations=tuple((chainage, "") for chainage in chainages),
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
wanted = set(chainages)
picked = {
round(float(cross["chainage_m"]), 3): cross
for cross in crosses
if round(float(cross["chainage_m"]), 3) in wanted
}
designs = compute_default_designs(
project_root,
str(longitudinal["longitudinal_file_path"]),
list(picked),
crosses=picked,
)
for chainage, design in designs:
picked[round(chainage, 3)]["design"] = design
return list(picked.values())
sections = await asyncio.to_thread(_preview)
except Exception:
logger.exception("B06 미리 뜨기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "횡단을 미리 뜨지 못했습니다."},
)
return JSONResponse(content={"status": "success", "cross_sections": sections})
+186
View File
@@ -0,0 +1,186 @@
/* =============================================================================
* B06_Section_Section_Drafts.ts
* 초안 측점 한 벌(272 · 273-2) — 관 · 구조물을 놓거나 옮긴 초안을 공유 상세(횡단 카드 · 유토곡선)에 맞춘다.
*
* · 옛 자리 구조물 측점(서버가 [저장] 때 다시 맞추기 전) = 숨김 — 쥐고 있다가 초안이 다시 그 자리를
* 심거나 초안이 없어지면 돌려 놓음.
* · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB
* 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음.
*
* 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙
* (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`.
* ========================================================================== */
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
fetchStructures,
fetchStructureTypes,
readPendingStructures,
type StructureInstance,
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import {
attachCulvertSets,
buildCulvertSets,
registryDefaults,
} from "@util/common_util_culvert_sets";
import {
pipeStationMarks,
plantedChainages,
SAME_STATION_M,
structureStationMarks,
} from "../common_util/common_util_structure_station";
import {
previewStructureSections,
type CrossSection,
type SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
type Pipes = PendingPipes;
const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M;
const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) =>
a.chainage_m - b.chainage_m;
/** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀 · 저장 뒤(`forgetDraftStations`) 다시 받음. */
const saved = new Map<string, Promise<[Pipes, StructureInstance[]]>>();
/** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */
const previews = new Map<string, CrossSection | null>();
/** 초안이 빼 둔 옛 자리 측점. */
const parked = new WeakMap<SectionDetailResponse, CrossSection[]>();
/** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */
const turns = new WeakMap<SectionDetailResponse, number>();
/** 저장 · 노선 바뀜 뒤 — 쥔 저장분 · 미리 뜬 횡단을 버림. */
export function forgetDraftStations(projectId: string): void {
saved.delete(projectId);
for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key);
}
function savedOf(projectId: string): Promise<[Pipes, StructureInstance[]]> {
let hit = saved.get(projectId);
if (!hit) {
hit = Promise.all([
fetchDetailPipePoints(projectId).then((res) => res.pipe_points as Pipes),
fetchStructures(projectId).then((res) => res.structures),
]);
hit.catch(() => saved.delete(projectId));
saved.set(projectId, hit);
}
return hit;
}
async function previewOf(
projectId: string,
routeId: number,
chainages: number[],
): Promise<CrossSection[]> {
const key = (at: number) => `${projectId}:${routeId}:${at.toFixed(3)}`;
const ask = chainages.filter((at) => !previews.has(key(at)));
if (ask.length) {
const got = await previewStructureSections(projectId, ask);
ask.forEach((at) => previews.set(key(at), got.find((s) => near(s.chainage_m, at)) ?? null));
}
return chainages.flatMap((at) => previews.get(key(at)) ?? []);
}
/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */
function decorate(
fresh: CrossSection[],
pipes: Pipes,
structures: StructureInstance[],
types: StructureType[],
): void {
const registry = registryDefaults(types);
const byId = new Map(types.map((type) => [type.type_id, type]));
const pipeLabel = (pipe: Pipes[number]): string | null => {
const facility = pipe.facility ?? "pipe";
if (facility === "ford_pavement") return null;
if (facility !== "pipe") return byId.get(facility)?.name ?? facility;
const values = { ...registry.pipe, ...pipe.options };
return `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
};
const labels: Array<[number, string]> = [
...pipes.flatMap((pipe) => {
const label = pipeLabel(pipe);
return label
? pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label])
: [];
}),
...structures.flatMap((structure) => {
const type = byId.get(structure.type_id);
const label = type?.name ?? structure.type_id;
return structureStationMarks(structure, type).map((m): [number, string] => [
m.chainage_m,
label,
]);
}),
];
for (const section of fresh) {
section.preview = true;
const hit = labels.find(([at]) => near(at, section.chainage_m));
if (hit) section.structure = hit[1];
}
const own = pipes.filter((pipe) =>
fresh.some((section) => near(section.chainage_m, pipe.chainage_m)),
);
attachCulvertSets(
fresh as unknown as Array<Record<string, unknown>>,
buildCulvertSets(own, registry),
);
}
/**
* 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 새 자리 미리 뜬 횡단 얹음. 초안이 없으면
* 서버 목록 그대로(빼 둔 것 돌려 놓음). 미리 뜨기를 못 하면(샘플링 조건 없음 · 끊김) 새 자리는
* 종전처럼 [저장] 뒤에 섬.
*/
export async function withDraftStations(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
): Promise<void> {
const turn = (turns.get(detail) ?? 0) + 1;
turns.set(detail, turn);
const pipesDraft = readPendingPipes(projectId);
const structuresDraft = readPendingStructures(projectId);
const all = [
...detail.cross_sections.filter((section) => !section.preview),
...(parked.get(detail) ?? []),
].sort(byChainage);
let live = all;
let stale: CrossSection[] = [];
let fresh: CrossSection[] = [];
if (pipesDraft || structuresDraft) {
const inputs = await Promise.all([
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
fetchStructureTypes(true),
]).catch(() => null);
if (inputs) {
const [pipes, structures, types] = inputs;
const planted = plantedChainages(pipes, structures, types);
const isStale = (section: CrossSection): boolean =>
section.kind === "irregular" &&
!section.grid &&
planted.every((at) => !near(at, section.chainage_m));
stale = all.filter(isStale);
live = all.filter((section) => !isStale(section));
const missing = planted.filter(
(at, index) =>
planted.findIndex((other) => near(other, at)) === index &&
live.every((section) => !near(section.chainage_m, at)),
);
fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : [];
if (fresh.length) decorate(fresh, pipes, structures, types);
}
}
if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음
detail.cross_sections.splice(
0,
detail.cross_sections.length,
...[...live, ...fresh].sort(byChainage),
);
parked.set(detail, stale);
}
+8 -64
View File
@@ -18,9 +18,7 @@
* ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
fetchStructures,
fetchStructureTypes,
readPendingStructures,
} from "../B05_Profile/B05_Profile_Api_Structures";
@@ -41,11 +39,8 @@ import {
pavementSpecsFrom,
type PavementStructureInput,
} from "@util/common_util_structure_pavement";
import {
plantedChainages,
SAME_STATION_M,
setGeneralStations,
} from "../common_util/common_util_structure_station";
import { setGeneralStations } from "../common_util/common_util_structure_station";
import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
@@ -69,9 +64,10 @@ function keyOf(projectId: string, routeId: number): string {
async function withDraftWalls(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
): Promise<SectionDetailResponse> {
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
await withDraftStations(detail, projectId);
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
const drafts = readPendingStructures(projectId);
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
@@ -108,58 +104,6 @@ async function withDraftWalls(
return withStructureAreas(detail);
}
/** 저장된 관 지점 — 구조물 초안만 있을 때 심는 자리 셈에 씀. 저장 뒤(`invalidateSectionDetail`)
* 다시 받음 · 고칠 때마다 서버에 안 물음. */
const savedPipes = new Map<
string,
Promise<Awaited<ReturnType<typeof fetchDetailPipePoints>>["pipe_points"]>
>();
function savedPipesOf(projectId: string) {
let hit = savedPipes.get(projectId);
if (!hit) {
hit = fetchDetailPipePoints(projectId).then((res) => res.pipe_points);
hit.catch(() => savedPipes.delete(projectId));
savedPipes.set(projectId, hit);
}
return hit;
}
/** 초안이 빼 둔 옛 자리 측점 — 초안이 다시 그 자리를 심거나 초안이 없어지면 돌려 놓음. */
const parkedSections = new WeakMap<
SectionDetailResponse,
SectionDetailResponse["cross_sections"]
>();
/**
* 옮긴 관 · 구조물의 옛 자리 측점(서버가 다시 맞추기 전)을 횡단 카드 · 유토곡선에서 뺀다 — 초안이
* 심는 자리(`plantedChainages` · 서버와 같은 규칙)에 없는 서버 구조물 측점(272 · 측점 한 벌). 초안이
* 없으면 서버 목록이 지금 것이라 다 돌려 놓음. [저장] 뒤는 서버가 맞춘 새 상세라 뺄 것이 없음.
*/
async function withDraftStations(detail: SectionDetailResponse, projectId: string): Promise<void> {
const pipesDraft = readPendingPipes(projectId);
const structuresDraft = readPendingStructures(projectId);
let planted: number[] | null = null;
if (pipesDraft || structuresDraft) {
const [pipes, structures, types] = await Promise.all([
pipesDraft ?? savedPipesOf(projectId),
structuresDraft ?? fetchStructures(projectId).then((res) => res.structures),
fetchStructureTypes(true),
]).catch(() => [null, null, null] as const);
if (pipes && structures && types) planted = plantedChainages(pipes, structures, types);
}
const all = [...detail.cross_sections, ...(parkedSections.get(detail) ?? [])].sort(
(a, b) => a.chainage_m - b.chainage_m,
);
const stale = (section: (typeof all)[number]): boolean =>
planted !== null &&
section.kind === "irregular" &&
!section.grid &&
planted.every((mark) => Math.abs(mark - section.chainage_m) >= SAME_STATION_M);
detail.cross_sections.splice(0, detail.cross_sections.length, ...all.filter((s) => !stale(s)));
parkedSections.set(detail, all.filter(stale));
}
/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
@@ -244,21 +188,21 @@ export async function loadSectionDetail(
const key = keyOf(projectId, routeId);
if (!options?.force) {
const cached = cache.get(key);
if (cached) return withDraftWalls(cached, projectId);
if (cached) return withDraftWalls(cached, projectId, routeId);
const inFlight = pending.get(key);
if (inFlight) return inFlight;
// 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다.
const stored = readState<SectionDetailResponse>("section-detail", projectId, routeId);
if (stored) {
cache.set(key, stored);
return withDraftWalls(stored, projectId);
return withDraftWalls(stored, projectId, routeId);
}
}
const request = fetchSectionDetail(projectId, routeId)
.then(async (detail) => {
cache.set(key, detail);
writeState("section-detail", detail, projectId, routeId);
return withDraftWalls(detail, projectId);
return withDraftWalls(detail, projectId, routeId);
})
.finally(() => {
pending.delete(key);
@@ -283,7 +227,7 @@ export function replaceSectionDetail(
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
*/
export function invalidateSectionDetail(projectId: string, routeId?: number): void {
savedPipes.delete(projectId);
forgetDraftStations(projectId);
if (routeId !== undefined) {
cache.delete(keyOf(projectId, routeId));
clearState("section-detail", projectId, routeId);
@@ -62,13 +62,13 @@ const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */
const PIPE_ADD_NOTICE =
"계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
"계곡 통과 시설을 넣었습니다 — 횡단도는 바로 보이고, 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다.";
const PIPE_REMOVE_NOTICE =
"계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
"계곡 통과 시설을 뺐습니다 — 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다.";
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
const PIPE_MOVE_NOTICE =
"기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
"기준 측점을 옮겼습니다 — 횡단도는 바로 보이고, 세부 배수유역은 [저장]·[확정] 뒤에 다시 계산됩니다.";
export interface B06StructuresPanelDeps {
projectId: string | null;
@@ -493,6 +493,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2)
showToast(PIPE_MOVE_NOTICE, "success");
}
@@ -33,6 +33,8 @@ export interface SectionStructureEdit {
moveStructure: (structureId: string, toChainageM: number) => void;
/** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */
selectStructure: (structureId: string) => void;
/** 배수 재계산이 관 종류 · 관경을 바꿈 — 카드(미리 뜬 새 관 그림 포함)를 다시 얹음(273-2). */
pipesChanged?: () => void;
}
/**
@@ -146,6 +146,8 @@ export function createSectionProfile(
let types: ReadonlyArray<StructureType> = [];
let stations: IrregularStation[] = [];
let intervalM = 20;
/** 마지막으로 받은 관 목록 지문(자리 · 종류 · 관경) — 바뀔 때만 카드를 다시 얹음. */
let pipeSignature: string | null = null;
/** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */
const nearestCard = (chainageM: number): CrossSection | null => {
@@ -215,9 +217,16 @@ export function createSectionProfile(
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 배수 재계산이 지운 지도 강조를 값대로 다시 얹음(종단 보기 restorePick 과 같은 몫 · 270)
onPipesChanged: () => {
onPipesChanged: (pipes) => {
if (shared.view() === "cross" && shared.profile?.side)
paintMapPick(shared.profile.side, readSelection(shared.projectId));
// 관 종류 · 관경이 바뀜(새 관 관경은 배수 재계산이 정함) — 카드 관 그림도 다시(273-2)
const signature = JSON.stringify(
pipes.map((p) => [p.chainage_m, p.facility, p.options?.pipe_diameter_mm ?? null]),
);
const changed = pipeSignature !== null && signature !== pipeSignature;
pipeSignature = signature;
if (changed && shared.view() === "cross") host.edit()?.pipesChanged?.();
},
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
// 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15).
@@ -22,6 +22,7 @@ let selectedStructureId = null;
const structureIdAtStation = (stationId) => (stationId === "irr-80" ? "pipe-80.17" : null);
const stationIdAtStructure = (structureId) => (structureId === "pipe-80.17" ? "irr-80" : null);
const draw = () => {};
const revealPick = () => {}; // 화면 밖이면 가운데로(273-1) — 이 시험 몫 아님
module.exports = {
line: () => selectedStationId,
${method("setSelectedStation")}
@@ -0,0 +1,113 @@
/* 273-2 초안 측점 — 진짜 `B06_Section_Section_Drafts.ts` + 진짜 측점 규칙(`common_util_structure_station`)을
* 가짜 서버 · 초안으로 돌림. 표준출력 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: {} };
new Function("exports", "module", "require", js)(box.exports, box, (name) => {
if (!stubs[name]) throw new Error(`stub 없음: ${name} (${file})`);
return stubs[name];
});
return box.exports;
}
const station = loadTs("common_util/common_util_structure_station.ts");
let draftPipes = null;
const asked = [];
let slow = false;
const types = [
{
type_id: "pipe",
group: "A",
placement: "point",
managed_by: "pipe_points",
name: "배관",
options: [],
},
];
const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
fetchDetailPipePoints: async () => ({ pipe_points: [{ chainage_m: 95.38, facility: "pipe" }] }),
},
"../B05_Profile/B05_Profile_Api_Structures": {
fetchStructures: async () => ({ structures: [] }),
fetchStructureTypes: async () => types,
readPendingStructures: () => null,
},
"../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => draftPipes },
"@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" }])),
attachCulvertSets: (sections, sets) => {
for (const [at] of sets) {
const hit = sections.find((s) => Math.abs(s.chainage_m - at) < 0.005);
if (hit) hit.culvert = true;
}
},
},
"../common_util/common_util_structure_station": station,
"./B06_Section_Api_Fetch": {
previewStructureSections: async (_projectId, chainages) => {
asked.push(chainages);
if (slow) await new Promise((resolve) => setTimeout(resolve, 50));
return chainages.map((c) => ({
chainage_m: c,
kind: "irregular",
station_id: `s${c}`,
samples: [],
}));
},
},
});
/** 서버 상세 — 격자 0 · 20 · 40 · 100 + 옛 관 측점 95.38. */
const detailOf = () => ({
cross_sections: [0, 20, 40, 95.38, 100].map((c) => ({
chainage_m: c,
kind: c === 95.38 ? "irregular" : c === 0 ? "bp" : "regular",
station_id: `s${c}`,
samples: [],
})),
});
const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m);
(async () => {
const out = {};
const detail = detailOf();
draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95
await drafts.withDraftStations(detail, "P", 1);
const preview = detail.cross_sections.find((s) => s.chainage_m === 95);
out.moved = {
chainages: chainages(detail),
asked: asked.splice(0),
preview: { preview: preview.preview, structure: preview.structure, culvert: preview.culvert },
};
await drafts.withDraftStations(detail, "P", 1);
out.again = { chainages: chainages(detail), asked: asked.splice(0) };
draftPipes = null;
await drafts.withDraftStations(detail, "P", 1);
out.cleared = { chainages: chainages(detail) };
const race = detailOf();
slow = true;
draftPipes = [{ chainage_m: 96, facility: "pipe", source: "user" }];
const first = drafts.withDraftStations(race, "P", 1); // 늦게 끝남
await new Promise((resolve) => setTimeout(resolve, 5));
slow = false;
draftPipes = [{ chainage_m: 120, facility: "pipe", source: "user" }];
await drafts.withDraftStations(race, "P", 1);
await first;
out.race = { chainages: chainages(race) };
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
@@ -0,0 +1,132 @@
"""273-2 B06 횡단도 바로 반영 — 초안 자리 횡단 미리 뜨기(요구 168).
- 미리 뜨기(`sample_structure_sections`)는 파일을 안 쓰고, [저장] 때 정본 쓰기
(`generate_irregular_sections`)와 같은 횡단을 낸다(같은 길 · 화면이 본 값 = 저장 값).
- 기본 설계(`compute_default_designs`)는 파일 대신 받은 횡단으로도 셈.
- 화면 쪽: 옛 자리 숨김 · 새 자리 얹음 · 이름 · 관 그림 · 늦게 끝난 옛 맞춤이 새 것을 안 덮음
(`helper_273_2_preview_stations.cjs`).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import numpy as np
import pytest
import B05_Profile.B05_Profile_Engine_Sections as sections
from B06_Section.B06_Section_Router_Design import compute_default_designs
from common_util.common_util_drainage_pipes import route_signature
from common_util.common_util_route_geometry import RouteVertex
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]]
ROUTE = "B05_Profile/route/route.geojson"
class _Slope:
"""옆으로 기운 지표 — 왼쪽이 높음(상단측 = left)."""
def sample_xy(self, xy):
points = np.asarray(xy, dtype=np.float64).reshape(-1, 2)
return 100.0 + 0.2 * points[:, 1], np.ones(len(points), dtype=bool)
def _write(root: Path, rel: str, data) -> None:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def _project(root: Path) -> None:
_write(root, ROUTE, {"geometry": {"type": "LineString", "coordinates": LINE}})
vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in LINE]
_write(
root,
"B04_PreProcess/drainage/edits/pipe_points.json",
{"route_signature": route_signature(vertices), "points": []},
)
def _files(root: Path) -> list[str]:
return sorted(str(p.relative_to(root)) for p in root.rglob("*") if p.is_file())
def test_미리_뜨기는_안_쓰고_저장과_같은_횡단(tmp_path: Path, monkeypatch) -> None:
_project(tmp_path)
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_a, **_k: _Slope())
before = _files(tmp_path)
stations, crosses = sections.sample_structure_sections(
tmp_path, ROUTE, "csf", "tin", False, extra_stations=((130.0, ""),)
)
assert _files(tmp_path) == before # 파일 · 폴더 하나도 안 생김
assert [c["chainage_m"] for c in crosses] == [130.0] and len(stations) == 1
sections.generate_irregular_sections(
tmp_path, ROUTE, "csf", "tin", False, extra_stations=((130.0, "파형강관 D800"),)
)
written = json.loads(
(tmp_path / "B06_Section/cross_sections/cross_00130m.json").read_text(encoding="utf-8")
)
assert written["samples"] == crosses[0]["samples"]
assert written["frame"] == crosses[0]["frame"]
def test_기본_설계는_받은_횡단으로도_셈(tmp_path: Path) -> None:
rel = "B06_Section/longitudinal/longitudinal.json"
plan = [{"chainage_m": c, "elevation_m": 851.0} for c in (0.0, 1000.0)]
_write(tmp_path, rel, {"stations": [], "design_profiles": [{"samples": plan}]})
offsets = np.arange(-20.0, 20.5, 0.5)
cross = {
"chainage_m": 610.0,
"uphill_side": "right",
"samples": [
{"offset_m": float(o), "elevation_m": 852.0 - 0.1 * float(o), "valid": True}
for o in offsets
],
}
designs = compute_default_designs(tmp_path, rel, [610.0], crosses={610.0: cross})
assert [chainage for chainage, _ in designs] == [610.0]
design = designs[0][1]
assert design["status"] == "provisional" and design.get("section_mode")
# 파일에도 없고 받은 횡단에도 없는 자리는 건너뜀
assert compute_default_designs(tmp_path, rel, [611.0], crosses={610.0: cross}) == []
@pytest.fixture(scope="module")
def ts() -> dict:
if not (ROOT / "config" / "node_modules" / "typescript").is_dir():
pytest.skip("프론트엔드 의존성 미설치")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_273_2_preview_stations.cjs")],
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["moved"]["chainages"] == [0, 20, 40, 95, 100] # 옛 95.38 숨김 · 새 95 미리 뜬 것
assert ts["moved"]["asked"] == [[95]]
card = ts["moved"]["preview"]
assert card["preview"] is True and card["structure"] == "파형강관 D800"
assert card["culvert"] is True # 관 그림(세트)도 얹힘
def test_한_번_뜬_자리는_다시_안_물음(ts) -> None:
assert ts["again"]["asked"] == []
assert ts["again"]["chainages"] == [0, 20, 40, 95, 100]
def test_초안이_없으면_서버_목록_그대로(ts) -> None:
assert ts["cleared"]["chainages"] == [0, 20, 40, 95.38, 100]
def test_늦게_끝난_옛_맞춤이_새_것을_안_덮음(ts) -> None:
assert ts["race"]["chainages"] == [0, 20, 40, 100, 120]