Merge remote-tracking branch 'origin/feat/las-free-sheet-surface' into feat/B07-cad-block-library

This commit is contained in:
2026-08-30 19:58:51 +09:00
47 changed files with 2959 additions and 360 deletions
+69 -43
View File
@@ -69,12 +69,15 @@ export async function uploadProjectFiles(
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
});
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/files`,
{
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
},
);
return await readJsonOrThrow<FileUploadResponse>(response);
} finally {
window.clearTimeout(timeoutId);
@@ -87,19 +90,24 @@ export async function createUploadSession(
chunkSizeBytes: number,
fingerprint?: string | null,
completeUpload = false,
lasFree = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_filename: file.name,
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
}),
});
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_filename: file.name,
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
las_free: lasFree,
}),
},
);
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
}
@@ -128,18 +136,23 @@ export async function finalizeUploadSession(
totalChunks: number,
completeUpload: boolean,
fingerprint?: string | null,
lasFree = false,
): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
}),
});
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/finalize`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
las_free: lasFree,
}),
},
);
return await readJsonOrThrow<FileUploadResponse>(response);
}
@@ -147,10 +160,13 @@ export async function fetchUploadStatus(
projectId: string,
sessionId: string,
): Promise<UploadStatusResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
method: "GET",
credentials: "include",
});
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<UploadStatusResponse>(response);
}
@@ -182,11 +198,16 @@ export interface UploadOverviewResponse {
}
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
method: "GET",
credentials: "include",
});
export async function fetchUploadOverview(
projectId: string,
): Promise<UploadOverviewResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<UploadOverviewResponse>(response);
}
@@ -200,10 +221,15 @@ export interface WF1AnalysisStatus {
error?: string;
}
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
method: "GET",
credentials: "include",
});
export async function checkWF1AnalysisStatus(
projectId: string,
): Promise<WF1AnalysisStatus> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/surface/status`,
{
method: "GET",
credentials: "include",
},
);
return await readJsonOrThrow<WF1AnalysisStatus>(response);
}
+8 -3
View File
@@ -64,8 +64,8 @@ async def create_input_file(
async def get_project_input_readiness(
connection: aiomysql.Connection,
project_id: UUID,
) -> tuple[set[str], int | None]:
"""현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다."""
) -> tuple[set[str], int | None, int | None]:
"""업로드 파일 유형, 최신 포인트클라우드 입력 ID, 최신 계획노선 CSV 입력 ID를 반환한다."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
@@ -83,7 +83,12 @@ async def get_project_input_readiness(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
None,
)
return file_types, point_cloud_id
# LAS 없는 설계(2026-08-30)의 WF1 입력 — 계획노선 CSV가 분석 원천이 된다.
route_csv_id = next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") == "csv"),
None,
)
return file_types, point_cloud_id, route_csv_id
async def get_project_storage_relative_path(
+54 -13
View File
@@ -93,15 +93,16 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
def _missing_required_file_types(file_types: set[str]) -> list[str]:
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
if not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다.
if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
missing.append("las/laz")
return missing
def _require_complete_file_set(file_types: set[str]) -> None:
missing = _missing_required_file_types(file_types)
def _require_complete_file_set(file_types: set[str], las_free: bool = False) -> None:
missing = _missing_required_file_types(file_types, las_free)
if missing:
raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}")
@@ -153,11 +154,17 @@ async def _already_uploaded(
async def _complete_file_input_if_ready(
connection: aiomysql.Connection,
project_id: UUID,
las_free: bool = False,
) -> int:
file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id)
_require_complete_file_set(file_types)
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
connection, project_id
)
_require_complete_file_set(file_types, las_free)
if point_cloud_input_id is None:
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
if not las_free:
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
if route_csv_input_id is None:
raise ValueError("계획 노선 CSV 입력 파일을 찾을 수 없습니다.")
# 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
stored_path = await get_project_storage_relative_path(connection, project_id)
@@ -168,7 +175,8 @@ async def _complete_file_input_if_ready(
async with connection.cursor(aiomysql.DictCursor) as cursor:
await reset_stages_after_input_change(cursor, str(project_id))
await complete_stage(cursor, str(project_id), 0)
return point_cloud_input_id
# LAS가 있으면 LAS, 없으면(las_free) 계획노선 CSV가 WF1 분석 입력이다.
return point_cloud_input_id if point_cloud_input_id is not None else int(route_csv_input_id)
def _write_stage_metadata(
@@ -289,6 +297,7 @@ async def _send_upload_complete_notification(
async def upload_project_files(
project_id: UUID,
files: list[UploadFile] = File(...),
las_free: bool = Form(False),
session: dict[str, Any] = Depends(verify_session),
) -> FileUploadResponse | JSONResponse:
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
@@ -308,7 +317,17 @@ async def upload_project_files(
content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."},
)
las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames)
if las_count != 1:
# LAS 없는 설계(las_free)는 LAS를 **0개만** 받는다. 섞여 들어오면 되돌린다 —
# 올려 두면 전처리가 어느 쪽 경로인지 갈리지 않는다(2026-08-30 사용자 지시).
if las_free and las_count:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.",
},
)
if not las_free and las_count != 1:
return JSONResponse(
status_code=400,
content={
@@ -326,7 +345,7 @@ async def upload_project_files(
},
)
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
missing_required = _missing_required_file_types(request_file_types)
missing_required = _missing_required_file_types(request_file_types, las_free)
if missing_required:
return JSONResponse(
status_code=400,
@@ -385,7 +404,9 @@ async def upload_project_files(
metadata=metadata,
)
)
point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id)
point_cloud_input_id = await _complete_file_input_if_ready(
connection, project_id, las_free
)
await connection.commit()
except Exception:
await connection.rollback()
@@ -434,6 +455,16 @@ async def create_project_upload_session(
session: dict[str, Any] = Depends(verify_session),
) -> ChunkSessionCreateResponse | JSONResponse:
"""대용량 파일 청크 업로드 세션을 생성한다."""
# LAS 없는 설계를 켠 상태면 포인트클라우드는 받지 않는다 — 큰 LAS는 이 경로로
# 들어오므로 여기서 막지 않으면 `/files` 검사를 통째로 비켜 간다.
if payload.las_free and Path(payload.original_filename).suffix.lower() in {".las", ".laz"}:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.",
},
)
chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES)
total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes)
session_id = str(uuid4())
@@ -460,6 +491,7 @@ async def create_project_upload_session(
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
payload.las_free,
)
await connection.commit()
except Exception:
@@ -649,6 +681,7 @@ async def finalize_project_upload(
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
payload.las_free,
)
await connection.commit()
except Exception:
@@ -753,7 +786,9 @@ async def get_project_upload_overview(
async with pool.acquire() as connection:
files = await list_project_input_files(connection, project_id)
sessions = await list_incomplete_upload_sessions(connection, project_id)
file_types, point_cloud_id = await get_project_input_readiness(connection, project_id)
file_types, point_cloud_id, _route_csv_id = await get_project_input_readiness(
connection, project_id
)
async with connection.cursor(aiomysql.DictCursor) as cursor:
state = await get_workflow_state(cursor, str(project_id))
stages = (state or {}).get("stages") or []
@@ -761,6 +796,11 @@ async def get_project_upload_overview(
int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE"
for stage in stages
)
# LAS 없는 설계로 stage 0을 마친 프로젝트는 LAS가 없어도 필수 충족으로 본다.
stage0_complete = any(
int(stage.get("stage_no", -1)) == 0 and str(stage.get("state")) == "COMPLETE"
for stage in stages
)
return UploadOverviewResponse(
files=[
UploadOverviewFile(
@@ -787,7 +827,8 @@ async def get_project_upload_overview(
)
for row in sessions
],
required_complete=_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None,
required_complete=_REQUIRED_FILE_TYPES <= file_types
and (point_cloud_id is not None or stage0_complete),
analysis_complete=analysis_complete,
)
except Exception:
+4
View File
@@ -57,6 +57,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
chunk_size_bytes: int = Field(default=UPLOAD_CHUNK_SIZE_BYTES, gt=0)
complete_upload: bool = False
# LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다.
las_free: bool = False
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
fingerprint: str | None = Field(default=None, max_length=128)
@@ -96,6 +98,8 @@ class UploadFinalizeRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=36)
total_chunks: int = Field(gt=0)
complete_upload: bool = True
# LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다.
las_free: bool = False
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
fingerprint: str | None = Field(default=None, max_length=128)
+6 -3
View File
@@ -149,7 +149,7 @@ async def run_auto_design_chain(
save_initial_snapshot,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from config.config_db import get_db_pool
pool = get_db_pool()
@@ -177,8 +177,11 @@ async def run_auto_design_chain(
logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id)
return None
# 3) B05 경로 계산 — WF1 자동 확정과 같은 config 기본값을 쓴다.
defaults = surface_confirmation_defaults()
# 3) B05 경로 계산 — WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다.
# config 기본값(csf/dtm)을 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을
# 가리켜 404로 체인이 끊긴다(2026-08-30 실사고).
async with pool.acquire() as connection:
defaults = await get_surface_confirmation_params(connection, str(project_id))
request = RouteSolveRequest(
filter_key=str(defaults["source_filter"]),
method=str(defaults["method"]),
+45 -13
View File
@@ -20,6 +20,8 @@ from config.config_db import get_db_pool
from config.config_system import (
AUTO_DESIGN_CHAIN_ENABLED,
SEND_ANALYSIS_COMPLETION_EMAIL,
SHEET_SURFACE_DEFAULT_METHOD,
SURFACE_CONTOUR_INTERVAL_M,
SURFACE_MODEL_PRECOMPUTE,
SURFACE_MODEL_SOURCE_FILTERS,
)
@@ -79,9 +81,15 @@ async def trigger_wf1_analysis_and_email(
input_file = await get_input_file(connection, project_id, input_file_id)
project_root = Path(resolve_stored_project_path(stored_path))
las_path = project_root / Path(str(input_file["raw_file_path"]))
if not las_path.is_file():
raise FileNotFoundError("원본 LAS/LAZ 파일을 찾을 수 없습니다.")
source_path = project_root / Path(str(input_file["raw_file_path"]))
# LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다.
las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"}
if not source_path.is_file():
raise FileNotFoundError(
"계획 노선 파일을 찾을 수 없습니다."
if las_free
else "원본 LAS/LAZ 파일을 찾을 수 없습니다."
)
from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis
from B04_PreProcess.B04_PreProcess_Repository import save_surface_analysis_to_db
@@ -92,15 +100,27 @@ async def trigger_wf1_analysis_and_email(
def _on_progress(percent: int, stage: str, message: str) -> None:
write_surface_progress(project_root, percent, stage, message)
analysis_result = await asyncio.to_thread(
run_surface_analysis,
project_root,
las_path,
source_filters=source_filters,
methods=methods,
force=False,
on_progress=_on_progress,
)
if las_free:
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
run_sheet_surface_analysis,
)
analysis_result = await asyncio.to_thread(
run_sheet_surface_analysis,
project_root,
source_path,
on_progress=_on_progress,
)
else:
analysis_result = await asyncio.to_thread(
run_surface_analysis,
project_root,
source_path,
source_filters=source_filters,
methods=methods,
force=False,
on_progress=_on_progress,
)
auto_confirmation_error: str | None = None
auto_confirmed = False
@@ -124,7 +144,19 @@ async def trigger_wf1_analysis_and_email(
find_surface_model_for_selection,
)
selection = surface_confirmation_defaults()
# LAS 없는 설계는 도엽 서피스 모델(sheet/dtm)로 확정한다.
# 스무딩은 LAS 경로와 같이 적용한다(2026-08-30 사용자 확정) — 방식마다
# `dtm_sheet_*_smooth.npz`를 같이 만들어 두므로 종·횡단이 그걸 샘플링한다.
selection = (
{
"source_filter": f"sheet_{SHEET_SURFACE_DEFAULT_METHOD}",
"method": "dtm",
"smooth": True,
"contour_interval_m": SURFACE_CONTOUR_INTERVAL_M,
}
if las_free
else surface_confirmation_defaults()
)
try:
model_id = await find_surface_model_for_selection(
connection, project_id, selection
+197 -48
View File
@@ -9,8 +9,14 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
import {
createStepBar,
WORKFLOW_STEP_ICONS,
} from "@ui/ui_template_workflow_layout";
import {
fetchUploadOverview,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router";
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
@@ -66,6 +72,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 단계로 넘어가는 것을 막는다(2026-08-08 사용자 지시).
let pageRoot: HTMLElement | null = null;
let isUploading = false;
// LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 프로젝트별로 기억한다.
let lasFreeDesign = activeProjectId
? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1"
: false;
function clearDerivedCaches(projectId: string): void {
clearRouteLatestCache(projectId);
@@ -114,7 +124,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return Array.from(slots.values()).filter((state) => state.file);
}
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
function setCardState(
slot: FileSlot,
stateName: "empty" | "selected" | UploadStatus,
): void {
const card = cardMap.get(slot);
if (!card) return;
card.classList.remove(
@@ -128,7 +141,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const cssState = stateName === "failed" ? "error" : stateName;
card.classList.add(`b03-file__card--${cssState}`);
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
const badgeContainer = card.querySelector<HTMLDivElement>(
".b03-file__card-badge-container",
);
if (badgeContainer) {
badgeContainer.replaceChildren();
if (stateName === "empty") {
@@ -183,18 +198,38 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const card = cardMap.get(slot);
if (!state || !card) return;
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
const fileName = card.querySelector<HTMLSpanElement>(
".b03-file__file-name",
);
const fileSize = card.querySelector<HTMLSpanElement>(
".b03-file__file-size",
);
const progress = card.querySelector<HTMLDivElement>(
".b03-file__progress-bar",
);
const progressBytes = card.querySelector<HTMLSpanElement>(
".b03-file__progress-bytes",
);
const progressSpeed = card.querySelector<HTMLSpanElement>(
".b03-file__progress-speed",
);
const progressEta = card.querySelector<HTMLSpanElement>(
".b03-file__progress-eta",
);
const error = card.querySelector<HTMLDivElement>(
".b03-file__error-message",
);
const remove = card.querySelector<HTMLButtonElement>(
".b03-file__card-remove",
);
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
const percent = state.file
? Math.min(100, (state.progressBytes / state.file.size) * 100)
: 0;
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileName)
fileName.textContent =
state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileSize) {
fileSize.textContent = state.file
? formatBytes(state.file.size)
@@ -220,8 +255,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (remove) remove.hidden = !state.file;
if (state.error) setCardState(slot, "failed");
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
else if (!state.file)
setCardState(slot, state.serverUploaded ? "completed" : "empty");
else
setCardState(
slot,
state.uploadStatus === "pending" ? "selected" : state.uploadStatus,
);
updateUploadButton();
}
@@ -233,19 +273,29 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
renderSlot(slot);
}
function validateFileForSlot(file: File, state: FileSlotState): string | null {
function validateFileForSlot(
file: File,
state: FileSlotState,
): string | null {
const extension = getExtension(file.name);
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
if (!state.extensions.includes(extension))
return L("B03_File_Error_SlotType");
if (file.size === 0 || file.size > maxBytes)
return L("B03_File_Error_Size");
return null;
}
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
async function assignFileToSlot(
file: File,
targetSlot?: FileSlot,
): Promise<void> {
const extension = getExtension(file.name);
const state = targetSlot
? slots.get(targetSlot)
: Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension));
: Array.from(slots.values()).find((candidate) =>
candidate.extensions.includes(extension),
);
if (!state) {
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
return;
@@ -256,13 +306,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return;
}
if (!targetSlot && state.file && state.file.name !== file.name) {
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
showErrorMessage(
state.slot,
`${L("B03_File_Error_DuplicateSlot")} ${file.name}`,
);
return;
}
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
// 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다.
if (state.serverUploaded) {
const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name);
const accepted = await confirmReplaceUpload(
L(state.labelKey),
state.serverUploaded.name,
);
if (!accepted) return;
}
@@ -276,8 +332,24 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
renderSlot(state.slot);
}
function onFileSelected(files: readonly File[], targetSlot?: FileSlot): void {
if (files.length === 0) return;
function onFileSelected(
selection: readonly File[],
targetSlot?: FileSlot,
): void {
if (selection.length === 0) return;
// LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) —
// 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다.
const pointCloudExtensions = slots.get("las_laz")?.extensions ?? [];
const files = lasFreeDesign
? selection.filter(
(file) => !pointCloudExtensions.includes(getExtension(file.name)),
)
: selection;
const blocked = files.length !== selection.length;
if (blocked && files.length === 0) {
pageError.textContent = L("B03_File_Error_LasFreeBlocked");
return;
}
// 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다.
// 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른
// 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08).
@@ -286,15 +358,16 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const extension = getExtension(file.name);
const slot =
targetSlot ??
Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension))
?.slot;
Array.from(slots.values()).find((candidate) =>
candidate.extensions.includes(extension),
)?.slot;
if (slot) occupied.add(slot);
}
if (occupied.size > UPLOAD_MAX_FILES) {
pageError.textContent = L("B03_File_Error_Count");
return;
}
pageError.textContent = "";
pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : "";
void (async () => {
for (const file of files) await assignFileToSlot(file, targetSlot);
await detectPausedUploads();
@@ -325,11 +398,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
const missingRequired = Array.from(slots.values()).some(
(state) => state.isRequired && !state.file && !state.serverUploaded,
(state) =>
state.isRequired &&
!state.file &&
!state.serverUploaded &&
// LAS 없는 설계면 포인트클라우드 카드는 필수에서 뺀다.
!(lasFreeDesign && state.slot === "las_laz"),
);
if (missingRequired) return L("B03_File_Error_RequiredSlots");
const lasState = slots.get("las_laz");
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
if (!lasFreeDesign) {
const lasState = slots.get("las_laz");
if (!lasState?.file && !lasState?.serverUploaded)
return L("B03_File_Error_Las");
}
for (const state of selected) {
if (state.error) return state.error;
const validation = validateFileForSlot(state.file!, state);
@@ -358,7 +439,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
candidate.extensions.includes(extension),
);
if (state) {
state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb };
state.serverUploaded = {
name: file.original_filename,
sizeMb: file.file_size_mb,
};
}
}
for (const slot of slots.keys()) renderSlot(slot);
@@ -393,22 +477,30 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!card) throw new Error("file-card-template is invalid");
card.dataset.slotId = state.slot;
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
card.querySelector(".b03-file__card-label")!.textContent = L(
state.labelKey,
);
// 지형 래스터만 선택 항목이라 확장자 옆에 표시해 둔다.
const extLabel = state.extensions.join(", ");
card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired
? extLabel
: `${extLabel} · ${L("B03_File_Card_Optional")}`;
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
const input = card.querySelector<HTMLInputElement>(
".b03-file__slot-input",
)!;
input.accept = state.extensions.join(",");
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
const select = card.querySelector<HTMLButtonElement>(
".b03-file__card-select",
)!;
select.textContent = L("B03_File_Card_Select");
select.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
input.value = "";
});
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
const remove = card.querySelector<HTMLButtonElement>(
".b03-file__card-remove",
)!;
remove.textContent = "×";
remove.title = L("B03_File_Card_Remove");
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
@@ -417,7 +509,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return card;
}
function createCardGroup(title: string, groupSlots: readonly FileSlot[]): HTMLElement {
function createCardGroup(
title: string,
groupSlots: readonly FileSlot[],
): HTMLElement {
const group = document.createElement("section");
group.className = "b03-file__group";
if (title) {
@@ -443,7 +538,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!activeProjectId) return;
for (const state of selectedStates()) {
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
const stored = localStorage.getItem(
makeSessionKey(activeProjectId, state.file!),
);
if (!stored) continue;
const session = JSON.parse(stored) as StoredUploadSession;
state.uploadSessionId = session.uploadSessionId;
@@ -513,7 +610,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
const detail =
error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
showToast(L("B03_Temp_Attach_Failed"), "error");
}
@@ -542,7 +640,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
}
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
async function startChunkedUpload(
targetStates = selectedStates(),
): Promise<void> {
if (isUploading) return;
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
if (tempPicker.selected()) {
@@ -568,8 +668,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
uploaded.push(
...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1, () =>
renderSlot(state.slot),
...(await uploadOneFile(
activeProjectId,
state,
index === targetStates.length - 1,
() => renderSlot(state.slot),
lasFreeDesign,
)),
);
}
@@ -586,8 +690,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
const failed = targetStates.find(
(state) => state.uploadStatus === "uploading",
);
const detail =
error instanceof Error ? error.message : L("B03_File_Upload_Failed");
if (failed) showErrorMessage(failed.slot, detail);
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
showToast(L("B03_File_Upload_Failed"), "error");
@@ -621,7 +728,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
function onB03_File_Drop(event: DragEvent): void {
event.preventDefault();
dropzone.classList.remove("is-dragging");
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
onFileSelected(
event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [],
);
}
fileInput.addEventListener("change", onB03_File_Select_Change);
@@ -633,7 +742,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
event.preventDefault();
dropzone.classList.add("is-dragging");
});
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
dropzone.addEventListener("dragleave", () =>
dropzone.classList.remove("is-dragging"),
);
dropzone.addEventListener("drop", onB03_File_Drop);
uploadButton = createButton({
@@ -667,9 +778,46 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
"tif",
]);
// LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다.
const lasFreeRow = document.createElement("label");
lasFreeRow.className = "b03-file__lasfree";
lasFreeRow.title = L("B03_File_LasFree_Hint");
const lasFreeCheck = document.createElement("input");
lasFreeCheck.type = "checkbox";
const lasFreeText = document.createElement("span");
lasFreeText.textContent = L("B03_File_LasFree_Toggle");
lasFreeRow.append(lasFreeCheck, lasFreeText);
function applyLasFreeState(): void {
lasFreeCheck.checked = lasFreeDesign;
const card = cardMap.get("las_laz");
card?.classList.toggle("b03-file__card--disabled", lasFreeDesign);
// 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다.
card
?.querySelectorAll<HTMLButtonElement | HTMLInputElement>(
".b03-file__card-select, .b03-file__slot-input",
)
.forEach((element) => {
element.disabled = lasFreeDesign;
});
// 켜기 전에 이미 골라 둔 LAS는 내린다 — 켠 채로 남아 올라가는 사고를 막는다.
if (lasFreeDesign && slots.get("las_laz")?.file) removeFile("las_laz");
}
lasFreeCheck.addEventListener("change", () => {
lasFreeDesign = lasFreeCheck.checked;
if (activeProjectId) {
localStorage.setItem(
`b03_las_free_${activeProjectId}`,
lasFreeDesign ? "1" : "0",
);
}
applyLasFreeState();
pageError.textContent = "";
});
const cardsContainer = document.createElement("div");
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(inputsGroup);
cardsContainer.className =
"b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(lasFreeRow, inputsGroup);
const workflowState = activeProjectId
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
@@ -705,6 +853,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
root.replaceChildren(layout.root);
for (const slot of slots.keys()) renderSlot(slot);
applyLasFreeState();
void relockWhileInitialPipelineRuns();
void registerB03ServiceWorker();
void applyUploadOverview();
+23 -2
View File
@@ -277,7 +277,9 @@
color: var(--color-royal-amethyst, #3e0079);
background: var(--color-mist-violet, #edecff);
font-size: var(--text-body-sm, 14px);
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
margin-right: var(
--spacing-8
); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
}
.b03-file__card-heading {
@@ -321,7 +323,9 @@
font-size: var(--text-body-sm, 14px);
line-height: 1;
padding: 0;
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
margin-left: var(
--spacing-8
); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
transition: all var(--transition-base, 0.2s);
}
@@ -474,3 +478,20 @@
grid-template-columns: 1fr; /* 모바일에서는 1행 1열 구조 */
}
}
/* LAS 없는 설계(도엽등고선 기반) 토글 — 2026-08-30 */
.b03-file__lasfree {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 2px;
font-size: var(--text-body);
color: var(--color-text-body);
cursor: pointer;
user-select: none;
}
.b03-file__card--disabled {
opacity: 0.45;
pointer-events: none;
}
+49 -12
View File
@@ -6,8 +6,14 @@
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
* ========================================================================== */
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
import {
PROGRESS_UPDATE_INTERVAL_MS,
UPLOAD_CHUNK_SIZE_MB,
} from "@config/config_frontend";
import {
fetchWorkflowState,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import { createButton } from "@ui/ui_template_elements";
import { fileFingerprint } from "./B03_FileInput_Fingerprint";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -18,7 +24,10 @@ import {
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
import {
saveB03UploadedFile,
updateB03AnalysisState,
} from "./B03_FileInput_State";
import {
makeSessionKey,
type FileSlotState,
@@ -33,7 +42,10 @@ function L(key: keyof typeof ui_locales): string {
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
*/
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
export function confirmReplaceUpload(
slotLabel: string,
fileName: string,
): Promise<boolean> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
@@ -98,6 +110,7 @@ export async function uploadOneFile(
state: FileSlotState,
completeUpload: boolean,
onProgress: () => void,
lasFree = false,
): Promise<UploadedFileResult[]> {
const file = state.file;
if (!file) return [];
@@ -107,7 +120,9 @@ export async function uploadOneFile(
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
const fingerprint = state.uploadSessionId
? null
: await fileFingerprint(file);
let session = state.uploadSessionId;
if (!session) {
const created = await createUploadSession(
@@ -116,6 +131,7 @@ export async function uploadOneFile(
chunkSizeBytes,
fingerprint,
completeUpload,
lasFree,
);
if (created.already_uploaded) {
state.progressBytes = file.size;
@@ -140,11 +156,22 @@ export async function uploadOneFile(
const start = chunkIndex * chunkSizeBytes;
const end = Math.min(file.size, start + chunkSizeBytes);
const chunkStartedAt = performance.now();
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
await uploadFileChunk(
projectId,
session,
chunkIndex,
file.slice(start, end),
);
const elapsedSec = Math.max(
0.001,
(performance.now() - chunkStartedAt) / 1000,
);
state.progressBytes = end;
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
state.etaSeconds =
state.speedMbs > 0
? (file.size - end) / 1024 / 1024 / state.speedMbs
: null;
const stored: StoredUploadSession = {
key: storageKey,
@@ -161,7 +188,10 @@ export async function uploadOneFile(
localStorage.setItem(storageKey, JSON.stringify(stored));
const now = performance.now();
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
if (
now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS ||
chunkIndex === totalChunks - 1
) {
lastPaintAt = now;
onProgress();
}
@@ -173,6 +203,7 @@ export async function uploadOneFile(
totalChunks,
completeUpload,
fingerprint,
lasFree,
);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
@@ -182,7 +213,10 @@ export async function uploadOneFile(
});
state.progressBytes = file.size;
state.speedMbs =
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
file.size /
1024 /
1024 /
Math.max(0.001, (performance.now() - startedAt) / 1000);
state.etaSeconds = 0;
state.uploadStatus = "completed";
onProgress();
@@ -203,9 +237,12 @@ export async function uploadOneFile(
*
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
*/
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
export function isInitialPipelineRunning(
state: WorkflowState | undefined,
): boolean {
if (!state?.stages?.length) return false;
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
const stageAt = (stageNo: number) =>
state.stages.find((stage) => stage.stage_no === stageNo);
const fileInput = stageAt(0);
const preprocess = stageAt(1);
const section = stageAt(3);
+73 -11
View File
@@ -223,11 +223,64 @@ def run_surface_analysis(
time.monotonic() - step_started,
)
# 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드 (기존 산출물이 있으면 스킵)
# 3-2·3-3. VWorld 지도·국가 GIS 벡터·수치지형도 도엽 (공용 블록 — 도엽 서피스도 사용)
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
las_bounds_dict = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
download_geodata(
project_root, processed_dir, las_bounds_dict, las_path.parent, rebuild, report=_report
)
# 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다
# (2026-08-30 사용자 확정). 실패해도 분석은 계속한다.
_report(94, "surface_model", "도엽등고선 3D 서피스 생성 중")
sheet_models: list[dict[str, Any]] = []
try:
# 입력 LAS와 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거)
prj_candidates = sorted(las_path.parent.glob("*.prj")) or sorted(
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
build_sheet_surface_from_route,
)
sheet_models = build_sheet_surface_from_route(project_root, processed_dir, models_dir)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
_report(95, "saving", "결과 저장 중")
return _collect_analysis_result(
project_root,
models_dir,
structured_path,
bounds_dict,
stats,
total_points,
ground_summary,
manifest,
sheet_models,
total_started,
)
def download_geodata(
project_root: Path,
processed_dir: Path,
las_bounds_dict: dict[str, list[float]],
prj_search_dir: Path,
rebuild: bool,
*,
default_epsg: str = "EPSG:5186",
report: Any = None,
) -> None:
"""VWorld 지도·국가 GIS 벡터·수치지형도 도엽 확보 (공용 블록).
LAS 분석(run_surface_analysis)과 LAS 없는 도엽 서피스 분석이 같이 쓴다.
실패해도 예외를 밖으로 던지지 않는다 — 분석 본체를 막지 않는다.
"""
try:
# 입력 파일과 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거)
prj_candidates = sorted(prj_search_dir.glob("*.prj")) or sorted(
project_root.glob("B03_FileInput/**/*.prj")
)
prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj"
@@ -243,12 +296,7 @@ def run_surface_analysis(
get_epsg_from_prj,
)
las_bounds_dict = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
project_epsg = "EPSG:5186"
project_epsg = default_epsg
if prj_path.exists():
project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
# 국가 GIS 벡터는 라이다∪계획노선 범위로 받는다.
@@ -305,7 +353,8 @@ def run_surface_analysis(
# 3-3. 1:5,000 수치지형도 도엽 확보 → 프로젝트 영구저장소
# 기준은 계획노선 시점·종점 (같은 도엽이면 9매, 이웃 도엽에 걸치면 12매).
# (실패해도 분석은 계속 — 폴백은 수동 다운로드 + 인제스트)
_report(92, "download_maps", "수치지형도 도엽 확보 중")
if report is not None:
report(92, "download_maps", "수치지형도 도엽 확보 중")
try:
from B04_PreProcess.B04_PreProcess_Engine_Extent import sheet_reference_points_wgs84
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import neighbors_for_points
@@ -356,8 +405,20 @@ def run_surface_analysis(
except Exception as exc:
logger.warning("B04 지도·GIS 다운로드 단계 실패: %s", exc)
_report(95, "saving", "결과 저장 중")
def _collect_analysis_result(
project_root: Path,
models_dir: Path,
structured_path: Path,
bounds_dict: dict[str, float],
stats: dict[str, Any],
total_points: int,
ground_summary: dict[str, Any],
manifest: dict[str, Any],
sheet_models: list[dict[str, Any]],
total_started: float,
) -> dict[str, Any]:
"""manifest에서 모델 목록을 추려 분석 결과 dict를 조립한다."""
processed = {
"processed_file_path": _relative_to_project(project_root, structured_path),
"converted_file_path": None,
@@ -403,6 +464,7 @@ def run_surface_analysis(
"layers": layers,
}
)
models.extend(sheet_models)
logger.info(
"B04 WF1 분석 완료: 모델 %d개, 총 %.1fs", len(models), time.monotonic() - total_started
@@ -0,0 +1,513 @@
"""도엽등고선 → 표고 격자 보간 방식 모음 (비교용).
문헌(Hutchinson 1988/89 ANUDEM; Chaplot 2006; Arun 2013 등)은 지형 복잡도·자료 밀도에
따라 우열이 갈리며 단일 최적해가 없다고 본다. 그래서 방식을 하나로 고르지 않고 여기
모아 두고 B04 화면에서 바꿔 가며 보게 한다(2026-08-30 사용자 지시).
각 builder는 `(spec, burned, features, cell_m) -> (R, C) float32` 격자를 돌려준다.
`burned`는 등고 라인이 구워진 격자(라인 셀 = 표고, 그 외 NaN)다. 폐합 링 안쪽 처리와
프리뷰·저장은 호출측(`_SheetSurface`)이 방식과 무관하게 똑같이 해 준다.
"""
import logging
import warnings
from typing import Any, Callable
import numpy as np
logger = logging.getLogger(__name__)
# TIN(도엽선)이 물어 오는 격자 밖 여유 — 테두리가 삼각망 밖으로 나가지 않을 만큼만.
SHEET_TIN_CLIP_MARGIN_M = 100.0
# 화면 버튼에 쓰는 이름 — 키는 surface_models.generation_params.source_filter 접미사다.
SHEET_METHOD_LABELS: dict[str, str] = {
"tin_sheet": "TIN(도엽선)",
"tin": "TIN 격자",
"biharmonic": "TPS(박판)",
"anudem": "ANUDEM형",
"multires": "다중해상도",
"laplace": "라플라스",
}
def _laplacian(values: np.ndarray) -> np.ndarray:
padded = np.pad(values, 1, mode="edge")
return (
padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] - 4.0 * values
)
def _contour_vertices(burned: np.ndarray, spec: Any) -> tuple[np.ndarray, np.ndarray]:
"""등고 라인 셀을 (N,2) 세계좌표와 표고로 바꾼다."""
rows, cols = np.nonzero(np.isfinite(burned))
xs = spec.cell_centers_x()[cols]
ys = spec.cell_centers_y()[rows]
return np.column_stack([xs, ys]), burned[rows, cols].astype(np.float64)
def _grid_points(spec: Any) -> tuple[np.ndarray, np.ndarray]:
grid_x, grid_y = np.meshgrid(spec.cell_centers_x(), spec.cell_centers_y())
return grid_x, grid_y
# ── ① 거리 비례 (버튼에서는 뺐지만 다른 방식의 초기추정으로 계속 쓴다) ───────
def build_distance(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""가장 가까운 서로 다른 표고 두 라인 사이를 거리 비례로 나눈다.
z = (L1·d2 + L2·d1) / (d1 + d2)
지도 제작의 고전적 손보간을 그대로 옮긴 것이다. 원뿔·능선(z=r) 형상을 정확히
재현하고 계단이 생기지 않는다. 표고별 거리장을 돌며 가장 작은 두 값을 추적하므로
L1≠L2가 보장된다.
"""
from scipy.ndimage import distance_transform_edt
levels = np.unique(burned[np.isfinite(burned)])
if len(levels) < 2:
return np.full(burned.shape, levels[0] if len(levels) else np.nan, dtype=np.float32)
infinity = np.float32(np.inf)
first_d = np.full(burned.shape, infinity, dtype=np.float32)
first_z = np.zeros(burned.shape, dtype=np.float32)
second_d = np.full(burned.shape, infinity, dtype=np.float32)
second_z = np.zeros(burned.shape, dtype=np.float32)
for level in levels:
distance = distance_transform_edt(burned != level, sampling=cell_m).astype(np.float32)
beats_first = distance < first_d
second_d = np.where(beats_first, first_d, second_d)
second_z = np.where(beats_first, first_z, second_z)
first_d = np.where(beats_first, distance, first_d)
first_z = np.where(beats_first, np.float32(level), first_z)
beats_second = ~beats_first & (distance < second_d)
second_d = np.where(beats_second, distance, second_d)
second_z = np.where(beats_second, np.float32(level), second_z)
total = first_d + second_d
usable = np.isfinite(second_d) & (total > 1e-9)
surface = first_z.astype(np.float32)
surface[usable] = (
(
first_z[usable].astype(np.float64) * second_d[usable].astype(np.float64)
+ second_z[usable].astype(np.float64) * first_d[usable].astype(np.float64)
)
/ total[usable].astype(np.float64)
).astype(np.float32)
return surface
def relax_laplace(surface: np.ndarray, fixed: np.ndarray, iterations: int) -> None:
"""등고 라인을 고정한 채 이웃 평균으로 다듬는다 (in-place, red-black 순서)."""
if iterations <= 0:
return
free = ~fixed & np.isfinite(surface)
if not free.any():
return
rows, cols = np.indices(surface.shape)
red = free & (((rows + cols) & 1) == 0)
black = free & ~red
padded = np.zeros((surface.shape[0] + 2, surface.shape[1] + 2), dtype=np.float32)
for _ in range(iterations):
for colour in (red, black):
padded[1:-1, 1:-1] = surface
padded[0, 1:-1] = surface[0]
padded[-1, 1:-1] = surface[-1]
padded[1:-1, 0] = surface[:, 0]
padded[1:-1, -1] = surface[:, -1]
neighbours = (
padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:]
) * np.float32(0.25)
surface[colour] = neighbours[colour]
# ── ② 라플라스(조화) ─────────────────────────────────────────────────────────
def build_laplace(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""등고선을 경계값으로 두고 Δz=0을 푼다.
면이 매끈해지지만 z=r(원뿔·능선)은 조화함수가 아니라 마루가 눌린다. 비교 기준으로
남겨 둔다 — ANUDEM이 라플라스 대신 박판 스플라인을 쓰는 이유를 눈으로 보기 위함.
"""
surface = build_distance(spec, burned, features, cell_m)
relax_laplace(surface, np.isfinite(burned), 400)
return surface
# ── ③ 박판 스플라인(중조화) ──────────────────────────────────────────────────
def solve_min_curvature(constrained: np.ndarray, guess: np.ndarray) -> np.ndarray:
"""제약 셀을 고정하고 Δ²z=0(박판 스플라인)을 최소곡률 최소제곱으로 푼다.
ANUDEM/Topo to Raster가 쓰는 박판 스플라인과 같은 연산자다. 라플라스와 달리 z=r을
그대로 통과시켜 능선·마루가 눌리지 않고, 경사가 등고선 너머로 자연스럽게 이어진다.
`constrained`: 값이 고정된 셀(등고 라인, 필요하면 구조선 앵커) — 그 외는 NaN.
`guess`: 시작값이자 감쇠 기준(보통 거리 보간 결과).
"""
from scipy.sparse.linalg import LinearOperator, lsmr
guess = guess.astype(np.float64)
burned = constrained
# 제약 셀 + **격자 테두리**를 고정한다. 최외곽 등고선 바깥이 통째로 자유면
# 1차함수가 Δ²의 영공간에 남아 해가 하나로 정해지지 않고 켤레기울기가 발산한다
# (2026-08-30 실측: |Δz| 2092m). 테두리는 거리 보간값으로 묶는다.
fixed = np.isfinite(burned)
fixed[0, :] = fixed[-1, :] = True
fixed[:, 0] = fixed[:, -1] = True
free = ~fixed & np.isfinite(guess)
if not free.any():
return guess.astype(np.float32)
index = np.flatnonzero(free.ravel())
values = np.where(np.isfinite(burned), burned, guess)
base = np.where(fixed, np.nan_to_num(values), 0.0)
# Δ²z=0을 정규방정식(CG)으로 풀면 조건수가 격자변 4제곱이라 발산한다(실측).
# 대신 **최소곡률** 최소제곱으로 세운다 — 자유 셀에 대해 ‖Δz‖를 최소화하며,
# 그 정상해가 곧 Δ²z=0이다. 조건수가 제곱으로 줄어 LSMR이 안정적으로 푼다
# (Briggs 1974의 최소곡률 격자화와 같은 목적함수).
# 최소곡률만으로는 제약(등고선)에서 먼 영역이 정해지지 않아 해가 폭주한다.
# ANUDEM의 거칠기 벌점과 같은 취지로 감쇠항을 붙여 거리 보간값에 묶어 둔다:
# minimize ‖Δz‖² + λ‖z − 거리보간‖²
# λ가 작을수록 더 매끈하고 클수록 거리 보간에 가깝다.
total_cells = base.size
free_count = len(index)
damping = np.float64(np.sqrt(0.02))
anchor = guess.ravel()[index]
def forward(vector: np.ndarray) -> np.ndarray:
# 반드시 **선형**이어야 한다 — 고정 셀 기여(base)를 여기서 더하면 아핀이 되어
# LSMR의 전제가 깨지고 해가 폭주한다. base 몫은 우변으로만 넘긴다.
scattered = np.zeros_like(base)
scattered.ravel()[index] = vector
return np.concatenate([_laplacian(scattered).ravel(), damping * vector])
def adjoint(vector: np.ndarray) -> np.ndarray:
curvature = _laplacian(vector[:total_cells].reshape(base.shape)).ravel()[index]
return curvature + damping * vector[total_cells:]
rhs = np.concatenate([-_laplacian(base).ravel(), damping * anchor])
linear = LinearOperator(
(total_cells + free_count, free_count),
matvec=forward,
rmatvec=adjoint,
dtype=np.float64,
)
result = lsmr(linear, rhs, x0=anchor, maxiter=400, atol=1e-8, btol=1e-8)
solution, info = result[0], result[1]
surface = base.copy()
surface.ravel()[index] = solution
surface[fixed] = values[fixed]
# 안전장치 — 발산하면 조용히 틀린 지형을 넘기지 말고 거리 보간으로 되돌린다.
drift = float(np.nanmax(np.abs(surface - guess)))
span = float(np.nanmax(guess) - np.nanmin(guess))
if not np.isfinite(drift) or drift > max(span, 1.0):
logger.warning(
"도엽 서피스(TPS): 해가 발산해(최대 %.1fm) 거리 보간으로 되돌립니다 (info=%s).",
drift,
info,
)
return guess.astype(np.float32)
logger.info("도엽 서피스(TPS): 최소제곱 info=%s, 최대 변화 %.2fm", info, drift)
return surface.astype(np.float32)
def build_biharmonic(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""등고선만 제약으로 둔 박판 스플라인."""
return solve_min_curvature(burned, build_distance(spec, burned, features, cell_m))
# ── ④ TIN ───────────────────────────────────────────────────────────────────
def _triangulate(
spec: Any, shape_: tuple[int, int], points: np.ndarray, values: np.ndarray
) -> np.ndarray:
"""정점 구름을 Delaunay 삼각망 선형 보간해 격자로 편다. 삼각망 밖은 NaN."""
from scipy.interpolate import LinearNDInterpolator
if len(points) < 3:
return np.full(shape_, np.nan, dtype=np.float32)
interpolator = LinearNDInterpolator(points, values)
grid_x, grid_y = _grid_points(spec)
surface = np.empty(shape_, dtype=np.float32)
chunk = max(1, int(4_000_000 // max(spec.n_cols, 1)))
for start in range(0, spec.n_rows, chunk):
stop = min(start + chunk, spec.n_rows)
surface[start:stop] = interpolator(grid_x[start:stop], grid_y[start:stop]).astype(
np.float32
)
return surface
def build_tin(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""격자에 구운 등고 라인 셀을 Delaunay 삼각망 선형 보간한다.
정점이 셀 중심에 맞춰져 있어 1m 계단이 삼각망에 그대로 실린다. 같은 표고 정점
3개로 이루어진 평탄 삼각형이 굴곡부·마루에 계단을 만든다. 비교 기준으로 남긴다.
"""
points, values = _contour_vertices(burned, spec)
if len(points) > 120_000: # 삼각망 비용은 정점 수에 비례한다
step = int(np.ceil(len(points) / 120_000))
points, values = points[::step], values[::step]
return _triangulate(spec, burned.shape, points, values)
def build_tin_sheet(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""**원본 5m 도엽 등고선 정점**을 그대로 이은 고전 TIN (2026-08-30 사용자 지시).
`tin`은 격자에 구운 라인 셀(=1m 계단으로 뭉개진 정점)을 쓰지만, 이쪽은 벡터
등고선의 정점을 좌표 그대로 쓴다 — 도면 등고선을 삼각망으로 잇는 측량 관행 그대로다.
격자 밖 등고선은 물지 않는다(삼각망 비용만 커지고 결과는 같다). 다만 격자 테두리가
삼각망 밖으로 나가지 않도록 여유를 두고 자른다.
길이 필터는 두지 않는다 — 짧은 봉우리 폐합 등고선을 버리면 마루가 통째로 평평해진다.
"""
from shapely.geometry import shape as to_shape
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
ELEVATION_KEYS,
iter_linestrings,
)
xs, ys = spec.cell_centers_x(), spec.cell_centers_y()
margin = max(SHEET_TIN_CLIP_MARGIN_M, cell_m * 2.0)
x_lo, x_hi = xs[0] - margin, xs[-1] + margin
y_lo, y_hi = ys[-1] - margin, ys[0] + margin
coords: list[np.ndarray] = []
levels: list[np.ndarray] = []
for feature in features or []:
properties = feature.get("properties") or {}
elevation = next(
(float(properties[key]) for key in ELEVATION_KEYS if properties.get(key) is not None),
None,
)
geometry = feature.get("geometry")
if elevation is None or not geometry:
continue
try:
parsed = to_shape(geometry)
except Exception: # noqa: BLE001 — 손상된 피처는 건너뛴다
continue
for line in iter_linestrings(parsed):
point = np.asarray(line.coords, dtype=np.float64)[:, :2]
inside = (
(point[:, 0] >= x_lo)
& (point[:, 0] <= x_hi)
& (point[:, 1] >= y_lo)
& (point[:, 1] <= y_hi)
)
if not inside.any():
continue
coords.append(point[inside])
levels.append(np.full(int(inside.sum()), elevation, dtype=np.float64))
if not coords:
logger.warning("도엽 서피스: TIN(도엽선)에 쓸 등고선 정점이 없습니다.")
return np.full(burned.shape, np.nan, dtype=np.float32)
points = np.vstack(coords)
values = np.concatenate(levels)
# 도엽 이음매에서 같은 정점이 겹쳐 들어온다 — Qhull 비용만 늘어 미리 접는다.
_, unique = np.unique(np.round(points, 3), axis=0, return_index=True)
points, values = points[unique], values[unique]
logger.info("도엽 서피스: TIN(도엽선) 정점 %d개", len(points))
return _triangulate(spec, burned.shape, points, values)
# ── ⑦ ANUDEM형 (구조선 + 배수 강제) ─────────────────────────────────────────
def _contour_corner_anchors(
spec: Any, burned: np.ndarray, guess: np.ndarray, cell_m: float
) -> tuple[np.ndarray, np.ndarray] | None:
"""등고선의 국소 최대 곡률점(코너)에서 능선·계곡 구조선 앵커를 만든다.
ANUDEM은 등고선 자체의 곡률에서 능선·계곡망을 먼저 뽑아 흐름 구조를 세운다
(Hutchinson 1988/89). 여기서도 같은 순서를 따른다.
① 라인마다 정점 곡률을 재 국소 최대점(V자 꼭짓점)을 고른다
② 굽은 안쪽이 더 높으면 **계곡**(등고선 V가 상류를 가리킴), 낮으면 **능선**
③ 같은 종류의 코너를 이웃 표고끼리 이어 그 사이를 선형 보간해 앵커로 심는다
앵커는 박판 해의 제약으로 들어가 계곡 바닥이 이어져 내려가고 능선 마루가 선다.
"""
from scipy.ndimage import label
from scipy.spatial import cKDTree
levels = np.unique(burned[np.isfinite(burned)])
if len(levels) < 2:
return None
interval = float(np.diff(levels).min())
xs = spec.cell_centers_x()
ys = spec.cell_centers_y()
corners: list[tuple[float, float, float, int]] = [] # x, y, level, +1 계곡 / -1 능선
span = 6 # 곡률을 재는 정점 간격(px) — 짧으면 노이즈, 길면 꼭짓점을 놓친다
for level in levels:
labelled, count = label(burned == level)
for component_id in range(1, count + 1):
line_rows, line_cols = np.nonzero(labelled == component_id)
if len(line_rows) < 3 * span:
continue
# 라인 셀을 한 줄로 세운다 — 좌표 정렬로 근사한다(정밀 추적은 과하다).
order = np.argsort(line_cols + line_rows * 1e-3)
path = np.column_stack([line_cols[order], line_rows[order]]).astype(np.float64)
before = np.roll(path, span, axis=0)
after = np.roll(path, -span, axis=0)
first = path - before
second = after - path
first_len = np.hypot(first[:, 0], first[:, 1])
second_len = np.hypot(second[:, 0], second[:, 1])
valid = (first_len > 1e-6) & (second_len > 1e-6)
cosine = np.ones(len(path))
cosine[valid] = (first[valid] * second[valid]).sum(axis=1) / (
first_len[valid] * second_len[valid]
)
sharp = np.flatnonzero(valid & (cosine < 0.3)) # 70도 이상 꺾인 자리
for i in sharp[:: max(1, span)]:
# 굽은 안쪽 방향 = 두 변 단위벡터 합의 반대
inward = -(first[i] / first_len[i] + second[i] / second_len[i])
norm = float(np.hypot(inward[0], inward[1]))
if norm < 1e-6:
continue
probe = path[i] + inward / norm * 6.0
probe_col = int(round(probe[0]))
probe_row = int(round(probe[1]))
if not (0 <= probe_row < guess.shape[0] and 0 <= probe_col < guess.shape[1]):
continue
inside = float(guess[probe_row, probe_col])
if not np.isfinite(inside) or abs(inside - level) < interval * 0.15:
continue
corners.append(
(
float(xs[int(path[i, 0])]),
float(ys[int(path[i, 1])]),
float(level),
1 if inside > level else -1,
)
)
if len(corners) < 4:
logger.info("도엽 서피스(ANUDEM형): 등고선 코너가 부족해 구조선을 건너뜁니다.")
return None
array = np.asarray(corners, dtype=np.float64)
anchor_xy: list[np.ndarray] = []
anchor_z: list[np.ndarray] = []
reach = interval * 20.0 # 이보다 먼 코너는 같은 구조선으로 보지 않는다
for level in levels[:-1]:
upper = level + interval
lower_set = array[np.abs(array[:, 2] - level) < 1e-6]
upper_set = array[np.abs(array[:, 2] - upper) < 1e-6]
if not len(lower_set) or not len(upper_set):
continue
tree = cKDTree(upper_set[:, :2])
distance, index = tree.query(lower_set[:, :2], k=1)
for i in range(len(lower_set)):
j = int(index[i])
if distance[i] > reach or lower_set[i, 3] != upper_set[j, 3]:
continue
start = lower_set[i, :2]
end = upper_set[j, :2]
steps = max(2, int(distance[i] / max(cell_m, 1e-6) / 4))
fraction = np.linspace(0.0, 1.0, steps + 1)[1:-1]
if not len(fraction):
continue
anchor_xy.append(start + (end - start) * fraction[:, None])
anchor_z.append(level + interval * fraction)
if not anchor_xy:
return None
return np.vstack(anchor_xy), np.concatenate(anchor_z)
def _enforce_drainage(surface: np.ndarray, epsilon: float = 0.01) -> int:
"""가짜 웅덩이를 메운다 — ANUDEM의 배수 강제와 같은 목적.
등고선만으로 만든 면에는 흐름이 끊기는 웅덩이가 남는다. 형태학적 재구성(erosion)
으로 채우되 완전 평탄해지지 않게 아주 작은 값을 얹는다. 채운 셀 수를 반환한다.
"""
from skimage.morphology import reconstruction
if not np.isfinite(surface).all():
return 0
seed = np.full(surface.shape, float(surface.max()), dtype=np.float64)
seed[0, :] = surface[0, :]
seed[-1, :] = surface[-1, :]
seed[:, 0] = surface[:, 0]
seed[:, -1] = surface[:, -1]
filled = reconstruction(seed, surface.astype(np.float64), method="erosion")
raised = filled > surface + 1e-6
if not raised.any():
return 0
# 그냥 채우면 웅덩이가 통째로 평탄해져 흐름 방향이 없어진다. 채운 영역 안쪽으로
# 갈수록 아주 조금 높아지게 해서 물이 가장자리(넘침점)로 빠져나가게 둔다
# (Garbrecht·Martz의 평탄면 해소를 간단히 옮긴 것 — 표고 변화는 cm 단위다).
from scipy.ndimage import distance_transform_edt
inner = distance_transform_edt(raised)
surface[raised] = (filled[raised] + epsilon * inner[raised]).astype(surface.dtype)
return int(raised.sum())
def build_anudem(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""ANUDEM형 — 등고선 곡률에서 능선·계곡 구조선을 뽑아 제약에 더하고, 박판으로 풀고,
가짜 웅덩이를 메운다. Topo to Raster가 밟는 세 단계를 그대로 옮긴 것이다."""
guess = build_distance(spec, burned, features, cell_m).astype(np.float64)
constrained = burned.astype(np.float64).copy()
anchors = _contour_corner_anchors(spec, burned, guess, cell_m)
if anchors is not None:
xy, z = anchors
row, col = spec.world_to_rc(xy[:, 0].copy(), xy[:, 1].copy())
inside = (row >= 0) & (col >= 0)
row, col, z = row[inside], col[inside], z[inside]
free = ~np.isfinite(constrained[row, col])
constrained[row[free], col[free]] = z[free]
logger.info("도엽 서피스(ANUDEM형): 구조선 앵커 %d셀", int(free.sum()))
surface = solve_min_curvature(constrained, guess).astype(np.float32)
logger.info("도엽 서피스(ANUDEM형): 가짜 웅덩이 %d셀 메움", _enforce_drainage(surface))
return surface
# ── ⑧ 다중해상도 (coarse → fine) ────────────────────────────────────────────
def build_multires(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""성긴 격자에서 풀고 점차 세밀화한다 — ANUDEM의 다중해상도 전략.
전체 형상은 성긴 격자에서 싸게 잡고, 세밀한 격자에서는 등고선 근처만 다듬는다.
한 해상도에서만 풀 때보다 넓은 밴드가 고르게 퍼지고 값싸게 수렴한다.
"""
from scipy.ndimage import zoom
surface: np.ndarray | None = None
for factor in (8, 4, 2, 1):
if factor == 1:
coarse = burned
else:
# 성긴 격자의 제약 — 블록 안 등고 라인의 평균 표고를 대표로 쓴다.
rows = burned.shape[0] // factor * factor
cols = burned.shape[1] // factor * factor
blocks = burned[:rows, :cols].reshape(rows // factor, factor, cols // factor, factor)
# 라인이 하나도 없는 블록은 NaN이 정상이라 경고를 삼킨다.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
coarse = np.nanmean(blocks, axis=(1, 3)).astype(np.float32)
level = build_distance(spec, coarse, features, cell_m * factor)
if surface is not None:
# 앞 단계 해를 지금 해상도로 올려 절반씩 섞는다 — 성긴 단계의 넓은 추세를
# 이어받되 이번 해상도의 등고선 정보를 덮지 않는다.
scale = (level.shape[0] / surface.shape[0], level.shape[1] / surface.shape[1])
upscaled = zoom(surface, scale, order=1)
free = ~np.isfinite(coarse)
level[free] = (level[free] + upscaled[free]) * 0.5
relax_laplace(level, np.isfinite(coarse), 8)
surface = level
assert surface is not None
if surface.shape != burned.shape: # 블록 자르기로 남은 가장자리 보정
scale = (burned.shape[0] / surface.shape[0], burned.shape[1] / surface.shape[1])
surface = zoom(surface, scale, order=1)
line = np.isfinite(burned)
surface[line] = burned[line]
return surface.astype(np.float32)
SHEET_METHOD_BUILDERS: dict[str, Callable[[Any, np.ndarray, Any, float], np.ndarray]] = {
"tin_sheet": build_tin_sheet,
"tin": build_tin,
"biharmonic": build_biharmonic,
"anudem": build_anudem,
"multires": build_multires,
"laplace": build_laplace,
}
@@ -0,0 +1,550 @@
"""도엽등고선 3D 서피스 — 1:5,000 수치지형도 등고선으로 DTM 격자를 만든다.
LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가 있어도 참고용으로
같이 만들어 두는 서피스다. 산출 형식은 LAS 파이프라인의 DTM과 완전히 같게 맞춘다
(`dtm_sheet.npz`: x/y/z/valid_mask) — 종·횡단·배수 세부설계가 쓰는
`build_surface_sampler(models_dir, "sheet", "dtm", smooth=False)`가 무수정으로 돈다.
절취 범위는 노선 XY bbox + `SHEET_SURFACE_MARGIN_M`(300m) 직사각형(사용자 확정),
격자는 `SHEET_SURFACE_GRID_M`(1m).
순서는 **2D 먼저, 메시는 맨 마지막**이다(2026-08-30 사용자 지시):
① 등고 라인을 격자에 굽고 ② 등고선 사이 거리 비례 보간으로 표고 격자를 만든 뒤
③ 폐합 등고선 안쪽(마루·웅덩이)을 바깥 사면 경사로 연장하고 ④ 라인을 고정한 채
완화(라플라스)해 등고 간격을 고르게 한다.
격자에서 뽑는 1m 등고선이 곧 2D 보간선이며, 메시(glb)는 그 격자의 표현일 뿐이다.
"""
import json
import logging
import time
from pathlib import Path
from typing import Any
import numpy as np
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
atomic_npz,
clip_and_compact_mesh,
grid_faces,
grid_vertices,
write_glb,
)
from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import (
SHEET_METHOD_BUILDERS,
SHEET_METHOD_LABELS,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds
from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
SHEET_SURFACE_METHODS,
SURFACE_MAX_PREVIEW_VERTICES,
SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M,
SURFACE_SMOOTHING_DTM_SIGMA_M,
SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH,
)
logger = logging.getLogger(__name__)
# 도엽 병합 산출물 파일명 (B04_PreProcess_Router_Watershed와 같은 값)
_CONTOUR_FILE = "도엽_등고선.geojson"
# 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다.
SHEET_SOURCE_FILTER = "sheet"
def _load_features_metric(
processed_dir: Path, epsg: int, filename: str = _CONTOUR_FILE
) -> list[dict[str, Any]]:
"""병합 도엽 레이어(WGS84)를 읽어 사업지 CRS(m)로 재투영한다."""
path = processed_dir / filename
if not path.exists():
logger.warning("도엽 서피스: 도엽 레이어 파일이 없습니다: %s", path)
return []
try:
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("도엽 서피스: 등고선 GeoJSON을 읽지 못했습니다: %s", path)
return []
features = data.get("features")
if not isinstance(features, list):
return []
transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
def _map(coords: Any) -> Any:
if not isinstance(coords, list):
return coords
if coords and isinstance(coords[0], (int, float)):
x, y = transformer.transform(coords[0], coords[1])
return [x, y, *coords[2:]]
return [_map(item) for item in coords]
converted: list[dict[str, Any]] = []
for feature in features:
geometry = feature.get("geometry") or {}
coordinates = _map(geometry.get("coordinates"))
if coordinates is None:
continue
converted.append(
{
"type": "Feature",
"properties": feature.get("properties") or {},
"geometry": {"type": geometry.get("type"), "coordinates": coordinates},
}
)
return converted
def _preview_mesh(
x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""프리뷰용 정점·면 — 정점 수가 상한을 넘으면 격자를 성기게 딴다.
격자를 그대로 잇는다. 한때 NURBS 곡면을 걸었으나(2026-08-30) DTM 스무딩이
들어오면서 곡면 적합이 이중으로 걸려 되돌렸다 — 스무딩은 표고 정본(npz)에서
한 번만 한다.
"""
stride = 1
while (len(x) // stride + 1) * (len(y) // stride + 1) > SURFACE_MAX_PREVIEW_VERTICES:
stride += 1
px, py = x[::stride], y[::stride]
pz, pv = z[::stride, ::stride], valid[::stride, ::stride]
vertices = grid_vertices(px, py, pz.astype(np.float64))
faces = grid_faces(len(py), len(px))
return clip_and_compact_mesh(vertices, faces, pv.reshape(-1))
def _write_smoothed(
models_dir: Path,
stem: str,
x: np.ndarray,
y: np.ndarray,
z: np.ndarray,
valid: np.ndarray,
bounds: np.ndarray,
) -> None:
"""`{stem}_smooth.npz`·`_smooth_preview.glb`를 만든다 — LAS DTM 스무딩과 같은 절차.
`B04_PreProcess_Engine_Smooth.smooth_dtm()`은 `TerrainContext`(라이다 발자국)를
받으므로 그대로 못 쓴다. 그래서 계수는 **config 값을 그대로** 두고 같은 두 단계만
옮긴다(2026-08-30 사용자 지시 — 계수 변경 금지):
① 무효 영역이 번지지 않는 정규화 가우시안 (`smoothing_dtm_sigma_meters`)
② C² 바이큐빅 B-spline 재평가 (`kx=ky=3`, `s=smoothing_dtm_spline_smooth`)를
`smoothing_dtm_preview_resolution_meters` 격자에서
화면 스무딩 토글과 확정 스냅샷이 이 파일을 찾으므로 이름 규칙을 지켜야 한다.
"""
from scipy.interpolate import RectBivariateSpline
from B04_PreProcess.B04_PreProcess_Engine_Smooth import _masked_gaussian_filter
cell_m = float(x[1] - x[0]) if len(x) > 1 else SHEET_SURFACE_GRID_M
sigma_pixels = SURFACE_SMOOTHING_DTM_SIGMA_M / cell_m if cell_m > 0 else 0.0
# 결측이 하나라도 있으면 스플라인 결과가 통째로 NaN이 된다(TIN·TIN 곡면은 볼록껍질
# 밖이 결측이다). 최근접 표고로 메워 적합하고 아래에서 원래 마스크로 되돌린다.
filled = z.astype(np.float64)
if not valid.all():
from scipy.ndimage import distance_transform_edt
_, (near_row, near_col) = distance_transform_edt(~valid, return_indices=True)
filled = filled[near_row, near_col]
z_pre = _masked_gaussian_filter(filled, valid, sigma_pixels)
try:
spline = RectBivariateSpline(y, x, z_pre, kx=3, ky=3, s=SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH)
except Exception as exc: # noqa: BLE001 — 스무딩 실패가 원본 산출을 막으면 안 된다
logger.warning("도엽 서피스(%s): 스무딩 스플라인 실패(%s) — 건너뜁니다.", stem, exc)
return
# 재평가 격자는 config의 프리뷰 해상도를 쓰되 원본보다 성기게 잡지 않는다 —
# 이 npz는 화면용이자 **스무딩 확정 시 종·횡단이 샘플링하는 표고 정본**이라,
# 정점 상한(화면 사정)으로 해상도를 깎으면 설계 정밀도가 같이 깎인다.
# 메시 정점 수는 _preview_mesh가 알아서 성기게 딴다.
step = max(SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, cell_m)
sx = np.arange(x[0], x[-1] + step * 0.5, step)
sy = np.arange(y[0], y[-1] + step * 0.5, step)
sz = np.asarray(spline(sy, sx), dtype=np.float32)
# 원본 유효 마스크를 최근접으로 옮겨 무효 영역을 그대로 지킨다.
col = np.clip(np.searchsorted(x, sx) - 1, 0, len(x) - 1)
row = np.clip(np.searchsorted(y, sy) - 1, 0, len(y) - 1)
svalid = valid[np.ix_(row, col)]
sz[~svalid] = np.nan
atomic_npz(
models_dir / f"{stem}_smooth.npz",
x=sx,
y=sy,
z=sz,
valid_mask=svalid,
bounds=bounds,
resolution=np.array([step], np.float32),
)
vertices, faces = _preview_mesh(sx, sy, np.nan_to_num(sz, nan=float(bounds[2, 0])), svalid)
write_glb(models_dir / f"{stem}_smooth_preview.glb", vertices, faces, bounds)
def _rasterize_contour_levels(spec: Any, features: list[dict[str, Any]]) -> np.ndarray:
"""등고 라인을 격자에 굽는다 — 셀 = 그 위를 지나는 라인의 표고, 그 외 NaN.
배수유역의 `rasterize_contours()`와 두 가지가 다르다(둘 다 서피스 품질 때문이다):
· `all_touched=False` — 스치는 셀까지 칠하면 라인이 2px 두께가 되고, 그 폭만큼
정확히 등고 표고인 **평탄 띠**가 생겨 사이 1m 등고선 간격이 찌그러진다
(2026-08-30 사용자 지적: 보간선이 등간격이 아님).
· 길이 필터 없음 — 봉우리 폐합 링 같은 짧은 등고선을 버리면 그 일대가 통째로
평평해진다. 배수유역은 노이즈를 버려야 하지만 지형면은 다 있어야 한다.
같은 셀을 두 표고가 지나면 낮은 쪽을 남긴다(배수유역과 같은 규칙).
"""
from rasterio.features import rasterize
from shapely.geometry import shape
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
ELEVATION_KEYS,
grid_transform,
iter_linestrings,
)
by_level: dict[float, list[Any]] = {}
for feature in features:
geometry = feature.get("geometry")
if not geometry:
continue
properties = feature.get("properties") or {}
elevation = next(
(float(properties[key]) for key in ELEVATION_KEYS if properties.get(key) is not None),
None,
)
if elevation is None:
continue
try:
parsed = shape(geometry)
except Exception: # noqa: BLE001
continue
for line in iter_linestrings(parsed):
by_level.setdefault(elevation, []).append(line)
burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32)
transform = grid_transform(spec)
for elevation in sorted(by_level, reverse=True):
stamp = rasterize(
[(line, 1) for line in by_level[elevation]],
out_shape=(spec.n_rows, spec.n_cols),
transform=transform,
fill=0,
dtype="uint8",
all_touched=False,
).astype(bool)
burned[stamp] = elevation
logger.info(
"도엽 서피스: 등고 라인 %d단을 격자에 굽어 %d셀",
len(by_level),
int(np.isfinite(burned).sum()),
)
return burned
def _resolve_enclosed_interiors(
burned: np.ndarray, present: list[float], surface: np.ndarray, cell_m: float, interval_m: float
) -> np.ndarray:
"""폐합 등고선 안쪽(봉우리·웅덩이)을 바깥 사면 경사로 연장한다 (in-place).
거리 보간은 "가장 가까운 서로 다른 두 라인 사이"를 채우므로, 마지막 등고선
안쪽에는 더 높은 라인이 없어 아래쪽 라인 쪽으로 끌려 **분화구처럼 파인다**.
등고선이 'ㅜ'에서 점점 짧아지다 사라지는 마루가 바로 이 자리다(2026-08-30
사용자 지적).
폐합 라인 내부에 다른 표고 제약이 하나도 없으면 그 안이 마루(바깥이 낮을 때)
또는 웅덩이(바깥이 높을 때)다. 바깥 사면의 국소 경사를 안쪽으로 연장하되
±(간격−0.5m)로 제한한다 — 다음 등고선이 없다는 사실과 모순되지 않는 범위다.
처리한 영역의 마스크를 반환한다 — 이어지는 완화에서 함께 고정해야 한다.
"""
from scipy.ndimage import binary_dilation, binary_fill_holes, distance_transform_edt, label
constrained = np.isfinite(burned)
band_cells = 8
limit = max(interval_m - 0.5, 0.5)
resolved = 0
handled = np.zeros(burned.shape, dtype=bool)
for level in present:
mask = burned == level
interior = binary_fill_holes(mask) & ~mask
if not interior.any():
continue
components, count = label(interior)
for component_id in range(1, count + 1):
component = components == component_id
if (constrained & component).any():
continue # 안에 다른 제약이 있으면 마루가 아니다(보통의 감싸는 링)
ring = binary_dilation(binary_fill_holes(mask) | mask) & ~component & ~mask
ring &= np.isfinite(surface)
if not ring.any():
continue
outside_mean = float(surface[ring].mean())
direction = 1.0 if outside_mean < level else -1.0
inner = distance_transform_edt(component, sampling=cell_m)
# 바깥 사면 경사 — 라인 밖 band_cells 이내 유효 셀의 (낙차 / 거리) 평균.
outer_distance = distance_transform_edt(~(mask | component), sampling=cell_m)
band = (outer_distance > 0) & (outer_distance <= band_cells * cell_m)
band &= np.isfinite(surface) & ~component & ~mask
if band.any():
slope = float(
np.mean(np.abs(level - surface[band].astype(np.float64)) / outer_distance[band])
)
else:
slope = 0.0
if slope > 1e-3:
offset = np.minimum(slope * inner[component], limit)
else:
peak = float(inner.max())
offset = (limit / 2.0) * (inner[component] / peak) if peak > 0 else 0.0
surface[component] = level + direction * offset
handled |= component
resolved += 1
if resolved:
logger.info("도엽 서피스: 폐합 등고선 내부 %d곳을 사면 경사로 연장", resolved)
return handled
def _write_method_model(
project_root: Path,
models_dir: Path,
spec: Any,
surface: np.ndarray,
method_key: str,
) -> dict[str, Any] | None:
"""방식 하나의 표고 격자를 npz·프리뷰 glb로 저장하고 등록용 dict를 만든다."""
# DtmGridSampler 규약에 맞춰 y 오름차순으로 뒤집어 저장한다.
x_coords = spec.cell_centers_x()
y_coords = spec.cell_centers_y()[::-1]
z_grid = surface[::-1, :].astype(np.float32)
valid_grid = np.isfinite(z_grid)
if not valid_grid.any():
logger.warning("도엽 서피스(%s): 유효 표고 셀이 없습니다.", method_key)
return None
source_filter = f"{SHEET_SOURCE_FILTER}_{method_key}"
stem = f"dtm_{source_filter}"
model_path = models_dir / f"{stem}.npz"
preview_path = models_dir / f"{stem}_preview.glb"
finite_z = z_grid[valid_grid]
# bounds를 npz에 같이 넣는다 — 등고선 API가 이 값을 화면 원점으로 쓴다. 없으면
# LAS structured.npz로 폴백해 메시(glb) 원점과 어긋난다(LAS 없는 설계는 아예 실패).
bounds = np.array(
[
[x_coords[0], x_coords[-1]],
[y_coords[0], y_coords[-1]],
[float(finite_z.min()), float(finite_z.max())],
]
)
atomic_npz(
model_path,
x=x_coords,
y=y_coords,
z=z_grid,
valid_mask=valid_grid,
bounds=bounds,
resolution=np.array([SHEET_SURFACE_GRID_M], np.float32),
)
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
write_glb(preview_path, vertices, faces, bounds)
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds)
return {
"model_type": "dtm",
"source_filter": source_filter,
"representation": "regular_grid",
"model_file_path": str(model_path.relative_to(project_root)).replace("\\", "/"),
"resolution_m": SHEET_SURFACE_GRID_M,
"generation_params": {
"source_filter": source_filter,
"representation": "regular_grid",
"source": "map_sheet_contours",
"interpolation": method_key,
"interpolation_label": SHEET_METHOD_LABELS.get(method_key, method_key),
"margin_m": SHEET_SURFACE_MARGIN_M,
},
"layers": [
{
"layer_name": f"{stem}_preview",
"geometry_type": "MESH",
"file_path": str(preview_path.relative_to(project_root)).replace("\\", "/"),
"file_format": "glb",
}
],
}
def build_sheet_surface_model(
project_root: Path,
processed_dir: Path,
models_dir: Path,
route_xy: np.ndarray,
epsg: int,
methods: list[str] | None = None,
) -> list[dict[str, Any]]:
"""도엽등고선으로 방식별 DTM npz·프리뷰 glb를 만들고 등록용 dict 목록을 돌려준다.
방식을 하나로 고르지 않고 전부 만들어 두는 이유: 문헌상 지형에 따라 우열이 갈려
화면에서 바꿔 보며 정해야 한다(2026-08-30 사용자 지시). 실패하면 빈 목록 —
호출측은 분석을 계속한다(도엽 미확보 지역 폴백).
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m).
"""
started = time.monotonic()
features = _load_features_metric(processed_dir, epsg)
if not features:
return []
x_min = float(np.min(route_xy[:, 0])) - SHEET_SURFACE_MARGIN_M
x_max = float(np.max(route_xy[:, 0])) + SHEET_SURFACE_MARGIN_M
y_min = float(np.min(route_xy[:, 1])) - SHEET_SURFACE_MARGIN_M
y_max = float(np.max(route_xy[:, 1])) + SHEET_SURFACE_MARGIN_M
spec = grid_spec_from_bounds(x_min, y_min, x_max, y_max, SHEET_SURFACE_GRID_M)
# ① 2D — 등고 라인을 격자에 굽는다(라인 셀 = 표고, 그 외 NaN).
burned = _rasterize_contour_levels(spec, features)
present = sorted(np.unique(burned[np.isfinite(burned)]).tolist())
if len(present) < 2:
logger.warning("도엽 서피스: 절취 범위 안에 등고선이 부족합니다.")
return []
# 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백.
interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0
selected = methods or list(SHEET_SURFACE_METHODS)
models: list[dict[str, Any]] = []
for method_key in selected:
builder = SHEET_METHOD_BUILDERS.get(method_key)
if builder is None:
logger.warning("도엽 서피스: 알 수 없는 보간 방식 %s — 건너뜁니다.", method_key)
continue
step_started = time.monotonic()
try:
# ② 2D 보간 — 여기서 나온 격자에서 1m 등고선을 뽑으므로 화면 등고선이 곧
# 2D 보간선이다. 메시(glb)는 그 격자의 표현일 뿐이다(사용자 지시).
surface = builder(spec, burned, features, spec.cell_m)
# ③ 폐합 등고선 안쪽(마루·웅덩이)은 방식과 무관하게 같은 규칙으로 채운다.
_resolve_enclosed_interiors(burned, present, surface, spec.cell_m, interval_m)
except Exception as exc: # noqa: BLE001 — 한 방식이 죽어도 나머지는 만든다
logger.warning("도엽 서피스(%s) 생성 실패: %s", method_key, exc)
continue
model = _write_method_model(project_root, models_dir, spec, surface, method_key)
if model is not None:
models.append(model)
logger.info("도엽 서피스(%s) 완료 (%.1fs)", method_key, time.monotonic() - step_started)
logger.info(
"도엽 서피스 생성 완료: %d×%d 격자, 등고 %d단, 방식 %d개 (%.1fs)",
spec.n_rows,
spec.n_cols,
len(present),
len(models),
time.monotonic() - started,
)
return models
def build_sheet_surface_from_route(
project_root: Path, processed_dir: Path, models_dir: Path
) -> list[dict[str, Any]]:
"""B03 업로드 계획노선 CSV를 찾아 방식별 도엽 서피스를 만든다. 없으면 빈 목록."""
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
if route_file is None:
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
return []
planned = read_planned_route_csv(route_file)
if planned is None or len(planned.vertices) < 2:
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
return []
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
return build_sheet_surface_model(
project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186
)
def run_sheet_surface_analysis(
project_root: Path,
route_csv_path: Path,
*,
on_progress: Any = None,
) -> dict[str, Any]:
"""LAS 없는 WF1 — 도엽 확보 후 도엽등고선 서피스만으로 분석 결과를 만든다.
반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환).
"""
from common_util.common_util_route_geometry import read_planned_route_csv
def _report(percent: int, stage: str, message: str) -> None:
if on_progress is not None:
on_progress(percent, stage, message)
stage_root = project_root / "B04_PreProcess"
processed_dir = stage_root / "processed"
models_dir = stage_root / "models"
processed_dir.mkdir(parents=True, exist_ok=True)
models_dir.mkdir(parents=True, exist_ok=True)
planned = read_planned_route_csv(route_csv_path)
if planned is None or len(planned.vertices) < 2:
raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}")
epsg = planned.epsg or 5186
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
bounds_dict = {
"x": [float(route_xy[:, 0].min()), float(route_xy[:, 0].max())],
"y": [float(route_xy[:, 1].min()), float(route_xy[:, 1].max())],
"z": [
float(min(v.z for v in planned.vertices)),
float(max(v.z for v in planned.vertices)),
],
}
# VWorld 지도·GIS 벡터·도엽 확보 — LAS 경로와 같은 공용 블록 (지연 import로 순환 회피)
_report(30, "download_maps", "VWorld 지도 및 수치지형도 도엽 확보 중")
from B04_PreProcess.B04_PreProcess_Engine import download_geodata
download_geodata(
project_root,
processed_dir,
bounds_dict,
route_csv_path.parent,
rebuild=False,
default_epsg=f"EPSG:{epsg}",
report=_report,
)
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg)
if not models:
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
_report(95, "saving", "결과 저장 중")
return {
"processed": {
"processed_file_path": str(
(processed_dir / _CONTOUR_FILE).relative_to(project_root)
).replace("\\", "/"),
"converted_file_path": None,
"point_count": int(len(route_xy)),
"bounds": {
"x_min": bounds_dict["x"][0],
"x_max": bounds_dict["x"][1],
"y_min": bounds_dict["y"][0],
"y_max": bounds_dict["y"][1],
},
"statistics": {
"min_z": bounds_dict["z"][0],
"max_z": bounds_dict["z"][1],
"mean_z": None,
},
},
"ground_summary": {},
"manifest": {"status": "sheet_only"},
"models": models,
}
+19 -1
View File
@@ -432,8 +432,26 @@ async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse |
"z_max": float(bounds[2, 1]),
}
# 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None).
# LAS 없이 설계한 프로젝트는 위 두 파일이 아예 없다(도엽등고선으로 만든
# 서피스가 정본). 그때는 확정 모델 격자의 bounds를 그대로 쓴다 — 없으면
# B05가 "지표면 범위 정보를 찾을 수 없습니다"로 3D를 못 띄운다(2026-08-30).
project_root = processed_dir.parent.parent
if bounds_payload is None and confirmed and confirmed.get("model_file_path"):
model_path = project_root / str(confirmed["model_file_path"])
if model_path.is_file():
with np.load(model_path) as stored:
if "bounds" in stored:
bounds = np.asarray(stored["bounds"], dtype=np.float64)
bounds_payload = {
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
"z_min": float(bounds[2, 0]),
"z_max": float(bounds[2, 1]),
}
# 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None).
route_bounds = planned_route_bounds(project_root, project_epsg_from_prj(project_root))
signature = "|".join(
@@ -239,7 +239,9 @@ async def _resolve(
requested = parse_pipe_points((payload or {}).get("points"))
signature = route_signature(context.vertices)
stored = load_pipe_points(context.stored_path, signature) if use_stored else None
stored = (
load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None
)
points = requested or stored or None
result = await asyncio.to_thread(_build, context.stored_path, context, points)
@@ -305,7 +307,10 @@ async def put_pipe_points(
context, detail, points, _ = resolved
signature = route_signature(context.vertices)
saved = await asyncio.to_thread(save_pipe_points, context.stored_path, signature, points)
# 좌표를 같이 남긴다 — 다른 선(B05 최적 경로)으로 읽어도 그 자리에 되놓는다.
saved = await asyncio.to_thread(
save_pipe_points, context.stored_path, signature, points, context.vertices
)
await asyncio.to_thread(
save_detail_basins, context.stored_path, _basin_features(context, detail, points)
)
+169 -18
View File
@@ -9,7 +9,10 @@ import {
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache";
import {
clearPreloadMark,
purgeOtherProjects,
} from "../A00_Common/b_asset_cache";
import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
@@ -40,6 +43,15 @@ const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
const DEFAULT_FILTER = "csf";
const DEFAULT_METHOD = "dtm";
const ROUTE_STAGE = ROUTES.B05_PROFILE;
// 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다.
const SHEET_METHOD_ORDER = [
"tin_sheet",
"tin",
"biharmonic",
"anudem",
"multires",
"laplace",
];
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -105,11 +117,17 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
if (guardedProjectId) {
const user = await fetchDashboardMe();
if (user.role !== "SYSTEM_ADMIN") {
const workflowState = await fetchWorkflowState(guardedProjectId).catch(() => undefined);
const surfaceStage = workflowState?.stages.find((stage) => stage.stage_no === 1);
const workflowState = await fetchWorkflowState(guardedProjectId).catch(
() => undefined,
);
const surfaceStage = workflowState?.stages.find(
(stage) => stage.stage_no === 1,
);
goToWorkflowStage(
guardedProjectId,
surfaceStage?.state === "COMPLETE" ? ROUTES.B05_PROFILE : ROUTES.B03_FILE_INPUT,
surfaceStage?.state === "COMPLETE"
? ROUTES.B05_PROFILE
: ROUTES.B03_FILE_INPUT,
);
return;
}
@@ -140,6 +158,58 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const viewer = createSurfacePointCloudViewer();
const terrainViewer = createSurfaceTerrainViewer();
const mapViewer = createSurfaceMapViewer();
// 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천,
// 2026-08-30). 모델 목록에 sheet/dtm이 있을 때만 별도 컨테이너로 보여준다.
const sheetViewer = createSurfaceTerrainViewer();
const sheetSection = document.createElement("section");
sheetSection.className = "b04-surface__sheet-section ui-sidebar-section";
const sheetTitle = document.createElement("h3");
sheetTitle.className = "b04-surface__panel-title";
sheetTitle.textContent = L("B04_Surface_SheetSurface");
// 보간 방식 전환 줄 — 어느 방식이 이 지형에 맞는지 눈으로 비교해 정한다
// (2026-08-30 사용자 지시). 버튼 목록은 실제 생성된 모델에서 만든다.
const sheetToolbar = document.createElement("div");
sheetToolbar.className = "b04-surface__sheet-toolbar";
const sheetMethodButtons = new Map<string, HTMLButtonElement>();
let sheetMethod = "";
function selectSheetMethod(method: string): void {
sheetMethod = method;
for (const [key, button] of sheetMethodButtons) {
button.classList.toggle("is-active", key === method);
}
const projectId = getProjectId();
if (!projectId) return;
sheetViewer.setSelection(`sheet_${method}`, "dtm");
sheetViewer.render(projectId, models);
}
// 라이다 지표면 겹쳐 보기 — 확정 필터의 DTM을 반투명으로 얹는다.
const lidarLabel = document.createElement("label");
lidarLabel.className = "toggle-label toggle-button b04-surface__sheet-lidar";
const lidarCheck = document.createElement("input");
lidarCheck.type = "checkbox";
lidarLabel.append(
lidarCheck,
document.createTextNode(` ${L("B04_Surface_SheetLidar")}`),
);
lidarCheck.addEventListener("change", () => {
void sheetViewer
.showOverlay(
lidarCheck.checked ? filterGroup.select.value : "",
"dtm",
terrainViewer.isSmoothingEnabled(),
)
.then((loaded) => {
if (lidarCheck.checked && !loaded) {
showToast(L("B04_Surface_SheetLidar_Missing"), "warning");
lidarCheck.checked = false;
}
});
});
sheetSection.append(sheetTitle, sheetToolbar, sheetViewer.root);
sheetSection.hidden = true;
let syncingCamera = false;
viewer.onCameraChange((state) => {
@@ -200,14 +270,20 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const actionRow = document.createElement("div");
actionRow.className = "ui-sidebar-actions";
actionRow.append(confirmButton, resetButton);
panel.append(inputGroup, analysisGroup, displayGroup, viewer.controlsGroup, actionRow);
panel.append(
inputGroup,
analysisGroup,
displayGroup,
viewer.controlsGroup,
actionRow,
);
const viewers = document.createElement("div");
viewers.className = "b04-surface__viewers";
viewers.append(viewer.root, terrainViewer.root);
const workspace = document.createElement("div");
workspace.className = "b04-surface__workspace";
workspace.append(statusBox, viewers, mapViewer.root);
workspace.append(statusBox, viewers, sheetSection, mapViewer.root);
let workflowState: WorkflowState | undefined;
const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
@@ -229,7 +305,8 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (layoutProjectId) goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
if (layoutProjectId)
goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
@@ -263,7 +340,11 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
return;
}
const variant =
status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning";
status.status === "completed"
? "success"
: status.status === "failed"
? "danger"
: "warning";
statusBox.append(
createTag(`${status.progress_percent}%`, variant),
document.createTextNode(status.message),
@@ -280,7 +361,9 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
inputInfo.append(
buildInfoLine(
"좌표계",
selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null,
selectedInputFile.crs_epsg
? `EPSG:${selectedInputFile.crs_epsg}`
: null,
),
buildInfoLine(
"크기",
@@ -289,7 +372,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
: `${selectedInputFile.file_size_mb.toFixed(2)} MB`,
),
buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()),
buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()),
buildInfoLine(
"표시 포인트 수",
pointCloud?.sampled_count.toLocaleString(),
),
buildInfoLine("높이 범위", heightRange),
);
}
@@ -328,7 +414,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
function updateSelectedModel(): void {
const projectId = getProjectId();
if (!projectId) return;
terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value);
terrainViewer.setSelection(
filterGroup.select.value,
methodGroup.select.value,
);
terrainViewer.render(projectId, models);
confirmButton.disabled = !findSelectedModel();
}
@@ -339,14 +428,20 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
showLoadingOverlay();
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
pointCloud = await fetchSurfacePointCloud(
projectId,
filterGroup.select.value,
);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
renderInputInfo();
} catch (error) {
pointCloud = null;
viewer.render(null);
const detail = error instanceof Error ? error.message : "지면 포인트 조회에 실패했습니다.";
const detail =
error instanceof Error
? error.message
: "지면 포인트 조회에 실패했습니다.";
showToast(detail, "error");
} finally {
hideLoadingOverlay();
@@ -366,7 +461,8 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
if (confirmed.model_id) {
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
if (confirmed.source_filter)
filterGroup.select.value = confirmed.source_filter;
if (confirmed.method) methodGroup.select.value = confirmed.method;
terrainViewer.setSmoothing(confirmed.smooth ?? false);
}
@@ -376,7 +472,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
renderStatus(status);
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
pointCloud = await fetchSurfacePointCloud(
projectId,
filterGroup.select.value,
);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
// 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다.
@@ -388,6 +487,51 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
renderInputInfo();
updateSelectedModel();
// 도엽등고 3D 서피스 — sheet_* 모델이 있으면 별도 컨테이너로 보여준다.
// 보간 방식마다 모델이 하나씩 있으므로 버튼으로 갈아 끼운다.
const sheetMethods = models
.filter(
(model) =>
model.model_type.toLowerCase() === "dtm" &&
getModelFilter(model).startsWith("sheet_"),
)
.map((model) => ({
key: getModelFilter(model).slice("sheet_".length),
label:
typeof model.generation_params?.interpolation_label === "string"
? (model.generation_params.interpolation_label as string)
: getModelFilter(model).slice("sheet_".length),
}))
// 모델 목록은 최신순이라 버튼이 뒤섞인다 — 정의 순서로 고정한다.
.sort(
(a, b) =>
(SHEET_METHOD_ORDER.indexOf(a.key) + 1 || 99) -
(SHEET_METHOD_ORDER.indexOf(b.key) + 1 || 99),
);
sheetSection.hidden = sheetMethods.length === 0;
if (sheetMethods.length) {
sheetToolbar.replaceChildren();
sheetMethodButtons.clear();
for (const method of sheetMethods) {
const button = document.createElement("button");
button.type = "button";
button.className = "b04-surface__sheet-method";
button.textContent = method.label;
button.addEventListener("click", () => selectSheetMethod(method.key));
sheetToolbar.append(button);
sheetMethodButtons.set(method.key, button);
}
// 스무딩 드롭다운과 라이다 토글은 오른쪽 끝에 함께 둔다.
sheetViewer.smoothingField.classList.add("b04-surface__sheet-smoothing");
sheetToolbar.append(sheetViewer.smoothingField, lidarLabel);
sheetViewer.setSmoothing(true);
selectSheetMethod(
sheetMethods.some((method) => method.key === sheetMethod)
? sheetMethod
: sheetMethods[0].key,
);
}
}
async function onB04_Surface_Confirm_Click(): Promise<void> {
@@ -414,14 +558,20 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
L("B04_Surface_Confirm_Success")
.replace("{filter}", filterGroup.select.value)
.replace("{method}", methodGroup.select.value)
.replace("{smoothing}", terrainViewer.isSmoothingEnabled() ? "ON" : "OFF"),
.replace(
"{smoothing}",
terrainViewer.isSmoothingEnabled() ? "ON" : "OFF",
),
"success",
);
await loadProjectData(projectId);
enableRouteStep(projectId);
goToWorkflowStage(projectId, ROUTE_STAGE);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed");
const detail =
error instanceof Error
? error.message
: L("B04_Surface_Confirm_Failed");
showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
@@ -446,7 +596,8 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
inputSelect.addEventListener("change", () => {
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
selectedInputFile =
inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
renderInputInfo();
});
filterGroup.select.addEventListener("change", () => {
@@ -793,3 +793,65 @@
grid-template-columns: 1fr;
}
}
/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */
.b04-surface__sheet-section {
margin: 0 var(--spacing-24) var(--spacing-16);
padding: var(--spacing-16);
box-sizing: border-box;
}
.b04-surface__sheet-section > .terrain-model-group {
margin-top: var(--spacing-12);
}
/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */
.b04-surface__sheet-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
margin: var(--spacing-12) 0;
}
.b04-surface__sheet-method {
padding: 4px 10px;
font-size: var(--text-caption);
color: var(--color-text-body);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
cursor: pointer;
}
.b04-surface__sheet-method:hover {
border-color: var(--color-primary);
}
.b04-surface__sheet-method.is-active {
color: var(--color-on-primary, #fff);
background: var(--color-primary);
border-color: var(--color-primary);
}
.b04-surface__sheet-lidar {
margin-left: auto;
}
/* 도엽 서피스 스무딩 드롭다운 — 방식 버튼 줄 오른쪽 끝 */
.b04-surface__sheet-smoothing {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-caption);
}
.b04-surface__sheet-smoothing .b04-surface__select {
width: auto;
min-width: 96px;
}
.b04-surface__sheet-toolbar .b04-surface__sheet-lidar {
margin-left: 0;
}
+194 -52
View File
@@ -6,7 +6,10 @@ import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
import type {
SurfaceBounds,
SurfaceModelSummary,
} from "./B04_PreProcess_Api_Fetch";
import {
bindCursorPivotControls,
bindSurfaceViewerTheme,
@@ -18,6 +21,10 @@ import {
type SurfaceCameraState,
} from "./B04_PreProcess_UI_Camera";
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다. 조각이 많은 지형에서
* 라벨이 수백 개가 되면 매 프레임 위치 재계산이 화면을 멈춰 세운다(2026-08-30). */
const MAX_CONTOUR_LABELS = 40;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
@@ -31,6 +38,12 @@ export interface SurfaceTerrainViewer {
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
setReferenceBounds: (bounds: SurfaceBounds) => void;
setSelection: (sourceFilter: string, method: string) => void;
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
showOverlay: (
sourceFilter: string,
method: string,
smooth: boolean,
) => Promise<boolean>;
applyCameraState: (state: SurfaceCameraState) => void;
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
@@ -225,7 +238,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scene.background = new THREE.Color(color);
});
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
const camera = new THREE.PerspectiveCamera(
SURFACE_CAMERA_FOV,
1,
0.01,
100000,
);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
@@ -262,7 +280,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
function disposeObject(obj: THREE.Object3D) {
obj.traverse((child) => {
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
const renderable = child as
THREE.Mesh | THREE.Points | THREE.LineSegments;
renderable.geometry?.dispose();
const material = renderable.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
@@ -278,6 +297,76 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
}
// ── 겹쳐 보기 메시 ─────────────────────────────────────────────────────────
// 도엽등고 서피스 위에 라이다 지표면을 겹쳐 두 지형을 눈으로 대조한다
// (2026-08-30 사용자 지시). 본 메시와 카메라·좌표계를 공유하므로 같은 자리에 겹친다.
let overlayMesh: THREE.Object3D | null = null;
let overlayGeneration = 0;
function clearOverlay() {
if (overlayMesh) {
scene.remove(overlayMesh);
disposeObject(overlayMesh);
overlayMesh = null;
}
}
async function loadOverlay(
projectId: string,
models: readonly SurfaceModelSummary[],
sourceFilter: string,
method: string,
smooth: boolean,
): Promise<boolean> {
const generation = ++overlayGeneration;
clearOverlay();
const match = models.find((model) => {
const configured = model.generation_params?.source_filter;
return (
model.model_type.toLowerCase() === method.toLowerCase() &&
typeof configured === "string" &&
configured.toLowerCase() === sourceFilter.toLowerCase()
);
});
if (!match) return false;
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${match.id}/preview?smooth=${smooth}`;
try {
const buffer = await fetchCachedBytes(projectId, url);
if (generation !== overlayGeneration) return false;
return await new Promise<boolean>((resolve) => {
new GLTFLoader().parse(
buffer,
"",
(gltf) => {
if (generation !== overlayGeneration) {
disposeObject(gltf.scene);
resolve(false);
return;
}
// 겹친 두 면을 구분하려고 반투명 단색으로 덮어씌운다.
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new THREE.MeshStandardMaterial({
color: 0x60a5fa,
transparent: true,
opacity: 0.45,
side: THREE.DoubleSide,
flatShading: false,
});
}
});
overlayMesh = gltf.scene;
scene.add(gltf.scene);
resolve(true);
},
() => resolve(false),
);
});
} catch {
return false;
}
}
function clearContours() {
while (contourGroup.children.length > 0) {
const child = contourGroup.children[0];
@@ -303,8 +392,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const fitCamera = (object: THREE.Object3D) => {
const { span } = getFitParams(object);
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : span * 1.2;
const aspect =
viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
const distance = referenceBounds
? getTopFitDistance(referenceBounds, aspect)
: span * 1.2;
controls.target.set(0, 0, 0);
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
camera.position.set(0, distance, distance * TOP_VIEW_TILT);
@@ -366,12 +458,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
const match = currentModelsList.find((m) => {
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
const typeMatches =
m.model_type.toLowerCase() === activeMethod.toLowerCase();
const configuredFilter = m.generation_params?.source_filter;
const filterMatches =
(typeof configuredFilter === "string" &&
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
Boolean(
m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()),
);
return typeMatches && filterMatches;
});
@@ -382,7 +477,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
const modelId = match.id;
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
const isSmooth =
(activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
currentModelId = modelId;
currentModelSmooth = isSmooth;
const generation = ++loadGeneration;
@@ -429,7 +525,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.side = THREE.DoubleSide;
child.material.vertexColors = child.geometry.hasAttribute("color");
child.material.vertexColors =
child.geometry.hasAttribute("color");
}
});
gltf.scene.visible = surfCheck.checked;
@@ -442,7 +539,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
},
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
statusSpan.textContent =
"3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
@@ -498,6 +596,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01).
const majorPoints: THREE.Vector3[] = [];
const minorPoints: THREE.Vector3[] = [];
const labelCandidates: {
level: number;
position: THREE.Vector3;
length: number;
}[] = [];
data.contours.forEach((c: any) => {
if (c.level < minH) minH = c.level;
@@ -512,47 +615,63 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
bucket.push(points[i], points[i + 1]);
}
// 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다
// 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다
// (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다.
if (isMajor && points.length > 4) {
const labelPos = points[Math.floor(points.length / 2)];
const labelDiv = document.createElement("div");
labelDiv.className = "contour-label";
labelDiv.innerText = `${Math.round(c.level)}m`;
labelDiv.style.position = "absolute";
labelDiv.style.background = "rgba(255, 255, 255, 0.85)";
labelDiv.style.border = "1px solid #d97706";
labelDiv.style.color = "#b45309";
labelDiv.style.padding = "1px 4px";
labelDiv.style.borderRadius = "3px";
labelDiv.style.fontSize = "9px";
labelDiv.style.fontWeight = "bold";
labelDiv.style.pointerEvents = "none";
labelDiv.style.zIndex = "5";
labelDiv.style.transform = "translate(-50%, -50%)";
(labelDiv as any).__updateLabelPos = () => {
if (!contourCheck.checked) {
labelDiv.style.display = "none";
return;
}
const proj = labelPos.clone().project(camera);
const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth;
const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight;
if (proj.z > 1) {
labelDiv.style.display = "none";
} else {
labelDiv.style.display = "block";
labelDiv.style.left = `${x}px`;
labelDiv.style.top = `${y}px`;
}
};
viewerArea.appendChild(labelDiv);
labelElements.push(labelDiv);
labelsDirty = true;
let length = 0;
for (let i = 0; i < points.length - 1; i++) {
length += points[i].distanceTo(points[i + 1]);
}
labelCandidates.push({
level: c.level,
position: points[Math.floor(points.length / 2)],
length,
});
}
});
labelCandidates.sort((a, b) => b.length - a.length);
for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) {
const labelPos = candidate.position;
const labelDiv = document.createElement("div");
labelDiv.className = "contour-label";
labelDiv.innerText = `${Math.round(candidate.level)}m`;
labelDiv.style.position = "absolute";
labelDiv.style.background = "rgba(255, 255, 255, 0.85)";
labelDiv.style.border = "1px solid #d97706";
labelDiv.style.color = "#b45309";
labelDiv.style.padding = "1px 4px";
labelDiv.style.borderRadius = "3px";
labelDiv.style.fontSize = "9px";
labelDiv.style.fontWeight = "bold";
labelDiv.style.pointerEvents = "none";
labelDiv.style.zIndex = "5";
labelDiv.style.transform = "translate(-50%, -50%)";
(labelDiv as any).__updateLabelPos = () => {
if (!contourCheck.checked) {
labelDiv.style.display = "none";
return;
}
const proj = labelPos.clone().project(camera);
const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth;
const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight;
if (proj.z > 1) {
labelDiv.style.display = "none";
} else {
labelDiv.style.display = "block";
labelDiv.style.left = `${x}px`;
labelDiv.style.top = `${y}px`;
}
};
viewerArea.appendChild(labelDiv);
labelElements.push(labelDiv);
labelsDirty = true;
}
// 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다.
[
{ points: minorPoints, color: 0xf59e0b },
@@ -632,18 +751,26 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
if (terrainMesh && terrainMesh.visible) {
scaleBar.hidden = false;
const dist = camera.position.distanceTo(controls.target);
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
const metersPerPixel = targetPlaneMetersPerPixel(
dist,
viewerArea.clientHeight,
);
const roughMeters = 100 * metersPerPixel;
const prettyMeters = niceScaleDistance(roughMeters);
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
scaleLabel.textContent =
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
prettyMeters >= 1000
? `${(prettyMeters / 1000).toFixed(0)} km`
: `${prettyMeters} m`;
} else {
scaleBar.hidden = true;
}
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
if (
labelsDirty ||
!cameraMatrixSnapshot.equals(camera.matrixWorldInverse)
) {
labelsDirty = false;
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
labelElements.forEach((label) => {
@@ -685,7 +812,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
intervalForm.addEventListener("submit", async (e) => {
e.preventDefault();
const interval = Number(intervalInput.value);
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null)
return;
intervalSubmit.disabled = true;
await loadSelectedContours(currentModelId, currentModelSmooth, true);
intervalSubmit.disabled = false;
@@ -724,6 +852,19 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
activeMethod = method;
syncSmoothingSupport();
},
showOverlay(sourceFilter, method, smooth) {
if (!sourceFilter) {
clearOverlay();
return Promise.resolve(false);
}
return loadOverlay(
currentProjectId,
currentModelsList,
sourceFilter,
method,
smooth,
);
},
applyCameraState,
onCameraChange(listener) {
cameraListener = listener;
@@ -739,7 +880,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
syncSmoothingSupport();
},
setContourInterval(interval) {
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
if (Number.isFinite(interval) && interval > 0)
intervalInput.value = String(interval);
},
getContourInterval() {
return Number.parseFloat(intervalInput.value);
+16 -21
View File
@@ -31,7 +31,7 @@ from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_PIPE,
PIPE_FACILITY_REVET,
PipePoint,
parse_pipe_points,
load_pipe_points_file,
pipe_anchor_clearances,
route_signature,
)
@@ -111,9 +111,17 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
계획선 정착과 구조물 측점 생성이 **같은 한 번의 읽기**를 쓴다 — 따로 읽으면 계획선이
물린 자리와 측점 자리가 어긋난다.
관 지점 파일에는 저장 당시 노선 지문이 함께 있다 — 노선이 바뀌었으면 버린다
(옛 노선의 배관 자리로 계획선을 앉히면 전부 어긋난다). 파일이 없거나 못 읽으면 빈 목록.
관 지점 파일에는 저장 당시 노선 지문이 함께 있다. 지문이 달라도 저장분에 좌표가 있으면
**이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 CSV)과 여기 계획선은
같은 자리를 지나면서 연장이 다르다(실측 350.11m vs 354.83m). 그래서 지문은 거의 항상
달랐고 관이 통째로 빠졌다(2026-08-30 사용자 지적). 좌표가 없는 구 저장분만 버린다.
"""
vertices = [
RouteVertex(
x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0
)
for p in polyline
]
path = (
project_root
/ "B04_PreProcess"
@@ -123,25 +131,12 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
)
if not path.is_file():
return []
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("B05 계획선: 관 지점 파일을 읽지 못했습니다 (%s)", path)
points = load_pipe_points_file(path, route_signature(vertices), vertices)
if points is None:
# 계획선 정착과 구조물 측점이 함께 빠지므로 남긴다(침묵 실패 금지).
logger.warning("B05 계획선: 저장된 관 지점을 쓰지 못했습니다 (좌표 없는 구 저장분).")
return []
vertices = [
RouteVertex(
x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0
)
for p in polyline
]
if str(document.get("route_signature") or "") != route_signature(vertices):
# 계획선 정착과 구조물 측점이 함께 빠지므로 버린 건수를 남긴다(침묵 실패 금지).
logger.warning(
"B05 계획선: 노선 지문이 달라 저장된 관 지점 %d건을 쓰지 않습니다.",
len(document.get("points") or []),
)
return []
return parse_pipe_points(document.get("points"))
return points
def resolve_extra_stations(
+32 -19
View File
@@ -23,6 +23,7 @@ from config.config_system import (
FOREST_ROAD_MAX_GRADE,
FOREST_ROAD_MIN_CURVE_R_M,
ROUTE_DEFAULT_GRADE_CLASS,
ROUTE_DIRECT_LINK_CELL_FACTOR,
ROUTE_GRID_RES_M,
ROUTE_MAX_COST_CELLS,
ROUTE_MAX_GRADE,
@@ -390,31 +391,43 @@ def solve_optimal_route(
full_path_grid: list[tuple[int, int]] = []
segment_bounds: list[dict[str, Any]] = []
direct_link_max_m = ROUTE_DIRECT_LINK_CELL_FACTOR * target_res
for i in range(len(sequence) - 1):
pt_start = sequence[i]
pt_end = sequence[i + 1]
r_s, c_s, _ = get_grid_indices(pt_start)
r_e, c_e, _ = get_grid_indices(pt_end)
segment = single_segment_dijkstra(
r_s,
c_s,
r_e,
c_e,
x_coords_sub,
y_coords_sub,
z_grid_sub,
valid_mask_sub,
dz_dx,
dz_dy,
ap_list,
weights,
max_grade,
target_res,
min_curve_radius_m,
max_uphill_grade,
max_downhill_grade,
)
# 제어점 쌍이 문턱보다 가까우면 격자 탐색 없이 직결한다. 두 끝은 어차피
# 원좌표로 되박히므로 이 구간 평면은 원청 계획노선 그대로 보존된다.
# 같은 칸에 스냅돼도 두 항목을 유지해 정점이 합쳐져 사라지지 않게 한다.
# 경사·곡선반경 제약은 이 구간에선 경고(curve_warning_segments)로만 남는다.
if (
math.hypot(pt_end["x"] - pt_start["x"], pt_end["y"] - pt_start["y"])
<= direct_link_max_m
):
segment = [(r_s, c_s), (r_e, c_e)]
else:
segment = single_segment_dijkstra(
r_s,
c_s,
r_e,
c_e,
x_coords_sub,
y_coords_sub,
z_grid_sub,
valid_mask_sub,
dz_dx,
dz_dy,
ap_list,
weights,
max_grade,
target_res,
min_curve_radius_m,
max_uphill_grade,
max_downhill_grade,
)
if not segment:
fp_note = "·금지구역(FP)" if fp_list else ""
raise ValueError(
@@ -96,6 +96,7 @@ async def sync_uphill_overrides_into_designs(
update_cross_section_design,
)
from B06_Section.B06_Section_Router import _read_cross_design_inputs
from B06_Section.B06_Section_Router_Design import ford_drop_at, ford_surface_drops
if not overrides:
return
@@ -108,6 +109,7 @@ async def sync_uphill_overrides_into_designs(
options = longitudinal["data"].get("options")
if isinstance(options, dict):
stored_standard = options.get("standard_cross_section")
ford_drops = ford_surface_drops(Path(project_root))
for record in designs:
chainage = round(float(record["chainage_m"]), 3)
side = by_chainage.get(chainage)
@@ -136,6 +138,7 @@ async def sync_uphill_overrides_into_designs(
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", True)),
ditch_enabled=design.get("ditch_enabled"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
)
next_design["status"] = design.get("status", "provisional")
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
+30 -10
View File
@@ -187,6 +187,10 @@ export interface FacilityOptionsForm {
/** 독립 기슭막이 좌·우 칸의 이식 자리 — 배관 유입구·유출구 자리와 같은 몫이다. */
revetInletSlot: HTMLElement;
revetOutletSlot: HTMLElement;
/** 세월교·BOX암거 날개벽 칸의 이식 자리 — 세월교 유입·유출 측벽을 고르면 이 칸이
* 선다(2026-08-30 사용자 지시 1: 배관 측점·기슭막이와 같게). */
wingInSlot: HTMLElement;
wingOutSlot: HTMLElement;
/** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */
inletStructure: () => InletStructureKind;
setInletStructure: (value: InletStructureKind) => void;
@@ -356,7 +360,9 @@ export function createFacilityOptionsForm(
// ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자
// 지시) 수량만 따로 받는다.
const fordCount = numberInput("1", "1", "련");
const fordRow = grid(labeled("수량 (련)", fordCount));
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3).
// 련은 정수라 소수 자릿수를 두지 않는다.
const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0)));
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시).
// 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음).
@@ -365,20 +371,24 @@ export function createFacilityOptionsForm(
const fordWidth = numberInput("0.1");
const fordHeight = numberInput("0.01");
const fordWidthRow = grid(
labeled("월류 폭 (m)", fordWidth),
labeled("월류 높이 (m)", fordHeight),
labeled("월류 폭 (m)", stepper(fordWidth, 0.1)),
labeled("월류 높이 (m)", stepper(fordHeight, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const fordSlope = numberInput("0.1");
fordSlope.placeholder = "노면 기울기";
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", fordSlope));
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)));
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let fordMinDepthM: number | null = null;
/** 직전에 자동으로 채워 넣은 필요 수심(m) — 칸이 아직 그 값이면 "자동"으로 보고
* 월류 폭이 바뀔 때마다 계산값을 계속 따라가게 한다. 사용자가 다른 값을 넣으면
* 그때부터 그 값이 이긴다(2026-08-30 사용자: 폭을 바꿔도 높이가 안 따라온다). */
let fordAutoDepthM: number | null = null;
function syncFordSummary(): void {
const section =
@@ -387,10 +397,14 @@ export function createFacilityOptionsForm(
: null;
fordMinDepthM = section ? section.depthM : null;
if (section) {
// 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나 그보다 작으면 계산값으로 채운다.
// 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전
// 자동값 그대로면 계산값으로 다시 채운다.
const min = Number(section.depthM.toFixed(2));
const current = Number.parseFloat(fordHeight.value);
if (!Number.isFinite(current) || current < min) fordHeight.value = min.toFixed(2);
const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005;
if (!Number.isFinite(current) || current < min || untouched)
fordHeight.value = min.toFixed(2);
fordAutoDepthM = min;
}
if (designFlowM3s === null) {
fordSummary.textContent =
@@ -423,8 +437,14 @@ export function createFacilityOptionsForm(
emit();
});
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
pipeRow,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
inletGroup.root,
outletGroup.root,
extraGroup.root,
@@ -434,10 +454,6 @@ export function createFacilityOptionsForm(
boxWrap,
wingInFields.root,
wingOutFields.root,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
);
let current: PipeFacility | null = null;
@@ -537,6 +553,8 @@ export function createFacilityOptionsForm(
extraSlot,
revetInletSlot: revetInlet.slot,
revetOutletSlot: revetOutlet.slot,
wingInSlot: wingInFields.slot,
wingOutSlot: wingOutFields.slot,
setRevetSideLabels(labels) {
revetSideLabels = labels;
syncVisibility();
@@ -605,6 +623,8 @@ export function createFacilityOptionsForm(
fordCount.value = isFord ? text("pipe_count") : "";
fordWidth.value = text("ford_width_m");
fordHeight.value = text("ford_height_m");
// 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다.
fordAutoDepthM = null;
fordSlope.value = text("ford_slope_pct");
revetSide.value = text("side") || "양쪽";
const spread = legacyRevetOptions(options);
@@ -84,7 +84,9 @@ export function numberInput(step: string, min = "0", placeholder = ""): HTMLInpu
/** 스텝 묶음 입력에 붙일 일련번호 — 라벨이 가리킬 대상을 명시하는 데 쓴다. */
let stepperSeq = 0;
export function stepper(input: HTMLInputElement, stepM: number): HTMLElement {
/** `decimals` — 표시 자릿수. 련·각도처럼 정수로 세는 칸은 0을 준다(2026-08-30 사용자:
* 세월교 상세도 같은 양식으로 맞추되 "1.0련"·"45.0°"로 보이면 안 된다). */
export function stepper(input: HTMLInputElement, stepM: number, decimals = 1): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b05-structure__stepper";
// 라벨이 이 입력을 가리키게 id를 붙인다. 없으면 <label>의 암묵 대상이 묶음의
@@ -95,11 +97,12 @@ export function stepper(input: HTMLInputElement, stepM: number): HTMLElement {
const current = Number.parseFloat(input.value);
const min = Number.parseFloat(input.min);
const floor = Number.isFinite(min) ? min : 0;
const grain = 10 ** decimals;
const next = Math.max(
floor,
Math.round(((Number.isFinite(current) ? current : 0) + delta) * 10) / 10,
Math.round(((Number.isFinite(current) ? current : 0) + delta) * grain) / grain,
);
input.value = next.toFixed(1);
input.value = next.toFixed(decimals);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
};
@@ -385,6 +388,9 @@ const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45"
interface WingFields {
root: HTMLFieldSetElement;
/** 횡단도에서 그 측 날개벽·측벽을 고를 때 칸을 세우는 자리(B06 전용, 2026-08-30).
* 배관 유입구·유출구 칸의 `adjust-slot`과 같은 몫이다. */
slot: HTMLElement;
inputs: Array<HTMLInputElement | HTMLSelectElement>;
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
@@ -403,10 +409,16 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle;
const heightField = labeled("짧은쪽 높이 (m)", height);
const dimsRow = grid(labeled("길이 (m)", length), labeled("각도 (°)", angle));
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1));
const dimsRow = grid(
labeled("길이 (m)", stepper(length, 0.1)),
labeled("각도 (°)", stepper(angle, 5, 0)),
);
const box = group(title);
box.body.append(grid(labeled("설치", install), heightField), dimsRow);
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(grid(labeled("설치", install), heightField), dimsRow, slot);
function syncVisibility(): void {
const off = install.value === "없음";
@@ -419,6 +431,7 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
return {
root: box.root,
slot,
inputs: [install, height, length, angle],
syncVisibility,
write(options) {
@@ -344,6 +344,15 @@
font-size: var(--text-caption);
}
/* 물넘이·세월교 개략 단면 결과 — 읽어 보는 안내문이라 입력 칸보다 작게 둔다
(2026-08-30 사용자: 글자가 너무 크다). */
.b05-drainage__facility-note {
margin: 0;
font-size: 10px;
line-height: 1.4;
opacity: 0.75;
}
/* 스텝 묶음과 그 안의 버튼·숫자칸도 같은 높이로 선다. */
.b05-structure__stepper {
height: var(--b05-control-h);
+7
View File
@@ -252,6 +252,9 @@ export interface FordSet {
pipe_count: number;
/** 구체의 도로 진행 방향 길이(m) = 월류 폭. 기준 측점 전후로 절반씩 걸친다. */
span_m: number;
/** 월류 높이(m) — 구체 위 노면이 이만큼 낮게 앉는다. 계획고를 통째로 내려 잡으므로
* 측벽·바닥판·절성토 면적이 함께 따라간다(2026-08-30 사용자 확정). 0 = 내리지 않음. */
overflow_depth_m: number;
slab_thickness_m: number;
wall_thickness_m: number;
min_cover_m: number;
@@ -370,6 +373,10 @@ export interface CrossDesign {
lateralM: number;
slopeM: number;
};
/** 세월교 월류 높이만큼 계획고를 내려 잡은 양(m) — 이 값이 있으면 `design_elevation_m`·
* `design_line`·절성토 면적이 **이미 내려간 값**이다. 프론트는 이 값으로 "월류가
* 없었다면" 노면을 점선으로 되그린다(2026-08-30 사용자 확정). */
surface_drop_m?: number;
/** 세월교 측벽 조작값(유입·유출) — 높이·좌우·상하(2026-08-25). */
ford_adjust?: StoredFordAdjust;
/** BOX암거 구체 조작값(좌·우 끝) — 길이·표고(2026-08-25). */
@@ -310,12 +310,17 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
depth = _number(values.get("ford_height_m"), None)
return {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"pipe_count": max(int(count), 1) if count else 1,
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
# 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른
# 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다
# (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다.
"overflow_depth_m": depth if depth and depth > 0 else 0.0,
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
"wall_thickness_m": FORD_WALL_THICKNESS_M,
"min_cover_m": MIN_PIPE_COVER_M,
+9
View File
@@ -445,6 +445,7 @@ def compute_cross_design(
rock_boundary_offset_m: float | None = None,
two_stage_slope: bool = True,
ditch_enabled: bool | None = None,
surface_drop_m: float = 0.0,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -455,6 +456,9 @@ def compute_cross_design(
standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순.
rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용.
two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제).
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
"""
if ground_type not in SECTION_GROUND_TYPE_PRESET:
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
@@ -464,6 +468,8 @@ def compute_cross_design(
raise ValueError(f"지원하지 않는 측구 형식입니다: {ditch_type}")
if design_elevation_m is None:
raise ValueError("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.")
drop = max(float(surface_drop_m), 0.0)
design_elevation_m = float(design_elevation_m) - drop
preset_key = SECTION_GROUND_TYPE_PRESET[ground_type]
if ditch_type == "l_type" and preset_key != "rock":
@@ -630,6 +636,9 @@ def compute_cross_design(
"ditch_area_m2": round(ditch_area, 4),
"design_line": design_line,
}
if drop > 0:
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.
result["surface_drop_m"] = round(drop, 4)
if paved:
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
# 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
+13
View File
@@ -43,9 +43,12 @@ from B06_Section.B06_Section_Router_Design import (
attach_default_designs as _attach_default_designs,
compute_default_designs as _compute_default_designs,
enforce_pavement_ranges as _enforce_pavement_ranges,
enforce_ford_surface_drops as _enforce_ford_surface_drops,
stored_standard_cross_section as _stored_standard_cross_section,
pavement_ranges as _pavement_ranges,
paved_at as _paved_at,
ford_surface_drops,
ford_drop_at,
)
from B06_Section.B06_Section_Router_Design import (
default_section_modes as _default_section_modes,
@@ -318,6 +321,15 @@ async def get_section_detail(
project_root,
standard,
)
# 세월교 측점은 구체 위 노면이 월류 높이만큼 낮게 앉는다 — 저장분이 옛 계획고면
# 여기서 다시 계산한다(2026-08-30 사용자 확정).
await asyncio.to_thread(
_enforce_ford_surface_drops,
detail["longitudinal"],
detail["cross_sections"],
project_root,
standard,
)
# 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다.
# (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.)
await asyncio.to_thread(
@@ -603,6 +615,7 @@ async def compute_cross_section_design(
rock_boundary_offset_m=request.rock_boundary_offset_m,
two_stage_slope=request.two_stage_slope,
ditch_enabled=request.ditch_enabled,
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
)
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
design["status"] = "provisional"
+92
View File
@@ -15,6 +15,10 @@ from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
logger = logging.getLogger(__name__)
# 세월교 노면 하강이 걸리는 측점 판정 허용 오차(m) — 세트 부착(`attach_culvert_sets`)과
# 같은 기준이라 "구체가 그려진 측점"과 "노면이 내려간 측점"이 어긋나지 않는다.
_FORD_DROP_TOLERANCE_M = 0.02
PREVIEW_DESIGN_FIELDS = (
"ground_type",
"roadbed_width_m",
@@ -69,6 +73,36 @@ def pavement_ranges(project_root: Path) -> list[tuple[float, float]]:
return ranges
def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]:
"""세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)].
구체 위 노면은 월류 높이만큼 낮게 앉는다(2026-08-30 사용자 확정). 계획고를 그만큼
내려 잡아야 측벽·바닥판이 맞고 절·성토 면적도 따라온다. 포장 구간과 같은 방식으로
관 지점 정본에서 읽는다 — 읽기 실패는 비치명(하강 없음으로 본다).
"""
if project_root is None:
return []
drops: list[tuple[float, float]] = []
try:
for chainage, spec in load_culvert_sets(project_root).items():
if spec.get("type") != "ford":
continue
depth = float(spec.get("overflow_depth_m") or 0.0)
if depth > 0:
drops.append((chainage, depth))
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 세월교 월류 높이를 읽지 못했습니다 (노면 하강 없음으로 본다)")
return drops
def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float:
"""그 측점의 노면 하강량(m). 구체가 앉은 측점만 — 붙는 기준은 세트 부착과 같다."""
for at, depth in drops:
if abs(chainage_m - at) <= _FORD_DROP_TOLERANCE_M:
return depth
return 0.0
def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool:
"""이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장)."""
for start, end in ranges:
@@ -118,6 +152,7 @@ def enforce_pavement_ranges(
ranges = pavement_ranges(project_root)
if not ranges:
return 0
ford_drops = ford_surface_drops(project_root)
changed = 0
for section in cross_sections:
design = section.get("design")
@@ -141,6 +176,57 @@ def enforce_pavement_ranges(
),
two_stage_slope=bool(design.get("two_stage_slope", True)),
ditch_enabled=design.get("ditch_enabled"),
surface_drop_m=ford_drop_at(chainage, ford_drops),
)
except (ValueError, KeyError):
continue
for key in ("status", "pavement_suggested", *_USER_TOUCHED_KEYS):
if design.get(key) is not None:
recomputed[key] = design[key]
section["design"] = recomputed
changed += 1
return changed
def enforce_ford_surface_drops(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None = None,
) -> int:
"""세월교가 앉은 측점의 계획고를 월류 높이만큼 내려 다시 계산한다.
저장분은 월류 높이를 넣기 전에 계산된 것이라 노면이 그대로다 — 포장 구간과 같은
방식으로 **저장분과 지금 값이 다를 때만** 다시 계산한다(2026-08-30 사용자 확정).
월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다. 사용자 조작값은 승계한다.
"""
drops = ford_surface_drops(project_root)
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict):
continue
chainage = float(section.get("chainage_m", 0.0))
wanted = ford_drop_at(chainage, drops)
stored = float(design.get("surface_drop_m") or 0.0)
if abs(wanted - stored) < 1e-6:
continue
try:
recomputed = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type=str(design.get("ground_type") or "ripping_rock"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=bool(design.get("paved", False)),
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
two_stage_slope=bool(design.get("two_stage_slope", True)),
ditch_enabled=design.get("ditch_enabled"),
surface_drop_m=wanted,
)
except (ValueError, KeyError):
continue
@@ -194,6 +280,7 @@ def attach_default_designs(
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(project_root) if project_root else []
ford_drops = ford_surface_drops(project_root)
for section in cross_sections:
if section.get("design"):
continue
@@ -209,6 +296,7 @@ def attach_default_designs(
paved=paved_at(chainage, paved_ranges),
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
surface_drop_m=ford_drop_at(chainage, ford_drops),
)
design.update(status="provisional", pavement_suggested=suggested)
section["design"] = design
@@ -227,6 +315,7 @@ def recompute_designs_for_alignment(
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(project_root) if project_root else []
ford_drops = ford_surface_drops(project_root)
stored_by_chainage = {
round(float(record["chainage_m"]), 3): (record.get("design") or {})
for record in stored_designs
@@ -258,6 +347,7 @@ def recompute_designs_for_alignment(
),
two_stage_slope=bool(stored.get("two_stage_slope", True)),
ditch_enabled=stored.get("ditch_enabled"),
surface_drop_m=ford_drop_at(chainage, ford_drops),
)
except (ValueError, KeyError):
continue
@@ -295,6 +385,7 @@ def compute_default_designs(
default_modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(root)
ford_drops = ford_surface_drops(root)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross_path = cross_dir / cross_filename(chainage_m)
@@ -315,6 +406,7 @@ def compute_default_designs(
paved=paved_at(chainage_m, paved_ranges),
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
surface_drop_m=ford_drop_at(chainage_m, ford_drops),
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
+12 -4
View File
@@ -22,6 +22,10 @@ interface SideSlots {
/** 독립 기슭막이 좌·우 칸 — 배관 유입구·유출구 칸이 숨어 있을 때 이쪽으로 간다. */
revetInlet: HTMLElement;
revetOutlet: HTMLElement;
/** 세월교 날개벽(유입·유출) 칸 — 세월교 측점에서는 배관·독립 칸이 모두 숨어 있어
* 이쪽이 그 측의 자리다(2026-08-30 사용자 지시 1). */
wingInlet: HTMLElement;
wingOutlet: HTMLElement;
}
let sideSlots: SideSlots | null = null;
@@ -51,12 +55,14 @@ function slotUsable(target: HTMLElement | undefined): target is HTMLElement {
return !!group && !group.hidden;
}
/** 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸 — 지금 서 있는 쪽을 준다. */
/** 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸, 세월교면 날개벽 칸 —
* 지금 서 있는 쪽을 준다(같은 측의 자리가 시설 종류마다 다른 칸에 있다). */
function slotOf(side: "inlet" | "outlet"): HTMLElement {
const slots = sideSlots!;
return slotUsable(slots[side])
? slots[side]
: slots[side === "inlet" ? "revetInlet" : "revetOutlet"];
const revet = side === "inlet" ? slots.revetInlet : slots.revetOutlet;
const wing = side === "inlet" ? slots.wingInlet : slots.wingOutlet;
if (slotUsable(slots[side])) return slots[side];
return slotUsable(revet) ? revet : wing;
}
/** 옮겨 온 행과 그 **제자리 표식** — 되돌릴 때 원래 순서로 꽂기 위해 짝으로 둔다.
@@ -85,6 +91,8 @@ function clearSideSlots(): void {
sideSlots?.extra,
sideSlots?.revetInlet,
sideSlots?.revetOutlet,
sideSlots?.wingInlet,
sideSlots?.wingOutlet,
]) {
target?.replaceChildren();
const group = target?.closest("fieldset");
+4 -1
View File
@@ -148,7 +148,10 @@ export function appendBoxOverlay(
{ offset: outer, elevation: bottom },
{ offset: midOffset, elevation: bottom },
],
"b06-chart__culvert-revet-hit",
// 강조용 클래스를 하나 더 단다 — 고른 끝을 옅게 칠한다(2026-08-30 사용자:
// BOX암거는 선택해도 아무 표시가 없었다). 구체 부재는 좌·우가 한 몸이라
// 그쪽을 칠하면 어느 끝을 고른 것인지 되레 흐려진다.
"b06-chart__culvert-revet-hit b06-chart__box-hit",
"",
);
hit.addEventListener("click", (event) => {
@@ -26,6 +26,9 @@ export interface BoxPanelDeps {
nudgeRise: (role: BoxSideRole, deltaM: number) => void;
/** 그 측을 자동 자리로 되돌린다. */
reset: (role: BoxSideRole) => void;
/** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은
* 유입/유출로만 부른다(2026-08-30 사용자: 조정창은 결국 지우고 폼 이름을 쓴다). */
wingRoleFor?: (role: BoxSideRole) => "inlet" | "outlet";
/** 지금 그려진 구체 길이(m)와 물매(1:n, 수평이면 null). */
bodyLengthM: () => number;
slopeRatio: () => number | null;
@@ -117,6 +120,10 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
show(role) {
current = role;
root.classList.toggle("is-hidden", role === null);
// 좌측 [횡단 조정]에 어느 칸을 세울지 알린다 — 날개벽(유입)/(유출) 칸이다
// (세월교와 같은 규약, 2026-08-30 사용자).
root.dataset.side = role === null ? "" : (deps.wingRoleFor?.(role) ?? "");
root.dataset.panelTitle = role === null ? "" : "BOX암거";
arrows.detach();
if (role !== null) arrows.attach();
render();
@@ -129,6 +136,14 @@ export interface BoxControl {
adjustFor: (chainageM: number) => { left: BoxSideAdjust; right: BoxSideAdjust };
update: (chainageM: number, role: BoxSideRole, patch: Partial<BoxSideAdjust>) => void;
reset: (chainageM: number, role: BoxSideRole) => void;
/** 본체 규격 저장 — 좌측 폼이 보낸다. B05 정본(`pipe_points`)으로 간다. */
setBody: (chainageM: number, patch: { body_width_m?: number; body_height_m?: number }) => void;
/** 날개벽 제원 저장(유입·유출) — 세월교와 같은 옵션 키를 쓴다. */
setWing: (
chainageM: number,
role: "inlet" | "outlet",
patch: Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number }>,
) => void;
selectedFor: (chainageM: number) => BoxSideRole | null;
select: (chainageM: number, role: BoxSideRole | null) => void;
}
@@ -72,6 +72,9 @@ export interface FordSideLayout {
pipeEnd: OffsetPoint;
/** 바닥판 안쪽 변(위·아래) — 두 측벽 사이를 잇는 판이 여기서 시작한다. */
slabInner: [OffsetPoint, OffsetPoint];
/** 측벽 상단의 **계류측 꼭짓점** — 좌측 벽이면 좌상단, 우측 벽이면 우상단.
* 월류 전 노면(점선)이 이 점까지 대각으로 내려온다(2026-08-30 사용자, 각도 무시). */
wallTopOuter: OffsetPoint;
/** 구조물 계류측 끝이 원지반 위일 때의 성토부선(집수정과 같은 체계). */
fillSegments: OutletFillSegment[];
/** 원지반 안으로 박힐 때의 절토선. */
@@ -222,6 +225,15 @@ export function computeFordLayout(
slabTopElevation: built.pipeEnd.elevation,
pipeEnd: built.pipeEnd,
slabInner: [floor.points[0], floor.points[3]],
// 벽 상단 두 꼭짓점 중 계류측 것 — 점선이 여기로 내려와 붙는다.
wallTopOuter: (() => {
const topZ = Math.max(...wall.points.map((point) => point.elevation));
const tops = wall.points.filter((point) => point.elevation > topZ - 1e-6);
return tops.reduce(
(far, point) => ((point.offset - far.offset) * outward > 0 ? point : far),
tops[0],
);
})(),
fillSegments: extras.segments,
cutLine: built.basin.cutLine,
adjust: {
+16 -2
View File
@@ -265,6 +265,11 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
show(role) {
current = role;
root.classList.toggle("is-hidden", role === null);
// 좌측 [횡단 조정]에 어느 칸을 세울지 알린다 — 세월교는 그 측 날개벽 칸이다
// (배관 조정창이 유입구·유출구 칸을 가리키는 것과 같은 규약, 2026-08-30 지시 1).
root.dataset.side = role ?? "";
// 칸 이름이 이미 "날개벽(유입)"이라 괄호에는 구조물 이름만 붙인다.
root.dataset.panelTitle = role === null ? "" : "세월교";
arrows.detach();
if (role !== null) arrows.attach();
render();
@@ -277,8 +282,17 @@ export interface FordControl {
adjustFor: (chainageM: number) => FordAdjust;
update: (chainageM: number, role: FordWallRole, patch: Partial<FordWallAdjust>) => void;
reset: (chainageM: number, role: FordWallRole) => void;
/** 관경(mm)·수량(련) 저장 — B05 정본(`pipe_points`)으로 간다. */
setPipe: (chainageM: number, patch: { pipe_diameter_mm?: number; pipe_count?: number }) => void;
/** 관경(mm)·수량(련)·관종·월류 폭 저장 — B05 정본(`pipe_points`)으로 간다.
* 관종·월류 폭은 조정창에 칸이 없고 좌측 「구조물 배치」 폼만 보낸다(2026-08-30 사용자). */
setPipe: (
chainageM: number,
patch: {
pipe_diameter_mm?: number;
pipe_count?: number;
pipe_kind?: string;
ford_width_m?: number;
},
) => void;
/** 날개벽 제원 저장(2026-08-25 사용자 — 조정창에서 직접 제어). 같은 정본으로 간다. */
setWing: (
chainageM: number,
@@ -14,6 +14,7 @@
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */
@@ -67,6 +68,45 @@ function line(points: string[], className: string): SVGPolylineElement {
return polyline;
}
/**
* 세월교로 **내려 앉힌 노면** 위에 "월류가 없었다면" 노면을 점선으로 되그린다
* (2026-08-30 사용자 확정). 계획고는 이미 백엔드가 월류 높이만큼 내려 잡았으므로
* (`design.surface_drop_m`) 여기서는 그 양만큼 위로 올린 선을 얹는다.
* 물넘이포장의 「기존 계획고 점선」과 같은 표기라 사용자가 한 규칙으로 읽는다.
*
* 양 끝은 **측벽 상단의 계류측 꼭짓점까지 대각으로 내려 잇는다**(2026-08-30 사용자:
* 좌측 벽이면 좌상단, 우측 벽이면 우상단, 각도는 무시). 월류 전 노면이 구체 밖에서
* 어디로 떨어지는지가 그 선으로 읽힌다.
*/
export function appendFordSurfaceDropPlan(
svg: SVGElement,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
ford?: { sides: ReadonlyArray<{ outward: number; wallTopOuter: OffsetPoint }> } | null,
): void {
const drop = design.surface_drop_m ?? 0;
if (!(drop > 0) || !design.road_edges) return;
const { left, right } = design.road_edges;
// +offset이 좌측이다 — 좌측 벽 상단 → 좌 노견 → 우 노견 → 우측 벽 상단 순.
const cornerAt = (side: 1 | -1): OffsetPoint | undefined =>
ford?.sides.find((entry) => Math.sign(entry.outward) === side)?.wallTopOuter;
const points: OffsetPoint[] = [
{ offset: left.offset_m, elevation: left.elevation_m + drop },
{ offset: right.offset_m, elevation: right.elevation_m + drop },
];
const leftCorner = cornerAt(1);
if (leftCorner) points.unshift(leftCorner);
const rightCorner = cornerAt(-1);
if (rightCorner) points.push(rightCorner);
svg.append(
line(
points.map((point) => `${x(point.offset)},${y(point.elevation)}`),
"b06-chart__ford-deck-plan",
),
);
}
/**
* 물넘이 파임을 그린다. 그렸으면 true — 호출부는 일반 포장층 박스를 건너뛴다
* (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다).
+7 -1
View File
@@ -14,7 +14,10 @@ import {
buildAreaReadout,
type CrossAreaKey,
} from "./B06_Section_UI_Cross_Areas";
import { appendFordPavementOverlay } from "./B06_Section_UI_Cross_Ford_Pavement";
import {
appendFordPavementOverlay,
appendFordSurfaceDropPlan,
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
import {
appendCrossDesignOverlay,
@@ -462,6 +465,9 @@ export function createCrossSectionCard(
toDisplayY,
);
if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
// 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면"
// 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자).
appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout);
// 독립 기슭막이(구 D군 경로) — 이 측점에 배관/숨김 기슭막이 세트(section.culvert)가
// 붙으면 그쪽(배관 경로)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 그린다
// (2026-08-28 이관: pipe_points 숨김 세트로 옮기는 중 — 이중 그리기 방지 가드).
@@ -79,6 +79,7 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
chainageM: chainage,
box,
layout: () => boxLayout,
inletOnLeft: (section.uphill_side ?? "left") === "left",
close: () => {
if (boxRole) toggleBox(boxRole);
},
@@ -13,6 +13,9 @@ export interface BoxPanelContext {
box: BoxControl;
/** 마지막 계산 결과 — 창이 길이·물매를 읽는다. */
layout: () => BoxLayout | null;
/** 유입(상류)이 화면 좌측인가 — 좌·우를 폼의 유입/유출 칸으로 옮길 때 쓴다
* (기하가 날개벽을 고르는 기준과 같다, 2026-08-30). */
inletOnLeft: boolean;
close: () => void;
}
@@ -28,6 +31,7 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps {
nudgeRise: (role, deltaM) =>
box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }),
reset: (role) => box.reset(chainageM, role),
wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"),
bodyLengthM: () => context.layout()?.bodyLengthM ?? 0,
slopeRatio: () => context.layout()?.slopeRatio ?? null,
close: context.close,
+16 -2
View File
@@ -31,6 +31,7 @@ import {
} from "./B06_Section_Api_Fetch";
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls";
import { buildCrossPatches } from "./B06_Section_UI_Page_Patches";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
@@ -194,8 +195,21 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const owner = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
);
const culvert = owner?.culvert;
if (!owner || !culvert) return;
if (!owner) return;
// 세월교는 스펙 자리(`section.ford`)가 배수관과 달라 조정창 제어기로 보낸다 —
// 로직은 그쪽 것을 쓰고 프론트만 폼 UI다(2026-08-30 사용자 확정).
if (owner.ford) {
applyFordFormOptions(stationControls.ford, owner.chainage_m, patch);
return;
}
// BOX암거도 스펙 자리가 따로다(`section.box`) — 같은 규칙으로 제어기에 보낸다
// (2026-08-30 사용자: 세월교와 같은 문제).
if (owner.box) {
applyBoxFormOptions(stationControls.box, owner.chainage_m, patch);
return;
}
const culvert = owner.culvert;
if (!culvert) return;
const num = (key: string): number | undefined => {
const value = Number(patch[key]);
return Number.isFinite(value) ? value : undefined;
@@ -16,6 +16,44 @@ import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box";
import type { BoxAdjust, BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box";
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
/** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */
type WingPatch = Partial<{
installed: boolean;
height_m: number;
length_m: number;
angle_deg: number;
}>;
/** 조작값을 관 지점 옵션 키로 옮긴다 — 값이 온 항목만 싣는다. */
function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string, number | string> {
const prefix = role === "inlet" ? "wing_in" : "wing_out";
const options: Record<string, number | string> = {};
if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음";
if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m;
if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m;
if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg;
return options;
}
/** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */
function wingPatchFrom(patch: Record<string, number | string>, prefix: string): WingPatch {
const num = (key: string): number | undefined => {
if (patch[key] === undefined) return undefined;
const value = Number(patch[key]);
return Number.isFinite(value) ? value : undefined;
};
const wing: WingPatch = {};
// 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약).
if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음";
const heightM = num(`${prefix}_height_m`);
if (heightM !== undefined) wing.height_m = heightM;
const lengthM = num(`${prefix}_length_m`);
if (lengthM !== undefined) wing.length_m = lengthM;
const angleDeg = num(`${prefix}_angle_deg`);
if (angleDeg !== undefined) wing.angle_deg = angleDeg;
return wing;
}
export interface FordControlDeps {
/** 세션 보관 키(프로젝트·노선별). 없으면 세션에 담지 않는다. */
sessionKey: () => string | null;
@@ -114,6 +152,9 @@ export function createFordControls(deps: FordControlDeps): FordControls {
// 캐시를 먼저 고쳐 즉시 반영한다 — 저장은 늦게 묶어서 간다.
if (patch.pipe_diameter_mm) spec.diameter_m = patch.pipe_diameter_mm / 1000;
if (patch.pipe_count) spec.pipe_count = patch.pipe_count;
if (patch.pipe_kind) spec.pipe_kind = patch.pipe_kind;
// 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리.
if (patch.ford_width_m) spec.span_m = patch.ford_width_m;
}
deps.queuePipeOptions(chainageM, patch);
deps.refreshCard(chainageM);
@@ -131,13 +172,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
: 0;
}
const prefix = role === "inlet" ? "wing_in" : "wing_out";
const options: Record<string, number | string> = {};
if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음";
if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m;
if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m;
if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg;
deps.queuePipeOptions(chainageM, options);
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
@@ -160,6 +195,78 @@ export function createFordControls(deps: FordControlDeps): FordControls {
};
}
/**
* 좌측 「구조물 배치」 폼이 낸 세월교 옵션을 **조정창 제어기**로 흘려보낸다
* (2026-08-30 사용자 확정: 프론트는 구조물 배치의 상세 UI, 로직은 조정창 것을 쓴다).
*
* 폼 옵션 키(`pipe_*`·`ford_width_m`·`wing_in*`·`wing_out*`)를 제어기 인자로 옮기기만
* 한다 — 캐시 반영·바닥판 연장 재계산·정본 예약·카드 갱신은 제어기 안에 이미 있다.
* 값이 온 항목만 보낸다(빈 patch는 부르지 않는다).
*/
export function applyFordFormOptions(
control: FordControl,
chainageM: number,
patch: Record<string, number | string>,
): void {
const num = (key: string): number | undefined => {
if (patch[key] === undefined) return undefined;
const value = Number(patch[key]);
return Number.isFinite(value) ? value : undefined;
};
const pipe: Parameters<FordControl["setPipe"]>[1] = {};
const diameterMm = num("pipe_diameter_mm");
if (diameterMm !== undefined) pipe.pipe_diameter_mm = diameterMm;
const count = num("pipe_count");
if (count !== undefined) pipe.pipe_count = count;
if (typeof patch.pipe_kind === "string") pipe.pipe_kind = patch.pipe_kind;
const widthM = num("ford_width_m");
if (widthM !== undefined) pipe.ford_width_m = widthM;
if (Object.keys(pipe).length) control.setPipe(chainageM, pipe);
applyWingFormOptions(control, chainageM, patch);
}
/** 폼이 낸 날개벽 옵션을 제어기로 보낸다 — 세월교·BOX암거가 같은 조각을 쓴다. */
function applyWingFormOptions(
control: {
setWing: (chainageM: number, role: "inlet" | "outlet", patch: WingPatch) => void;
},
chainageM: number,
patch: Record<string, number | string>,
): void {
for (const [role, prefix] of [
["inlet", "wing_in"],
["outlet", "wing_out"],
] as const) {
const wing = wingPatchFrom(patch, prefix);
if (Object.keys(wing).length) control.setWing(chainageM, role, wing);
}
}
/**
* 좌측 「구조물 배치」 폼이 낸 BOX암거 옵션을 조정창 제어기로 흘려보낸다 — 세월교와
* 같은 규칙이다(2026-08-30 사용자). 본체 규격·날개벽만 폼이 정하고, 구체 길이·표고는
* 조정창 축이라 여기 오지 않는다.
*/
export function applyBoxFormOptions(
control: BoxControl,
chainageM: number,
patch: Record<string, number | string>,
): void {
const num = (key: string): number | undefined => {
if (patch[key] === undefined) return undefined;
const value = Number(patch[key]);
return Number.isFinite(value) ? value : undefined;
};
const body: Parameters<BoxControl["setBody"]>[1] = {};
const widthM = num("body_width_m");
if (widthM !== undefined) body.body_width_m = widthM;
const heightM = num("body_height_m");
if (heightM !== undefined) body.body_height_m = heightM;
if (Object.keys(body).length) control.setBody(chainageM, body);
applyWingFormOptions(control, chainageM, patch);
}
/**
* BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 사본 + 캐시 `design.box_adjust`).
* 좌·우 끝을 따로 잡으며 길이는 바깥으로만, 표고는 양방향으로 움직인다.
@@ -233,6 +340,34 @@ export function createBoxControls(deps: FordControlDeps): {
const current = adjustAt(chainageM);
write(chainageM, { ...current, [role]: { ...DEFAULT_BOX_SIDE_ADJUST } });
},
setBody: (chainageM, patch) => {
const spec = sectionAt(chainageM)?.box;
if (spec) {
// 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다.
if (patch.body_width_m) {
spec.inner_width_m = patch.body_width_m;
spec.span_m = patch.body_width_m + 2 * spec.wall_thickness_m;
}
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
}
deps.queuePipeOptions(chainageM, patch as Record<string, number | string>);
deps.refreshCard(chainageM);
},
setWing: (chainageM, role, patch) => {
const spec = sectionAt(chainageM)?.box;
const wing = role === "inlet" ? spec?.wing_in : spec?.wing_out;
if (wing) {
if (patch.installed !== undefined) wing.installed = patch.installed;
if (patch.height_m !== undefined) wing.height_m = patch.height_m;
if (patch.length_m !== undefined) wing.length_m = patch.length_m;
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
wing.slab_extend_m = wing.installed
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
: 0;
}
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
selections.set(chainageM.toFixed(2), role);
@@ -102,6 +102,56 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
}
/**
* 세월교 폼 옵션을 **지금 그려진 스펙**(`section.ford`)으로 덮는다 — 조정창 조작이
* 이 스펙을 제자리에서 고치므로, 폼이 저장된 옵션을 붙들면 두 창이 갈린다
* (2026-08-30 사용자: 수량(련) 칸이 비어 있고 조정창은 1련이었다).
*/
function withFordSpec(
options: Record<string, string | number> | undefined,
ford: NonNullable<SectionDetailResponse["cross_sections"][number]["ford"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind;
merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000);
merged.pipe_count = ford.pipe_count;
merged.ford_width_m = ford.span_m;
for (const [wing, prefix] of [
[ford.wing_in, "wing_in"],
[ford.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
return merged;
}
/**
* BOX암거 폼 옵션을 **지금 그려진 스펙**(`section.box`)으로 덮는다 — 세월교와 같은
* 규칙이다(2026-08-30 사용자). 본체 규격은 내공 폭·높이가 정본이고, 구체 길이·표고는
* 조정 채널이라 여기 오지 않는다.
*/
function withBoxSpec(
options: Record<string, string | number> | undefined,
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
merged.body_width_m = box.inner_width_m;
merged.body_height_m = box.inner_height_m;
for (const [wing, prefix] of [
[box.wing_in, "wing_in"],
[box.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
return merged;
}
/**
* 관 지점 옵션에 **횡단 스펙 값**을 채워 넣는다 — 조정창이 보여주던 값과 폼이
* 어긋나지 않게 한다(2026-08-29 사용자). 옵션에 이미 값이 있으면 그대로 두고,
@@ -117,6 +167,11 @@ function withSpecDefaults(
const owner = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
);
// 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든
// 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는
// 구조물 배치 상세 UI, 로직·값은 조정창 것).
if (owner?.ford) return withFordSpec(options, owner.ford);
if (owner?.box) return withBoxSpec(options, owner.box);
const culvert = owner?.culvert;
if (!culvert) return options;
const merged: Record<string, string | number> = { ...(options ?? {}) };
@@ -236,6 +291,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
extra: section.facility.extraSlot,
revetInlet: section.facility.revetInletSlot,
revetOutlet: section.facility.revetOutletSlot,
wingInlet: section.facility.wingInSlot,
wingOutlet: section.facility.wingOutSlot,
});
async function load(): Promise<void> {
@@ -266,7 +323,13 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
deps.wallHeight,
deps.wallForm,
),
design_flow_m3s: null,
// 담당 유역의 설계유량 — 물넘이·세월교 개략 단면(필요 수심 = 월류 높이
// 자동값)이 이 값에서 나온다. B05와 같은 규칙으로 유역을 측점으로 짝짓는다
// (2026-08-30 사용자: 월류 폭을 넣어도 높이가 자동 계산되지 않았다).
design_flow_m3s:
pipeResponse.basins.find(
(basin) => Math.abs(basin.chainage_m - pipe.chainage_m) < PIPE_MATCH_M,
)?.design_flow_m3s ?? null,
}));
section.setPipeFacilities(pipeFacilities);
} catch (error) {
@@ -411,12 +474,17 @@ export function wireStructureSelection(
markWallSelecting();
origFord(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
refreshAdjustSlots();
};
const origBox = stationControls.box.select;
stationControls.box.select = (chainageM, role) => {
markWallSelecting();
origBox(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
refreshAdjustSlots();
};
return { syncInletStructure };
}
+13 -3
View File
@@ -407,6 +407,18 @@ export function createSectionView(
applySelection(previous);
};
/** 십자선이 설 계획고 — 세월교 측점은 구체 위 노면이 월류 높이만큼 낮게 앉으므로
* 그만큼 내려 찍는다(2026-08-30 사용자 확정). 하강량은 백엔드가 계획고를 내려
* 잡으며 실어 준 값(`design.surface_drop_m`)이고, 원래 노면은 점선이 남긴다. */
const cardDesignElevation = (
detail: SectionDetailResponse,
section: CrossSection,
): number | undefined => {
const planZ = designElevationAt(detail.longitudinal.design_profiles, section.chainage_m);
if (planZ === undefined) return undefined;
return planZ - (section.design?.surface_drop_m ?? 0);
};
const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement =>
createCrossSectionCard(
section,
@@ -417,9 +429,7 @@ export function createSectionView(
currentCrossHalfWidth,
cachedCardWidth,
forcedHeightPx,
currentDetail
? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m)
: undefined,
currentDetail ? cardDesignElevation(currentDetail, section) : undefined,
onDesignChange,
rockBoundary,
section.station_id === selectedStationId ? activeAreaKey : null,
@@ -250,6 +250,16 @@
cursor: pointer;
}
/* BOX암거 끝 선택 강조 — 고른 절반을 옅게 칠하고 테두리를 두른다
(2026-08-30 사용자: 선택해도 하이라이트가 없었다). 색은 기슭막이·집수정 강조와
같은 계열이되, 면이 넓어 채도를 낮춘다. */
.b06-chart__box-hit.is-active {
fill: color-mix(in srgb, var(--color-danger) 16%, transparent);
stroke: var(--color-danger);
stroke-width: 1.6;
stroke-dasharray: 4 3;
}
.b06-chart__culvert-apron {
fill: color-mix(in srgb, var(--color-text-secondary) 22%, transparent);
stroke: var(--color-text-secondary);
+100 -12
View File
@@ -5,9 +5,17 @@
읽고 쓴다 — 관리자 화면에서 옮긴 관이 사용자 화면에서 다르게 보이면 안 되기 때문이다
(2026-08-01 사용자 지시).
위치는 좌표가 아니라 **누가거리(chainage_m)** 로 저장한다. 지면 필터나 지표면 모델을 바꾸면
위치는 **누가거리(chainage_m) + 좌표(x, y)** 로 저장한다. 지면 필터나 지표면 모델을 바꾸면
종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다.
노선 자체가 바뀌면(`route_signature` 불일치) 기준이 사라지므로 전량 버리고 다시 만든다.
좌표를 같이 남기는 이유(2026-08-30 사용자 지적 — "결국 노선 위에 위치해야 한다"): 관 자리를
정한 선(계획노선 CSV)과 화면에 그려지는 선(B05 최적 경로)은 **같은 자리를 지나면서 연장이
다르다**(실측 350.11m vs 354.83m). 누가거리만 남기면 읽는 쪽이 쥔 선에 따라 같은 값이 3~4m
미끄러져 관이 선 옆에 떨어진 것처럼 보인다. 좌표를 남겨 두면 어느 선으로 읽든 그 좌표를
투영해 **항상 선 위에** 앉힐 수 있다.
노선이 바뀌면(`route_signature` 불일치) 좌표가 있는 저장분은 새 노선에 투영해 이월하고,
좌표가 없는 구 저장분만 버린다.
"""
from __future__ import annotations
@@ -19,9 +27,11 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from shapely.geometry import LineString, Point
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex
from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex
from config.config_system import (
DRAINAGE_DETAIL_FILENAME,
DRAINAGE_EDITS_DIRNAME,
@@ -72,6 +82,9 @@ class PipePoint:
start_m: float | None = None
end_m: float | None = None
options: dict[str, Any] | None = None
# 관이 실제로 놓인 자리(사업지 CRS, m). 노선이 바뀌어도 이 자리는 그대로다.
x: float | None = None
y: float | None = None
def as_dict(self) -> dict[str, Any]:
# 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다.
@@ -86,6 +99,9 @@ class PipePoint:
payload["end_m"] = round(float(self.end_m), 2)
if self.options:
payload["options"] = self.options
if self.x is not None and self.y is not None:
payload["x"] = round(float(self.x), 3)
payload["y"] = round(float(self.y), 3)
return payload
@@ -113,9 +129,59 @@ def route_signature(vertices: list[RouteVertex]) -> str:
return f"{len(vertices)}-{digest.hexdigest()[:16]}"
def load_pipe_points(stored_path: str, signature: str) -> list[PipePoint] | None:
"""저장된 관 지점을 읽는다. 파일이 없거나 노선이 바뀌었으면 None(= 다시 만들어야 함)."""
path = pipe_points_path(stored_path)
def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]:
"""좌표가 비어 있는 관에 그 누가거리의 노선 좌표를 채운다(제자리 수정)."""
if not vertices:
return points
for point in points:
if point.x is None or point.y is None:
x, y, _ = interpolate_vertex(vertices, float(point.chainage_m))
point.x, point.y = float(x), float(y)
return points
def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]:
"""저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다.
관이 놓인 **자리**는 좌표가 정본이고 누가거리는 그 자리를 읽는 선에 종속된 값이다.
앞뒤 구간(start_m·end_m)은 기준점이 옮겨간 만큼 같이 민다 — 구간 길이는 시설 치수라
노선이 바뀌어도 변하지 않는다.
"""
if not vertices:
return points
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
if line.length <= 0:
return points
for point in points:
if point.x is None or point.y is None:
continue
moved = float(line.project(Point(point.x, point.y)))
shift = moved - float(point.chainage_m)
point.chainage_m = moved
if point.start_m is not None:
point.start_m = float(point.start_m) + shift
if point.end_m is not None:
point.end_m = float(point.end_m) + shift
points.sort(key=lambda item: item.chainage_m)
return points
def load_pipe_points(
stored_path: str, signature: str, vertices: list[RouteVertex] | None = None
) -> list[PipePoint] | None:
"""저장된 관 지점을 읽는다. 파일이 없거나 이월할 수 없으면 None(= 다시 만들어야 함).
노선 지문이 다르면 예전에는 전량 버렸다. 저장분에 좌표가 있으면 `vertices`(읽는 쪽이
쓰는 노선)에 투영해 이월한다 — 같은 자리를 지나면서 연장만 다른 선끼리 관이 통째로
사라지던 것을 막는다(2026-08-30 사용자 지적).
"""
return load_pipe_points_file(pipe_points_path(stored_path), signature, vertices)
def load_pipe_points_file(
path: Path, signature: str, vertices: list[RouteVertex] | None = None
) -> list[PipePoint] | None:
"""`load_pipe_points`와 같되 파일 경로로 직접 읽는다 (B05는 프로젝트 루트를 쥔다)."""
if not path.exists():
return None
try:
@@ -124,11 +190,19 @@ def load_pipe_points(stored_path: str, signature: str) -> list[PipePoint] | None
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: 관 지점 파일을 읽지 못했습니다 (%s).", path)
return None
points = parse_pipe_points(document.get("points"))
stored_signature = str(document.get("route_signature") or "")
if stored_signature != signature:
logger.info("배수유역: 노선이 바뀌어 저장된 관 지점을 버립니다 (%s).", path.name)
return None
return parse_pipe_points(document.get("points"))
if stored_signature == signature:
return points
if vertices and points and all(p.x is not None and p.y is not None for p in points):
logger.info(
"배수유역: 노선이 바뀌어 관 지점 %d건을 좌표로 이월합니다 (%s).",
len(points),
path.name,
)
return project_pipe_points(points, vertices)
logger.info("배수유역: 노선이 바뀌어 저장된 관 지점을 버립니다 (%s).", path.name)
return None
def _parse_span(item: dict[str, Any], chainage: float) -> tuple[float | None, float | None]:
@@ -187,6 +261,7 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
facility = str(item.get("facility") or PIPE_FACILITY_PIPE)
start, end = _parse_span(item, float(chainage))
options = item.get("options")
raw_x, raw_y = item.get("x"), item.get("y")
points.append(
PipePoint(
chainage_m=float(chainage),
@@ -194,6 +269,8 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE,
start_m=start,
end_m=end,
x=float(raw_x) if isinstance(raw_x, (int, float)) else None,
y=float(raw_y) if isinstance(raw_y, (int, float)) else None,
options=(
_migrate_protection(dict(options))
if isinstance(options, dict) and options
@@ -227,8 +304,19 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint])
return base
def save_pipe_points(stored_path: str, signature: str, points: list[PipePoint]) -> int:
"""관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다."""
def save_pipe_points(
stored_path: str,
signature: str,
points: list[PipePoint],
vertices: list[RouteVertex] | None = None,
) -> int:
"""관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다.
`vertices`를 주면 좌표가 빈 관을 그 노선 위 좌표로 채워 둔다 — 다음에 다른 선으로
읽어도 그 자리에 되놓을 수 있다.
"""
if vertices:
fill_pipe_coordinates(points, vertices)
path = pipe_points_path(stored_path)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
+13 -7
View File
@@ -60,8 +60,9 @@ def resolve_temp_batch_path(user_id: int, batch_id: str, *, create: bool = True)
if not batch_id or any(sep in batch_id for sep in ("/", "\\", "..")):
raise ValueError("임시 보관함 묶음 식별자가 올바르지 않습니다.")
temp_root = os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
path = os.path.abspath(os.path.join(temp_root, str(user_id), batch_id))
# 실경로로 맞춘다 — 저장 엔진이 `Path.resolve()`를 쓰므로 기준이 같아야 한다.
temp_root = os.path.realpath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
path = os.path.realpath(os.path.join(temp_root, str(user_id), batch_id))
if os.path.commonpath((temp_root, path)) != temp_root or path == temp_root:
raise ValueError("임시 보관함 경로가 보관함 루트를 벗어났습니다.")
if create:
@@ -70,8 +71,8 @@ def resolve_temp_batch_path(user_id: int, batch_id: str, *, create: bool = True)
def temp_upload_root() -> str:
"""임시 보관함 루트(`storage/tmp`) 절대 경로."""
return os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
"""임시 보관함 루트(`storage/tmp`) 실경로."""
return os.path.realpath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
def resolve_project_root_for_delete(relative_path: str, project_id: str) -> str:
@@ -100,15 +101,20 @@ def resolve_project_root_for_delete(relative_path: str, project_id: str) -> str:
def resolve_stored_project_path(relative_path: str) -> str:
"""DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다."""
"""DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다.
실경로(`realpath`)로 돌려준다. `storage/`가 심볼릭 링크·정션일 수 있고(워크트리를
나눠 쓰면 실제로 그렇다), 저장 엔진 쪽은 `Path.resolve()`로 링크를 따라간다. 두
경로의 기준이 다르면 `chunk_path.relative_to(project_root)`가 터진다.
"""
normalized = PurePosixPath(relative_path.replace("\\", "/"))
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
if not normalized.parts or normalized.parts[0] != "storage":
raise ValueError("프로젝트 저장 경로는 storage/로 시작해야 합니다.")
storage_root = os.path.abspath(STORAGE_BASE_DIR)
path = os.path.abspath(os.path.join(storage_root, *normalized.parts[1:]))
storage_root = os.path.realpath(STORAGE_BASE_DIR)
path = os.path.realpath(os.path.join(storage_root, *normalized.parts[1:]))
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
os.makedirs(path, exist_ok=True)
+21
View File
@@ -183,6 +183,24 @@ SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0"))
SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0"))
# 도엽등고선 3D 서피스 (LAS 없는 설계 — 2026-08-30 사용자 확정)
# 노선 XY bbox에 더하는 절취 여유(m). 300m = 횡단·코리도·성토면 여유(사용자 확정값).
SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0"))
# 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값).
SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0"))
# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다
# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py.
SHEET_SURFACE_METHODS = [
method.strip()
for method in os.getenv(
"SHEET_SURFACE_METHODS",
"tin_sheet,tin,biharmonic,anudem,multires,laplace",
).split(",")
if method.strip()
]
# 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정).
SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace")
# 일반 사용자 WF1 자동 확정 기본값
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf")
SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm")
@@ -243,6 +261,9 @@ ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", "
ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk")
ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000"))
ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0"))
# 제어점 쌍이 이 배수×비용면 셀보다 가까우면 격자 탐색 없이 직결한다 — 원청 계획노선
# 보존 (2026-08-30 사용자 확정. 조밀 기준선은 격자 중간점이 못 끼어들어 평면 불변).
ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", "2.0"))
# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존
# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19).
ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch")
+181 -40
View File
@@ -29,16 +29,31 @@ export const ui_locales_b1 = {
B01_Account_Field_Name: ["이름", "Name"],
B01_Account_Field_Email: ["이메일", "Email"],
B01_Account_Field_Phone: ["연락처", "Phone"],
B01_Account_Field_Phone_Placeholder: ["연락처를 입력하세요", "Enter phone number"],
B01_Account_Field_Phone_Placeholder: [
"연락처를 입력하세요",
"Enter phone number",
],
B01_Account_Field_CurrentPw: ["현재 비밀번호", "Current password"],
B01_Account_Field_NewPw: ["새 비밀번호", "New password"],
B01_Account_Field_ConfirmPw: ["새 비밀번호 확인", "Confirm new password"],
B01_Account_Save_Profile: ["기본 정보 저장", "Save profile"],
B01_Account_Save_Password: ["비밀번호 변경", "Change password"],
B01_Account_Success_Profile: ["기본 정보가 저장되었습니다.", "Profile has been saved."],
B01_Account_Success_Password: ["비밀번호가 변경되었습니다.", "Password has been changed."],
B01_Account_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."],
B01_Account_Error_PwMismatch: ["새 비밀번호가 일치하지 않습니다.", "New passwords do not match."],
B01_Account_Success_Profile: [
"기본 정보가 저장되었습니다.",
"Profile has been saved.",
],
B01_Account_Success_Password: [
"비밀번호가 변경되었습니다.",
"Password has been changed.",
],
B01_Account_Error_Required: [
"필수 항목을 입력하세요.",
"Please fill in required fields.",
],
B01_Account_Error_PwMismatch: [
"새 비밀번호가 일치하지 않습니다.",
"New passwords do not match.",
],
B01_Account_Error_PwLength: [
"비밀번호는 8자 이상이어야 합니다.",
"Password must be at least 8 characters.",
@@ -79,8 +94,14 @@ export const ui_locales_b1 = {
/* --- B01 임시 보관함 (프로젝트 생성 전 업로드, 2026-08-08) --- */
B01_Temp_Section: ["임시 보관함", "Temporary storage"],
B01_Temp_Field_Name: ["보관 이름", "Storage name"],
B01_Temp_Field_Name_Placeholder: ["예: 2026년 3공구 측량자료", "e.g. 2026 Section 3 survey"],
B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"],
B01_Temp_Field_Name_Placeholder: [
"예: 2026년 3공구 측량자료",
"e.g. 2026 Section 3 survey",
],
B01_Temp_Field_Files: [
"파일 선택 (계획노선·라이다·좌표계·래스터)",
"Select files",
],
B01_Temp_Btn_Pick: ["파일 선택", "Choose files"],
B01_Temp_Btn_Add: ["파일 추가", "Add files"],
B01_Temp_Modal_Create: ["임시 자료 등록", "New stored set"],
@@ -100,7 +121,10 @@ export const ui_locales_b1 = {
"Delete this file from temporary storage?",
],
B01_Temp_File_Delete_Success: ["파일을 삭제했습니다.", "File deleted."],
B01_Temp_File_Delete_Failed: ["파일 삭제에 실패했습니다.", "Failed to delete the file."],
B01_Temp_File_Delete_Failed: [
"파일 삭제에 실패했습니다.",
"Failed to delete the file.",
],
/* 보관 기간은 섹션 제목 옆 태그로만 알린다(안내 문단 폐기, 2026-08-08). */
B01_Temp_Hint_Days: ["일 보관", " days retained"],
B01_Temp_Status_Uploading: ["업로드 중", "Uploading"],
@@ -111,9 +135,18 @@ export const ui_locales_b1 = {
B01_Temp_Meta_Linked: ["프로젝트로 이동 완료", "Moved to project"],
B01_Temp_Error_Name: ["보관 이름을 입력하세요.", "Enter a storage name."],
B01_Temp_Error_Files: ["올릴 파일을 선택하세요.", "Select files to upload."],
B01_Temp_Upload_Success: ["보관함에 저장했습니다.", "Saved to temporary storage."],
B01_Temp_Upload_Failed: ["보관함 업로드에 실패했습니다.", "Failed to upload."],
B01_Temp_Load_Failed: ["보관함을 불러오지 못했습니다.", "Failed to load storage."],
B01_Temp_Upload_Success: [
"보관함에 저장했습니다.",
"Saved to temporary storage.",
],
B01_Temp_Upload_Failed: [
"보관함 업로드에 실패했습니다.",
"Failed to upload.",
],
B01_Temp_Load_Failed: [
"보관함을 불러오지 못했습니다.",
"Failed to load storage.",
],
B01_Temp_Delete_Confirm: [
"이 보관 자료를 삭제할까요? 되돌릴 수 없습니다.",
"Delete this stored set? This cannot be undone.",
@@ -152,7 +185,10 @@ export const ui_locales_b1 = {
B01_Dashboard_Modal_FindCompany: ["회사 검색", "Find company"],
B01_Dashboard_Modal_AddMember: ["팀원 추가", "Add member"],
B01_Dashboard_Saved: ["저장되었습니다.", "Saved."],
B01_Dashboard_LoadFailed: ["대시보드를 불러오지 못했습니다.", "Failed to load dashboard."],
B01_Dashboard_LoadFailed: [
"대시보드를 불러오지 못했습니다.",
"Failed to load dashboard.",
],
B01_Dashboard_RequestFailed: ["요청 처리에 실패했습니다.", "Request failed."],
// 프로젝트 관리
@@ -164,7 +200,10 @@ export const ui_locales_b1 = {
B01_Dashboard_EditUser: ["사용자 수정", "Edit User"],
B01_Dashboard_DeleteUser: ["사용자 삭제", "Delete User"],
B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"],
B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"],
B01_Dashboard_SelectAvailableUsers: [
"사용 가능한 사용자 선택",
"Select Available Users",
],
// 확인 메시지
B01_Dashboard_Confirm_DeleteProject: [
@@ -176,7 +215,10 @@ export const ui_locales_b1 = {
"[하드 삭제 모드] 업로드한 라이다 원본과 모든 계산 결과가 서버에서 영구 삭제됩니다. 복구할 수 없습니다. 삭제하시겠습니까?",
"[Hard delete mode] The uploaded LiDAR source and every computed result will be permanently erased from the server. This cannot be recovered. Delete anyway?",
],
B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"],
B01_Dashboard_Confirm_DeleteUser: [
"사용자를 삭제하시겠습니까?",
"Delete this user?",
],
B01_Dashboard_Confirm_LastAdmin: [
"회사의 유일한 관리자는 삭제할 수 없습니다.",
"Cannot delete the last admin of the company.",
@@ -207,15 +249,24 @@ export const ui_locales_b1 = {
B02_Proj_RoadType_Work: ["작업임도", "Work forest road"],
B02_Proj_Field_Year: ["사업 연도", "Project year"],
B02_Proj_Field_Length: ["예상 연장 (m)", "Estimated length (m)"],
B02_Proj_Field_Length_Placeholder: ["예상 노선 길이", "Estimated route length"],
B02_Proj_Field_Length_Placeholder: [
"예상 노선 길이",
"Estimated route length",
],
B02_Proj_Field_Memo: ["비고", "Notes"],
B02_Proj_Field_Memo_Placeholder: ["추가 메모 (선택)", "Additional notes (optional)"],
B02_Proj_Field_Memo_Placeholder: [
"추가 메모 (선택)",
"Additional notes (optional)",
],
B02_Proj_Submit: ["프로젝트 생성", "Create project"],
B02_Proj_Success: [
"프로젝트가 생성되었습니다. 파일 입력 단계로 이동합니다.",
"Project created. Moving to the file input step.",
],
B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."],
B02_Proj_Error_Required: [
"필수 항목을 입력하세요.",
"Please fill in required fields.",
],
/* --- B03_FileInput 파일 입력 --- */
B03_File_Title: ["파일입력", "File Input"],
@@ -236,7 +287,10 @@ export const ui_locales_b1 = {
"현재 프로젝트가 선택되지 않았습니다. 프로젝트를 먼저 생성하거나 선택하세요.",
"No current project is selected. Create or select a project first.",
],
B03_File_Error_Required: ["업로드할 파일을 선택하세요.", "Select files to upload."],
B03_File_Error_Required: [
"업로드할 파일을 선택하세요.",
"Select files to upload.",
],
B03_File_Error_Count: [
"한 번에 업로드할 수 있는 파일 수를 초과했습니다.",
"Too many files were selected for one upload.",
@@ -245,10 +299,26 @@ export const ui_locales_b1 = {
"LAS 또는 LAZ 파일을 정확히 1개 선택하세요.",
"Select exactly one LAS or LAZ file.",
],
B03_File_Error_Extension: ["허용되지 않은 파일 형식입니다.", "Unsupported file type."],
B03_File_Error_Size: ["파일 크기 제한을 초과했습니다.", "File size limit exceeded."],
B03_File_Upload_Success: ["입력 파일 업로드를 완료했습니다.", "Input files uploaded."],
B03_File_Upload_Failed: ["파일 업로드에 실패했습니다.", "File upload failed."],
B03_File_Error_LasFreeBlocked: [
"LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 넣을 수 없습니다.",
"LAS/LAZ files cannot be added while designing without LAS.",
],
B03_File_Error_Extension: [
"허용되지 않은 파일 형식입니다.",
"Unsupported file type.",
],
B03_File_Error_Size: [
"파일 크기 제한을 초과했습니다.",
"File size limit exceeded.",
],
B03_File_Upload_Success: [
"입력 파일 업로드를 완료했습니다.",
"Input files uploaded.",
],
B03_File_Upload_Failed: [
"파일 업로드에 실패했습니다.",
"File upload failed.",
],
B03_File_Analysis_InProgress: [
"WF1 분석이 백그라운드에서 진행 중입니다. 완료되면 자동으로 이동합니다.",
"WF1 analysis is running in the background. You will move automatically when it completes.",
@@ -264,12 +334,23 @@ export const ui_locales_b1 = {
B03_File_Group_Inputs: ["입력 자료", "Input files"],
B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"],
B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"],
B03_File_LasFree_Toggle: [
"LAS 없이 설계 (도엽등고선 기반)",
"Design without LAS (map sheet contours)",
],
B03_File_LasFree_Hint: [
"포인트클라우드 없이 1:5,000 수치지형도 등고선으로 지형을 만듭니다.",
"Terrain is built from 1:5,000 map sheet contours without a point cloud.",
],
B03_File_Slot_Projection: ["좌표계 정의", "Projection"],
B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"],
B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"],
B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"],
/* --- B03 임시 보관함 불러오기 (2026-08-08) --- */
B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"],
B03_Temp_Btn_Open: [
"임시 보관함에서 불러오기",
"Load from temporary storage",
],
B03_Temp_None: ["선택된 보관 자료 없음", "No stored set selected"],
B03_Temp_Selected: ["선택됨:", "Selected:"],
B03_Temp_FileCount: ["개 파일", " files"],
@@ -279,12 +360,18 @@ export const ui_locales_b1 = {
"No usable stored set. Upload all required files in the dashboard temporary storage first.",
],
B03_Temp_Select_Required: ["보관 자료를 선택하세요.", "Select a stored set."],
B03_Temp_Load_Failed: ["보관 자료를 불러오지 못했습니다.", "Failed to load stored sets."],
B03_Temp_Load_Failed: [
"보관 자료를 불러오지 못했습니다.",
"Failed to load stored sets.",
],
B03_Temp_Attach_Success: [
"보관 자료를 프로젝트로 옮겼습니다. 분석을 시작합니다.",
"Stored files moved to the project. Analysis started.",
],
B03_Temp_Attach_Failed: ["보관 자료 연결에 실패했습니다.", "Failed to attach stored files."],
B03_Temp_Attach_Failed: [
"보관 자료 연결에 실패했습니다.",
"Failed to attach stored files.",
],
B03_Temp_Attach_NoAnalysis: [
"파일은 옮겼지만 라이다 파일이 없어 분석을 시작하지 못했습니다.",
"Files moved, but analysis did not start (no point cloud file).",
@@ -313,7 +400,10 @@ export const ui_locales_b1 = {
B03_File_Status_Completed: ["완료", "Completed"],
B03_File_Status_Failed: ["실패", "Failed"],
B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"],
B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"],
B03_File_Restore_State: [
"저장된 업로드/분석 상태 복구",
"Restored upload/analysis state",
],
B03_File_Resume_Button: ["업로드 재개", "Resume upload"],
B03_File_New_Button: ["새 파일로 시작", "Start new file"],
B03_File_Overview_Complete: [
@@ -350,6 +440,15 @@ export const ui_locales_b1 = {
B04_Surface_Group_Filters: ["지면 필터", "Ground filter"],
B04_Surface_Group_Methods: ["서피스", "Surface"],
B04_Surface_Group_Display: ["모델 표시 옵션", "Model display options"],
B04_Surface_SheetSurface: [
"도엽등고 3D 서피스",
"Map-sheet contour 3D surface",
],
B04_Surface_SheetLidar: ["라이다 겹쳐 보기", "Overlay LiDAR"],
B04_Surface_SheetLidar_Missing: [
"겹쳐 볼 라이다 지표면 모델이 없습니다.",
"No LiDAR surface model available to overlay.",
],
B04_Surface_Group_ViewControls: ["뷰어 시점 제어", "Viewer camera"],
B04_Surface_Field_Smoothing: ["스무딩", "Smoothing"],
B04_Surface_Smoothing_On: ["적용", "On"],
@@ -371,10 +470,16 @@ export const ui_locales_b1 = {
B04_Surface_Input_FileName: ["파일명", "File name"],
B04_Surface_Input_Crs: ["좌표계", "CRS"],
B04_Surface_Input_Size: ["크기(MB)", "Size (MB)"],
B04_Surface_PointCloud_Title: ["포인트클라우드 미리보기", "Point cloud preview"],
B04_Surface_PointCloud_Title: [
"포인트클라우드 미리보기",
"Point cloud preview",
],
B04_Surface_Status_Unknown: ["상태 미확인", "Unknown"],
B04_Surface_GroundStats_Title: ["지면 필터 통계", "Ground filter stats"],
B04_Surface_GroundStats_Empty: ["표시할 지면 통계가 없습니다.", "No ground stats to display."],
B04_Surface_GroundStats_Empty: [
"표시할 지면 통계가 없습니다.",
"No ground stats to display.",
],
B04_Surface_GroundStats_Filter: ["필터", "Filter"],
B04_Surface_GroundStats_SourcePoints: ["지면 포인트", "Ground points"],
B04_Surface_Result_Title: ["생성된 지표면 모델", "Generated Surface Models"],
@@ -392,8 +497,14 @@ export const ui_locales_b1 = {
"모델을 확정했습니다. 필터: {filter}, 기법: {method}, 스무딩/표현: {smoothing}",
"Model confirmed. Filter: {filter}, method: {method}, smoothing/representation: {smoothing}",
],
B04_Surface_Confirm_Failed: ["모델 확정에 실패했습니다.", "Failed to confirm model."],
B04_Surface_Map_Title: ["2D 배경 지도 및 GIS 레이어", "2D Basemap and GIS Layers"],
B04_Surface_Confirm_Failed: [
"모델 확정에 실패했습니다.",
"Failed to confirm model.",
],
B04_Surface_Map_Title: [
"2D 배경 지도 및 GIS 레이어",
"2D Basemap and GIS Layers",
],
B04_Surface_Map_Background: ["배경 지도", "Basemap"],
B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"],
B04_Surface_Map_None: ["없음", "None"],
@@ -424,14 +535,20 @@ export const ui_locales_b1 = {
"Failed to load the drainage analysis.",
],
/* {message}=원인 */
B04_Surface_Watershed_Failed: ["유역 분석 실패: {message}", "Basin analysis failed: {message}"],
B04_Surface_Watershed_Failed: [
"유역 분석 실패: {message}",
"Basin analysis failed: {message}",
],
B04_Surface_Watershed_NoSaved: [
"저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.",
"No stored drainage analysis. Press [Basin analysis].",
],
B04_Surface_Watershed_Origin_Cached: ["저장분", "Cached"],
/* {seconds}=재산정에 걸린 시간(초) */
B04_Surface_Watershed_Origin_Recomputed: ["재산정 {seconds}초", "Recomputed in {seconds}s"],
B04_Surface_Watershed_Origin_Recomputed: [
"재산정 {seconds}초",
"Recomputed in {seconds}s",
],
/* 도로 유입 흐름 강도 */
B04_Surface_Flow_Strength: ["흐름 강도", "Flow strength"],
B04_Surface_Flow_Strength_Tip: [
@@ -444,7 +561,10 @@ export const ui_locales_b1 = {
"노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
"Marks the spots along the route that collect the most water. Click a marker to outline the cells draining into it.",
],
B04_Surface_Flow_Inflow_Loading: ["유입 셀을 불러오는 중…", "Loading the contributing cells…"],
B04_Surface_Flow_Inflow_Loading: [
"유입 셀을 불러오는 중…",
"Loading the contributing cells…",
],
/* {index}=마커 번호, {chainage}=누가거리, {area}=유입면적, {cells}=셀 수, {path}=최장 유하장 */
B04_Surface_Flow_Inflow_Summary: [
"유입 집중점 {index} · 측점 {chainage}m — 유입면적 {area} · 셀 {cells}개 · 최장 유하장 {path}m",
@@ -516,16 +636,37 @@ export const ui_locales_b1 = {
"배경 지도 또는 GIS 레이어를 선택하세요.",
"Select a basemap or GIS layer.",
],
B04_Surface_Map_Loading: ["지도 레이어를 불러오는 중입니다.", "Loading map layers."],
B04_Surface_Map_Loading: [
"지도 레이어를 불러오는 중입니다.",
"Loading map layers.",
],
B04_Surface_Map_Features: ["{count}개 객체 표시", "Showing {count} features"],
B04_Surface_Map_LoadFailed: ["지도 레이어를 불러오지 못했습니다.", "Failed to load map."],
B04_Surface_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
B04_Surface_Error_InputId: ["유효한 입력 파일 ID를 입력하세요.", "Enter a valid input file ID."],
B04_Surface_Map_LoadFailed: [
"지도 레이어를 불러오지 못했습니다.",
"Failed to load map.",
],
B04_Surface_Error_Project: [
"먼저 프로젝트를 선택하세요.",
"Select a project first.",
],
B04_Surface_Error_InputId: [
"유효한 입력 파일 ID를 입력하세요.",
"Enter a valid input file ID.",
],
B04_Surface_Error_Selection: [
"지면 필터와 지표면 표현을 각각 1개 이상 선택하세요.",
"Select at least one filter and one method.",
],
B04_Surface_Analyze_Success: ["지표면 분석을 완료했습니다.", "Surface analysis complete."],
B04_Surface_Analyze_Failed: ["지표면 분석에 실패했습니다.", "Surface analysis failed."],
B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."],
B04_Surface_Analyze_Success: [
"지표면 분석을 완료했습니다.",
"Surface analysis complete.",
],
B04_Surface_Analyze_Failed: [
"지표면 분석에 실패했습니다.",
"Surface analysis failed.",
],
B04_Surface_Load_Failed: [
"모델 목록을 불러오지 못했습니다.",
"Failed to load models.",
],
} as const satisfies Record<string, LocaleEntry>;
+6 -3
View File
@@ -2,6 +2,7 @@ import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const apiTarget = `http://localhost:${process.env.AISLO_API_PORT ?? "8000"}`;
/**
* Vite 설정 — 임도 설계 웹앱 (Vanilla TS + Three.js)
@@ -26,15 +27,17 @@ export default {
server: {
port: 5173,
open: false,
// 백엔드(FastAPI) 프록시 — config_frontend.ts의 API_BASE_URL과 정합
// 백엔드(FastAPI) 프록시 — config_frontend.ts의 API_BASE_URL과 정합.
// 포트는 AISLO_API_PORT로 바꿀 수 있다(기본 8000) — 워크트리를 나눠 쓰는 병행
// 작업에서 각자 자기 백엔드를 보게 하려는 것이다(2026-08-30, CLAUDE.md 7장).
proxy: {
"/api": {
target: "http://localhost:8000",
target: apiTarget,
changeOrigin: true,
},
// B07 독립형 CAD 앱 — FastAPI 정적 마운트로 위임
"/b07-cad": {
target: "http://localhost:8000",
target: apiTarget,
changeOrigin: true,
},
},