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:
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -318,6 +318,8 @@ export interface BoxSet {
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
samples: SectionSample[];
|
||||
/** 초안 자리를 서버가 미리 떠 준 횡단 — 캐시에만 있음 · [저장]하면 서버가 정본으로 씀(273-2). */
|
||||
preview?: boolean;
|
||||
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
|
||||
design?: CrossDesign;
|
||||
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user