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) 형상을 정확히
재현하고 계단이 생기지 않는다. 표고별 거리장을 돌며 가장 작은 값을 추적하므로
L1L2가 보장된다.
"""
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`)
바이큐빅 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,
},
},