Merge remote-tracking branch 'origin/feat/b07-cover-template' into sub_laptop_1
This commit is contained in:
@@ -21,9 +21,8 @@ from config.config_system import (
|
||||
AUTO_DESIGN_CHAIN_ENABLED,
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
SHEET_SURFACE_DEFAULT_METHOD,
|
||||
SURFACE_AUTO_METHODS,
|
||||
SURFACE_CONTOUR_INTERVAL_M,
|
||||
SURFACE_MODEL_PRECOMPUTE,
|
||||
SURFACE_MODEL_SOURCE_FILTERS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,11 +61,13 @@ async def trigger_wf1_analysis_and_email(
|
||||
pool = get_db_pool()
|
||||
project_info: dict[str, Any] | None = None
|
||||
try:
|
||||
source_filters = list(SURFACE_MODEL_SOURCE_FILTERS)
|
||||
methods = list(SURFACE_MODEL_PRECOMPUTE)
|
||||
# 자동 전처리는 기본 조합만 만든다 — 필터는 엔진이 입력 LAS를 보고 정하고
|
||||
# (분류 있으면 classification, 없으면 csf), 표현은 SURFACE_AUTO_METHODS.
|
||||
# 다른 조합은 관리자가 B04 드롭다운에서 요청할 때 만든다 (2026-09-01 사용자 확정).
|
||||
methods = list(SURFACE_AUTO_METHODS)
|
||||
params = {
|
||||
"input_file_id": str(input_file_id),
|
||||
"source_filters": source_filters,
|
||||
"source_filters": None,
|
||||
"methods": methods,
|
||||
"force": False,
|
||||
}
|
||||
@@ -116,12 +117,17 @@ async def trigger_wf1_analysis_and_email(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
source_path,
|
||||
source_filters=source_filters,
|
||||
source_filters=None,
|
||||
methods=methods,
|
||||
force=False,
|
||||
on_progress=_on_progress,
|
||||
)
|
||||
|
||||
# 엔진이 정한 기본 필터 — LAS 없는 설계(도엽 서피스)는 이 키가 없다.
|
||||
resolved_filters = list(analysis_result.get("source_filters") or [])
|
||||
if not resolved_filters and las_free:
|
||||
resolved_filters = [f"sheet_{SHEET_SURFACE_DEFAULT_METHOD}"]
|
||||
|
||||
auto_confirmation_error: str | None = None
|
||||
auto_confirmed = False
|
||||
confirmed_model_id: int | None = None
|
||||
@@ -133,7 +139,7 @@ async def trigger_wf1_analysis_and_email(
|
||||
project_id=project_id,
|
||||
input_file_id=input_file_id,
|
||||
analysis_result=analysis_result,
|
||||
source_filters=source_filters,
|
||||
source_filters=resolved_filters,
|
||||
)
|
||||
# 역할 무관 자동 확정(2026-08-04 사용자 확정) — 시스템 관리자도 같은
|
||||
# 사용자다. 모두 기본값으로 자동 확정하고 B05·B06 자동 계산 체인까지 탄다.
|
||||
@@ -147,16 +153,20 @@ async def trigger_wf1_analysis_and_email(
|
||||
# LAS 없는 설계는 도엽 서피스 모델(sheet/dtm)로 확정한다.
|
||||
# 스무딩은 LAS 경로와 같이 적용한다(2026-08-30 사용자 확정) — 방식마다
|
||||
# `dtm_sheet_*_smooth.npz`를 같이 만들어 두므로 종·횡단이 그걸 샘플링한다.
|
||||
selection = (
|
||||
{
|
||||
if las_free:
|
||||
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()
|
||||
)
|
||||
else:
|
||||
# 자동 확정은 엔진이 정한 기본 필터를 그대로 따른다 — 이제 그 필터
|
||||
# 하나만 만들어 두므로 config 기본값과 어긋나면 확정할 모델이 없다.
|
||||
selection = surface_confirmation_defaults()
|
||||
if resolved_filters:
|
||||
selection["source_filter"] = resolved_filters[0]
|
||||
selection["method"] = methods[0]
|
||||
try:
|
||||
model_id = await find_surface_model_for_selection(
|
||||
connection, project_id, selection
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import {
|
||||
API_ANALYSIS_TIMEOUT_MS,
|
||||
API_BASE_URL,
|
||||
API_SURFACE_BUILD_TIMEOUT_MS,
|
||||
API_TIMEOUT_MS,
|
||||
} from "@config/config_frontend";
|
||||
|
||||
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
|
||||
export interface SurfaceAnalyzeRequest {
|
||||
@@ -178,10 +183,12 @@ export async function analyzeSurface(
|
||||
projectId: string,
|
||||
request: SurfaceAnalyzeRequest,
|
||||
): Promise<SurfaceAnalyzeResponse> {
|
||||
return requestJson<SurfaceAnalyzeResponse>(`/projects/${projectId}/surface/analyze`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
// 조합 하나를 새로 만드는 요청 — 실측 114초(4,900만 점)라 분석용 60초로도 abort 된다.
|
||||
return requestJson<SurfaceAnalyzeResponse>(
|
||||
`/projects/${projectId}/surface/analyze`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
API_SURFACE_BUILD_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** 프로젝트의 지표면 모델 목록을 조회한다. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import (
|
||||
build_ground_masks,
|
||||
resolve_auto_source_filters,
|
||||
run_ground_filter,
|
||||
summarize_masks,
|
||||
)
|
||||
@@ -22,7 +23,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_mode
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Structurize import structurize_las
|
||||
from common_util.common_util_atomic import atomic_write_npz
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config.config_system import build_surface_model_config
|
||||
from config.config_system import SURFACE_GROUND_RATIO_WARN, build_surface_model_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -105,15 +106,18 @@ def run_surface_analysis(
|
||||
project_root: Path,
|
||||
las_path: Path,
|
||||
*,
|
||||
source_filters: list[str],
|
||||
source_filters: list[str] | None,
|
||||
methods: list[str],
|
||||
force: bool = False,
|
||||
on_progress: ProgressCallback | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
|
||||
|
||||
`source_filters`가 비면 입력 LAS를 보고 기본 필터를 정한다(자동 전처리 경로).
|
||||
|
||||
반환 dict:
|
||||
- processed: {processed_file_path, converted_file_path, point_count, bounds, statistics}
|
||||
- source_filters: 실제로 계산한 필터 목록 (자동 판정 결과 포함)
|
||||
- ground_summary: 필터별 지면 포인트 요약
|
||||
- manifest: 지표면 모델 파이프라인 manifest
|
||||
- models: [{model_type, model_file_path, resolution_m, generation_params, layers}]
|
||||
@@ -169,10 +173,14 @@ def run_surface_analysis(
|
||||
"y_min": float(bounds[1, 0]),
|
||||
"y_max": float(bounds[1, 1]),
|
||||
}
|
||||
data = {"xyz": xyz, "bounds": bounds}
|
||||
data = {"xyz": xyz, "bounds": bounds, "classification": structured["classification"]}
|
||||
|
||||
# 2. 지면 필터 실행 — mask_{filter}.npy 영구 캐시 우선 재사용 (PLAN A-1)
|
||||
_report(40, "ground_filter", "지면 필터 적용 중")
|
||||
# 자동 전처리는 목록을 넘기지 않는다 — 기본 필터는 입력 LAS를 보고 여기서 정한다.
|
||||
if not source_filters:
|
||||
source_filters = resolve_auto_source_filters(data)
|
||||
logger.info("B04 자동 전처리 기본 필터: %s", ", ".join(source_filters))
|
||||
masks: dict[str, np.ndarray] = {}
|
||||
for filter_key in source_filters:
|
||||
mask_path = processed_dir / f"mask_{filter_key}.npy"
|
||||
@@ -193,6 +201,17 @@ def run_surface_analysis(
|
||||
)
|
||||
masks[filter_key] = mask
|
||||
ground_summary = summarize_masks(data, masks)
|
||||
# 지면점 비율은 필터가 조용히 실패해도 유일하게 드러나는 신호다 — 반드시 남긴다.
|
||||
for filter_key, entry in ground_summary.items():
|
||||
ratio = float(entry["ground_ratio"])
|
||||
log = logger.warning if ratio < SURFACE_GROUND_RATIO_WARN else logger.info
|
||||
log(
|
||||
"B04 지면점 %s: %d / %d (%.2f%%)",
|
||||
filter_key,
|
||||
entry["ground_point_count"],
|
||||
entry["total_point_count"],
|
||||
ratio * 100,
|
||||
)
|
||||
for filter_key, mask in masks.items():
|
||||
cache_path = processed_dir / f"ground_points_{filter_key}.npz"
|
||||
if not rebuild and cache_path.is_file():
|
||||
@@ -249,7 +268,7 @@ def run_surface_analysis(
|
||||
|
||||
_report(95, "saving", "결과 저장 중")
|
||||
|
||||
return _collect_analysis_result(
|
||||
result = _collect_analysis_result(
|
||||
project_root,
|
||||
models_dir,
|
||||
structured_path,
|
||||
@@ -261,6 +280,9 @@ def run_surface_analysis(
|
||||
sheet_models,
|
||||
total_started,
|
||||
)
|
||||
# 자동 경로는 필터를 넘기지 않으므로, 무엇으로 계산했는지 호출부에 돌려준다.
|
||||
result["source_filters"] = list(source_filters)
|
||||
return result
|
||||
|
||||
|
||||
def download_geodata(
|
||||
|
||||
@@ -14,6 +14,7 @@ from config.config_system import (
|
||||
SURFACE_CSF_CLASS_THRESHOLD_M,
|
||||
SURFACE_CSF_CLOTH_RESOLUTION_M,
|
||||
SURFACE_CSF_ITERATIONS,
|
||||
SURFACE_CSF_MAX_ITERATIONS,
|
||||
SURFACE_CSF_RIGIDNESS,
|
||||
SURFACE_CSF_SLOPE_SMOOTH,
|
||||
SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M,
|
||||
@@ -35,6 +36,7 @@ def filter_csf(
|
||||
iterations: int = SURFACE_CSF_ITERATIONS,
|
||||
slope_smooth: bool = SURFACE_CSF_SLOPE_SMOOTH,
|
||||
slope_smooth_threshold: float = SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M,
|
||||
max_iterations: int = SURFACE_CSF_MAX_ITERATIONS,
|
||||
) -> np.ndarray:
|
||||
"""CSF로 지면 포인트의 불리언 마스크를 반환한다."""
|
||||
if not math.isfinite(cloth_resolution) or cloth_resolution <= 0:
|
||||
@@ -47,6 +49,8 @@ def filter_csf(
|
||||
raise ValueError("분류 임계값은 0 이상의 유한한 값이어야 합니다.")
|
||||
if iterations <= 0:
|
||||
raise ValueError("반복 횟수는 1 이상이어야 합니다.")
|
||||
if max_iterations < iterations:
|
||||
raise ValueError("반복 상한은 정착 여유 반복 수보다 작을 수 없습니다.")
|
||||
|
||||
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
|
||||
if xyz.ndim != 2 or xyz.shape[1] != 3:
|
||||
@@ -78,9 +82,15 @@ def filter_csf(
|
||||
collision_grid[collision_grid == -np.inf] = 0.0
|
||||
|
||||
# 4. 천 시뮬레이션 반복 루프 (물리 하강)
|
||||
# 반복 수는 현장 기복에서 뽑는다 — 고정 횟수는 곧 고정 하강 예산이라
|
||||
# (0.3185m × 150회 = 47.8m) 기복이 그보다 큰 산악지에서 천이 지면에 닿지
|
||||
# 못하고 공중에 멈춘다. 낙하 구간 + 스프링 정착 여유(iterations)로 잡는다.
|
||||
gravity = _GRAVITY_BASE * time_step
|
||||
spring_coeff = _RIGIDNESS_SPRING_COEFF[rigidness]
|
||||
for _ in range(iterations):
|
||||
required_drop = start_height - float(np.min(collision_grid))
|
||||
total_iterations = int(math.ceil(max(required_drop, 0.0) / gravity)) + iterations
|
||||
total_iterations = min(total_iterations, max_iterations)
|
||||
for _ in range(total_iterations):
|
||||
cloth_z -= gravity
|
||||
cloth_z = np.maximum(cloth_z, collision_grid)
|
||||
|
||||
@@ -102,9 +112,11 @@ def filter_csf(
|
||||
height_diff = np.abs(inverted_zs - simulated_inverted_z)
|
||||
mask = height_diff <= class_threshold
|
||||
|
||||
# 6. 수목 노이즈 2차 필터 보정
|
||||
# 6. 수목 노이즈 2차 필터 보정 — 셀 지면 후보(반전고 최대 = 원표고 최저)보다
|
||||
# 얼마나 위에 떠 있는지를 본다. 반전 공간에서 높이는 (지면 후보 - 자기 반전고)다.
|
||||
# 반대로 빼면 항상 0 이하가 나와 조건이 늘 참이 된다(= 필터가 무력화).
|
||||
if slope_smooth:
|
||||
local_min_z = collision_grid[gy, gx]
|
||||
mask = mask & ((inverted_zs - local_min_z) < slope_smooth_threshold)
|
||||
local_ground_z = collision_grid[gy, gx]
|
||||
mask = mask & ((local_ground_z - inverted_zs) < slope_smooth_threshold)
|
||||
|
||||
return np.asarray(mask, dtype=bool)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""B04 LAS 자체 지면분류(ASPRS class 2) 필터.
|
||||
|
||||
계산이 없다 — LAS 제작 업체가 이미 분류해 둔 지면점을 그대로 읽는다.
|
||||
분류가 없는 LAS(classification 전부 0)에서는 마스크가 비므로,
|
||||
쓸 수 있는지는 has_classified_ground()로 먼저 판정한다.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config.config_system import SURFACE_CLASSIFIED_GROUND_MIN_RATIO
|
||||
|
||||
# ASPRS 표준 지면 클래스 코드
|
||||
GROUND_CLASS_CODE = 2
|
||||
|
||||
|
||||
def filter_classification(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
) -> np.ndarray:
|
||||
"""LAS classification이 지면(2)인 포인트의 불리언 마스크를 반환한다."""
|
||||
if "classification" not in structured_data:
|
||||
raise ValueError("구조화 데이터에 classification 배열이 없습니다.")
|
||||
classification = np.asarray(structured_data["classification"])
|
||||
return np.asarray(classification == GROUND_CLASS_CODE, dtype=bool)
|
||||
|
||||
|
||||
def has_classified_ground(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
minimum_ratio: float = SURFACE_CLASSIFIED_GROUND_MIN_RATIO,
|
||||
) -> bool:
|
||||
"""LAS에 쓸 만한 지면분류가 들어 있는지 판정한다."""
|
||||
if "classification" not in structured_data:
|
||||
return False
|
||||
classification = np.asarray(structured_data["classification"])
|
||||
total = int(classification.size)
|
||||
if not total:
|
||||
return False
|
||||
ground = int(np.count_nonzero(classification == GROUND_CLASS_CODE))
|
||||
return ground / total >= minimum_ratio
|
||||
@@ -28,24 +28,21 @@ def filter_grid_min_z(
|
||||
if xyz.shape[0] == 0:
|
||||
return np.zeros(0, dtype=bool)
|
||||
|
||||
x_min, y_min, z_min = bounds[0, 0], bounds[1, 0], bounds[2, 0]
|
||||
x_min, y_min = bounds[0, 0], bounds[1, 0]
|
||||
x_max, y_max = bounds[0, 1], bounds[1, 1]
|
||||
grid_width = int(np.ceil((x_max - x_min) / cell_size)) + 2
|
||||
grid_height = int(np.ceil((y_max - y_min) / cell_size)) + 2
|
||||
minimum_z = np.full((grid_height, grid_width), np.inf, dtype=np.float32)
|
||||
# float32로 담으면 셀 최저점(가장 확실한 지면점)이 반올림 때문에 자기 기준면보다
|
||||
# 낮아져 height_above < 0 으로 탈락한다 — 원본 좌표와 같은 float64로 둔다.
|
||||
minimum_z = np.full((grid_height, grid_width), np.inf, dtype=np.float64)
|
||||
|
||||
grid_x = np.clip(((xyz[:, 0] - x_min) / cell_size).astype(np.int64), 0, grid_width - 1)
|
||||
grid_y = np.clip(((xyz[:, 1] - y_min) / cell_size).astype(np.int64), 0, grid_height - 1)
|
||||
np.minimum.at(minimum_z, (grid_y, grid_x), xyz[:, 2])
|
||||
minimum_z[np.isinf(minimum_z)] = z_min
|
||||
|
||||
try:
|
||||
from scipy.ndimage import minimum_filter
|
||||
|
||||
minimum_z = minimum_filter(minimum_z, size=3).astype(np.float32)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 기준면은 점이 실제로 들어 있는 셀에서만 읽는다 — 3x3 최소필터로 이웃 셀 값을
|
||||
# 끌어오면 급경사에서 기준면이 경사만큼 파고들어(중앙 1.88m > 임계 1.5m) 지면점이
|
||||
# 통째로 탈락한다. 빈 셀은 아래 인덱싱에서 참조되지 않으므로 채울 필요가 없다.
|
||||
height_above = xyz[:, 2] - minimum_z[grid_y, grid_x]
|
||||
return np.asarray(
|
||||
(height_above >= 0.0) & (height_above <= height_threshold),
|
||||
|
||||
@@ -9,10 +9,15 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
|
||||
filter_classification,
|
||||
has_classified_ground,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Grid import filter_grid_min_z
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_PMF import filter_pmf
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_RANSAC import filter_ransac
|
||||
from config.config_system import SURFACE_AUTO_FALLBACK_FILTER
|
||||
|
||||
# 필터 키 → 함수 매핑
|
||||
_FILTERS = {
|
||||
@@ -20,13 +25,37 @@ _FILTERS = {
|
||||
"csf": filter_csf,
|
||||
"pmf": filter_pmf,
|
||||
"ransac": filter_ransac,
|
||||
"classification": filter_classification,
|
||||
}
|
||||
|
||||
# LAS에 지면분류가 실제로 들어 있을 때만 쓰는 필터 (미분류 LAS에서는 마스크가 빈다)
|
||||
_CONDITIONAL_FILTERS = {"classification": has_classified_ground}
|
||||
|
||||
|
||||
def available_filters() -> tuple[str, ...]:
|
||||
return tuple(_FILTERS.keys())
|
||||
|
||||
|
||||
def usable_conditional_filters(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
) -> list[str]:
|
||||
"""이 LAS에서 실제로 쓸 수 있는 조건부 필터를 고른다 (드롭다운 노출용)."""
|
||||
return [key for key, is_usable in _CONDITIONAL_FILTERS.items() if is_usable(structured_data)]
|
||||
|
||||
|
||||
def resolve_auto_source_filters(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
) -> list[str]:
|
||||
"""자동 전처리가 만들 필터를 입력 LAS를 보고 정한다. 첫 항목이 기본 확정값이다.
|
||||
|
||||
업체가 이미 지면을 분류해 놨으면 그것을 기본으로 쓴다 — 계산이 없고 품질도 가장
|
||||
안정적이다. **csf는 분류가 있어도 함께 만든다** — csf 처리 결과를 눈으로 검증할 수
|
||||
있어야 하기 때문이다 (2026-09-01 사용자 확정). 분류가 없으면 csf 하나만 남는다.
|
||||
"""
|
||||
usable = usable_conditional_filters(structured_data)
|
||||
return [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
|
||||
|
||||
|
||||
def run_ground_filter(
|
||||
filter_key: str, structured_data: dict[str, Any] | np.lib.npyio.NpzFile
|
||||
) -> np.ndarray:
|
||||
|
||||
@@ -87,12 +87,20 @@ def evaluate_nurbs_spline(
|
||||
return np.clip(z_values, low, high)
|
||||
|
||||
|
||||
# 서명에서 빼는 키 — 지오메트리를 만드는 방법이 아니라 "무엇을 만들지"를 고르는 값들.
|
||||
# source_filters·precompute를 넣으면 조합을 바꿀 때마다 manifest가 통째로 폐기돼
|
||||
# 이전에 만들어 둔 조합이 사라진다. manifest는 이미 필터·방식별로 키가 갈린다.
|
||||
_SIGNATURE_EXCLUDED_KEYS = {"source_filters", "precompute"}
|
||||
|
||||
|
||||
def config_signature(config: dict[str, Any]) -> str:
|
||||
"""지오메트리 원본과 무관한 등고선·스무딩 설정을 제외한 캐시 서명."""
|
||||
"""지오메트리 생성 방법만 남긴 캐시 서명 (선택 목록·등고선·스무딩 제외)."""
|
||||
sig_config = {
|
||||
k: v
|
||||
for k, v in config.items()
|
||||
if not k.startswith("contour_") and not k.startswith("smoothing_")
|
||||
if not k.startswith("contour_")
|
||||
and not k.startswith("smoothing_")
|
||||
and k not in _SIGNATURE_EXCLUDED_KEYS
|
||||
}
|
||||
encoded = json.dumps(sig_config, sort_keys=True, default=list).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()[:16]
|
||||
|
||||
@@ -9,7 +9,9 @@ from config.config_system import (
|
||||
SURFACE_MODEL_SOURCE_FILTERS,
|
||||
)
|
||||
|
||||
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {"ransac"}
|
||||
# classification은 LAS가 지면분류를 싣고 있을 때 엔진이 자동으로 붙이지만,
|
||||
# 재계산 요청에서 이름으로 지정하는 것도 허용한다.
|
||||
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {"ransac", "classification"}
|
||||
_ALLOWED_METHODS = set(SURFACE_MODEL_PRECOMPUTE)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createButton,
|
||||
createTag,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
analyzeSurface,
|
||||
confirmSurfaceModel,
|
||||
fetchConfirmedSurface,
|
||||
fetchSurfacePointCloud,
|
||||
@@ -38,7 +40,9 @@ import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer";
|
||||
import { createSurfacePointCloudViewer } from "./B04_PreProcess_UI_Viewer";
|
||||
import "./B04_PreProcess_UI_Style.css";
|
||||
|
||||
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf"] as const;
|
||||
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
|
||||
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
|
||||
const SOURCE_FILTERS = ["classification", "grid_min_z", "csf", "pmf"] as const;
|
||||
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
|
||||
const DEFAULT_FILTER = "csf";
|
||||
const DEFAULT_METHOD = "dtm";
|
||||
@@ -155,6 +159,9 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
MODEL_METHODS,
|
||||
DEFAULT_METHOD,
|
||||
);
|
||||
// 되돌릴 값을 기억해 둔다 — 모달에서 취소하면 드롭다운을 원래 자리로 돌린다.
|
||||
let appliedFilter = DEFAULT_FILTER;
|
||||
let appliedMethod = DEFAULT_METHOD;
|
||||
const viewer = createSurfacePointCloudViewer();
|
||||
const terrainViewer = createSurfaceTerrainViewer();
|
||||
const mapViewer = createSurfaceMapViewer();
|
||||
@@ -411,6 +418,57 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 조합이 영구 저장돼 있으면 그대로 쓰고, 없으면 물어본 뒤 그 조합만 만든다. */
|
||||
async function ensureCombinationBuilt(
|
||||
previousFilter: string,
|
||||
previousMethod: string,
|
||||
): Promise<boolean> {
|
||||
const projectId = getProjectId();
|
||||
const filter = filterGroup.select.value;
|
||||
const method = methodGroup.select.value;
|
||||
if (!projectId || findSelectedModel()) return true;
|
||||
if (!selectedInputFile) {
|
||||
showToast(L("B04_Surface_Load_Failed"), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = L("B04_Surface_Build_Confirm")
|
||||
.replace("{filter}", filter)
|
||||
.replace("{method}", method);
|
||||
if (!(await showConfirmDialog(message, L("B04_Surface_Build_Action")))) {
|
||||
filterGroup.select.value = previousFilter;
|
||||
methodGroup.select.value = previousMethod;
|
||||
return false;
|
||||
}
|
||||
|
||||
showLoadingOverlay();
|
||||
showToast(L("B04_Surface_Build_Running"), "info");
|
||||
try {
|
||||
await analyzeSurface(projectId, {
|
||||
input_file_id: selectedInputFile.id,
|
||||
source_filters: [filter],
|
||||
methods: [method],
|
||||
force: false,
|
||||
});
|
||||
models = (await listSurfaceModels(projectId)).models;
|
||||
showToast(
|
||||
L("B04_Surface_Build_Done")
|
||||
.replace("{filter}", filter)
|
||||
.replace("{method}", method),
|
||||
"success",
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "";
|
||||
showToast(`${L("B04_Surface_Build_Failed")} ${detail}`, "error");
|
||||
filterGroup.select.value = previousFilter;
|
||||
methodGroup.select.value = previousMethod;
|
||||
return false;
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectedModel(): void {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
@@ -591,6 +649,9 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
} catch {
|
||||
showToast(L("B04_Surface_Load_Failed"), "error");
|
||||
} finally {
|
||||
// 확정값이 드롭다운에 반영된 뒤이므로, 되돌릴 기준도 여기서 다시 맞춘다.
|
||||
appliedFilter = filterGroup.select.value;
|
||||
appliedMethod = methodGroup.select.value;
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -601,10 +662,24 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
renderInputInfo();
|
||||
});
|
||||
filterGroup.select.addEventListener("change", () => {
|
||||
updateSelectedModel();
|
||||
void updatePointCloudForFilter();
|
||||
void (async () => {
|
||||
if (!(await ensureCombinationBuilt(appliedFilter, appliedMethod))) {
|
||||
updateSelectedModel();
|
||||
return;
|
||||
}
|
||||
appliedFilter = filterGroup.select.value;
|
||||
updateSelectedModel();
|
||||
await updatePointCloudForFilter();
|
||||
})();
|
||||
});
|
||||
methodGroup.select.addEventListener("change", () => {
|
||||
void (async () => {
|
||||
if (await ensureCombinationBuilt(appliedFilter, appliedMethod)) {
|
||||
appliedMethod = methodGroup.select.value;
|
||||
}
|
||||
updateSelectedModel();
|
||||
})();
|
||||
});
|
||||
methodGroup.select.addEventListener("change", updateSelectedModel);
|
||||
|
||||
root.replaceChildren(layout.root);
|
||||
const projectId = getProjectId();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
|
||||
export interface DesignDrawingItem {
|
||||
id: string;
|
||||
kind: "longitudinal" | "cross" | "mass_haul" | "watershed";
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
confirmed: boolean;
|
||||
@@ -73,7 +73,7 @@ export interface DesignDrawingResponse {
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
id: string;
|
||||
kind: "longitudinal" | "cross" | "mass_haul" | "watershed";
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
confirmed: boolean;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""B07 표지 도면 — 템플릿 한 장을 그대로 도면으로 낸다 (2026-08-31 신설).
|
||||
|
||||
다른 도면과 다르게 **설계 자료가 없다**. 표지는 A1 종이에 고정 배치된 글자와 띠뿐이라
|
||||
콘텐츠를 감쌀 일도, 척도를 맞출 일도 없다. 그래서 도각(`frame_entities()`)을 두르지
|
||||
않고 `00_template_cover.json` 을 실치수 1:1 로 싣는다 — 사용자 확정(2026-08-31)
|
||||
"표지를 일단 전체가 템플릿이 되면 좋겠어" · "도각 없는 전면 디자인".
|
||||
|
||||
`frame_entities()` 를 재사용하지 않는 이유는 두 가지다.
|
||||
① 표지는 변환이 필요 없다(A1 실치수 고정).
|
||||
② `_transform_entity()` 가 옮기는 좌표 키는 `startPoint·endPoint·basePoint·point·
|
||||
center` 뿐이라 표지의 굵은 띠(`Hatch`)가 쓰는 `points` 배열을 **못 옮긴다**.
|
||||
띠를 `Hatch` 로 낸 것은 `lineWidth` 가 캔버스 화면 픽셀이라 실치수 두께를 못 내기
|
||||
때문이다(`screenCanvas.drawController.ts:261`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid5
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_ENTITY_NS,
|
||||
DRAWING_FORMAT,
|
||||
FRAME_LAYER_ID,
|
||||
_layer,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
_fill_placeholders,
|
||||
_load_template,
|
||||
)
|
||||
|
||||
COVER_TEMPLATE = "00_template_cover"
|
||||
|
||||
|
||||
def build_cover_drawing(drawing_id: str, fields: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
"""표지 도면 문서. 템플릿이 없으면 빈 도면을 낸다.
|
||||
|
||||
`fields` 의 `{{키}}` 는 도각과 같은 규약으로 치환하고, 값이 없으면 빈칸으로 둔다
|
||||
(남의 값이 남지 않는다). 값 공급은 다음 판(메타 배선) 몫이다.
|
||||
"""
|
||||
template = _load_template(COVER_TEMPLATE) or {}
|
||||
entities: list[dict[str, Any]] = []
|
||||
for index, entity in enumerate(template.get("entities", [])):
|
||||
placed = dict(entity)
|
||||
placed["id"] = str(uuid5(_ENTITY_NS, f"{drawing_id}:cover:{index}"))
|
||||
placed["layerId"] = FRAME_LAYER_ID
|
||||
shape = entity.get("shapeData")
|
||||
if isinstance(shape, dict):
|
||||
placed["shapeData"] = dict(shape)
|
||||
entities.append(placed)
|
||||
_fill_placeholders(entities, fields or {})
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"layers": [_layer(FRAME_LAYER_ID, "도각", locked=True)],
|
||||
}
|
||||
@@ -22,6 +22,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing, map_area_mm
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
@@ -52,6 +53,7 @@ _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
||||
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
|
||||
MASS_HAUL_ID = "mass_haul"
|
||||
WATERSHED_ID = "watershed"
|
||||
COVER_ID = "cover"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
@@ -300,6 +302,7 @@ def _drawing_list(
|
||||
)
|
||||
# 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다).
|
||||
for drawing_id, kind, label in (
|
||||
(COVER_ID, "cover", "표지"),
|
||||
(MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"),
|
||||
(WATERSHED_ID, "watershed", "유역도(배수 유역도)"),
|
||||
):
|
||||
@@ -490,7 +493,7 @@ def _read_drawing(
|
||||
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
|
||||
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
|
||||
if saved.get("format") == DRAWING_FORMAT:
|
||||
if drawing_id in (MASS_HAUL_ID, WATERSHED_ID):
|
||||
if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID):
|
||||
kind = drawing_id # id와 kind가 같은 단장 도면
|
||||
else:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
@@ -498,6 +501,10 @@ def _read_drawing(
|
||||
stored_table = manifest_entry.get("quantity_table")
|
||||
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
|
||||
return kind, label, saved, True, table
|
||||
if drawing_id == COVER_ID:
|
||||
# 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다.
|
||||
return "cover", "표지", build_cover_drawing(drawing_id), False, None
|
||||
|
||||
if drawing_id == MASS_HAUL_ID:
|
||||
# stored_design = 확정 종단 DB row의 mass_haul 산출물(라우터가 실어 준다).
|
||||
if not isinstance(stored_design, dict):
|
||||
|
||||
@@ -9,7 +9,7 @@ class DesignDrawingItem(BaseModel):
|
||||
"""B06 확정 산출물에서 노출하는 도면 메타데이터."""
|
||||
|
||||
id: str
|
||||
kind: Literal["longitudinal", "cross", "mass_haul", "watershed"]
|
||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed"]
|
||||
label: str
|
||||
chainage_m: float | None = None
|
||||
confirmed: bool = False
|
||||
@@ -31,7 +31,7 @@ class DesignDrawingResponse(BaseModel):
|
||||
project_id: str
|
||||
route_id: int
|
||||
id: str
|
||||
kind: Literal["longitudinal", "cross", "mass_haul", "watershed"]
|
||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed"]
|
||||
label: str
|
||||
drawing: dict[str, Any]
|
||||
confirmed: bool = False
|
||||
|
||||
@@ -43,7 +43,7 @@ import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
kind: "cross" | "longitudinal" | "mass_haul" | "watershed";
|
||||
kind: "cover" | "cross" | "longitudinal" | "mass_haul" | "watershed";
|
||||
title: string;
|
||||
info: string;
|
||||
confirmed: boolean;
|
||||
@@ -81,7 +81,7 @@ const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
||||
|
||||
/** 도면 구성 12분류 (2026-08-29 사용자 확정 순서). kind가 없으면 아직 만들지 않는 도면. */
|
||||
const DRAWING_GROUPS: readonly { label: string; kind?: DesignDrawingItem["kind"] }[] = [
|
||||
{ label: "표지" },
|
||||
{ label: "표지", kind: "cover" },
|
||||
{ label: "계획평면도(지형)" },
|
||||
{ label: "계획평면도(노선배치도)" },
|
||||
{ label: "계획평면도(배치도)" },
|
||||
|
||||
@@ -20,6 +20,11 @@ export const API_TIMEOUT_MS = 30_000;
|
||||
* 일반 요청에 이 값을 쓰면 장애 시 화면이 오래 멈추므로 분석 엔드포인트에만 쓴다. */
|
||||
export const API_ANALYSIS_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** 지표면 모델 한 조합을 새로 만드는 요청용 타임아웃 (ms).
|
||||
* 구조화된 점군 전체를 다시 보간하므로 실측 114초(4,900만 점, 캐시 있음)가 걸렸다.
|
||||
* 큰 LAS는 더 걸리므로 넉넉히 둔다 — 사용자에게는 모달에서 미리 알린다. */
|
||||
export const API_SURFACE_BUILD_TIMEOUT_MS = 900_000;
|
||||
|
||||
/** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */
|
||||
export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";
|
||||
|
||||
|
||||
+18
-2
@@ -106,7 +106,10 @@ SURFACE_CSF_CLOTH_RESOLUTION_M = float(os.getenv("SURFACE_CSF_CLOTH_RESOLUTION_M
|
||||
SURFACE_CSF_RIGIDNESS = int(os.getenv("SURFACE_CSF_RIGIDNESS", "1"))
|
||||
SURFACE_CSF_TIME_STEP = float(os.getenv("SURFACE_CSF_TIME_STEP", "0.65"))
|
||||
SURFACE_CSF_CLASS_THRESHOLD_M = float(os.getenv("SURFACE_CSF_CLASS_THRESHOLD_M", "0.5"))
|
||||
# 낙하 후 스프링 정착에 쓰는 여유 반복 수 (실제 낙하 구간은 현장 기복에서 계산한다)
|
||||
SURFACE_CSF_ITERATIONS = int(os.getenv("SURFACE_CSF_ITERATIONS", "150"))
|
||||
# 낙하+정착 반복 상한 — 기복 1500m(0.3185m/회)까지 커버한다
|
||||
SURFACE_CSF_MAX_ITERATIONS = int(os.getenv("SURFACE_CSF_MAX_ITERATIONS", "5000"))
|
||||
SURFACE_CSF_SLOPE_SMOOTH = os.getenv("SURFACE_CSF_SLOPE_SMOOTH", "True").lower() == "true"
|
||||
SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M = float(
|
||||
os.getenv("SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M", "1.8")
|
||||
@@ -126,13 +129,26 @@ SURFACE_RANSAC_ITERATIONS = int(os.getenv("SURFACE_RANSAC_ITERATIONS", "100"))
|
||||
SURFACE_RANSAC_LOCAL_GRID_SIZE_M = float(os.getenv("SURFACE_RANSAC_LOCAL_GRID_SIZE_M", "10.0"))
|
||||
SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42"))
|
||||
|
||||
# LAS 자체 지면분류(class 2) 필터 — 이 비율 이상 분류돼 있을 때만 필터로 제공한다.
|
||||
# 미분류 LAS는 classification이 전부 0이라 마스크가 비어 버린다.
|
||||
SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float(
|
||||
os.getenv("SURFACE_CLASSIFIED_GROUND_MIN_RATIO", "0.002")
|
||||
)
|
||||
# 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다.
|
||||
SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01"))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 빌드 대상 지면 필터·표현 방식
|
||||
# 고를 수 있는 지면 필터 전체 — 관리자가 B04 드롭다운에서 요청할 때 쓰인다.
|
||||
# 자동 전처리가 미리 만드는 조합이 아니다(그건 SURFACE_AUTO_* 가 정한다).
|
||||
SURFACE_MODEL_SOURCE_FILTERS = tuple(
|
||||
os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "grid_min_z,csf,pmf").split(",")
|
||||
os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "classification,grid_min_z,csf,pmf").split(",")
|
||||
)
|
||||
# 자동 전처리가 만드는 조합 — 기본 필터 1종 × 아래 표현. 나머지는 관리자 요청 시 만든다.
|
||||
SURFACE_AUTO_METHODS = tuple(os.getenv("SURFACE_AUTO_METHODS", "dtm").split(","))
|
||||
# 기본 필터는 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 그것을, 없으면 아래 값.
|
||||
SURFACE_AUTO_FALLBACK_FILTER = os.getenv("SURFACE_AUTO_FALLBACK_FILTER", "csf")
|
||||
# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6)
|
||||
SURFACE_MODEL_PRECOMPUTE = tuple(
|
||||
os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",")
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
{
|
||||
"format": 7,
|
||||
"source": "실무 설계도면 1쪽(울진 소광리 A3) 실측을 A1로 2배 환산 — 자체 제작",
|
||||
"entities": [
|
||||
{
|
||||
"id": "4f3a4e40-93ce-592f-9fc1-21d5bad71b58",
|
||||
"type": "Point",
|
||||
"lineColor": "#ff7f00",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지",
|
||||
"shapeData": {
|
||||
"point": {
|
||||
"x": -5.05,
|
||||
"y": -5.04
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ebd9e8d3-a2ea-58ea-a5fc-1978eff0f3d7",
|
||||
"type": "Point",
|
||||
"lineColor": "#ff7f00",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지",
|
||||
"shapeData": {
|
||||
"point": {
|
||||
"x": -5.05,
|
||||
"y": 588.96
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b8fb96e8-8503-589d-b337-eea9333c05bb",
|
||||
"type": "Point",
|
||||
"lineColor": "#ff7f00",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지",
|
||||
"shapeData": {
|
||||
"point": {
|
||||
"x": 834.95,
|
||||
"y": 588.96
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7b3f70ac-3bcd-547c-84fe-450696994a57",
|
||||
"type": "Point",
|
||||
"lineColor": "#ff7f00",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지",
|
||||
"shapeData": {
|
||||
"point": {
|
||||
"x": 834.95,
|
||||
"y": -5.04
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "57a36608-f16e-5293-838c-d81287aa80f2",
|
||||
"type": "Hatch",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지",
|
||||
"shapeData": {
|
||||
"points": [
|
||||
{
|
||||
"x": 47.55,
|
||||
"y": 561.76
|
||||
},
|
||||
{
|
||||
"x": 798.75,
|
||||
"y": 561.76
|
||||
},
|
||||
{
|
||||
"x": 798.75,
|
||||
"y": 564.36
|
||||
},
|
||||
{
|
||||
"x": 47.55,
|
||||
"y": 564.36
|
||||
},
|
||||
{
|
||||
"x": 47.55,
|
||||
"y": 561.76
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"style": "solid",
|
||||
"color": "#f5f7fa",
|
||||
"spacing": 1,
|
||||
"angle": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "12c06635-9583-5abc-8e97-7963c2587d78",
|
||||
"type": "Hatch",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지",
|
||||
"shapeData": {
|
||||
"points": [
|
||||
{
|
||||
"x": 47.55,
|
||||
"y": 19.76
|
||||
},
|
||||
{
|
||||
"x": 798.75,
|
||||
"y": 19.76
|
||||
},
|
||||
{
|
||||
"x": 798.75,
|
||||
"y": 21.76
|
||||
},
|
||||
{
|
||||
"x": 47.55,
|
||||
"y": 21.76
|
||||
},
|
||||
{
|
||||
"x": 47.55,
|
||||
"y": 19.76
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"style": "solid",
|
||||
"color": "#f5f7fa",
|
||||
"spacing": 1,
|
||||
"angle": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b87e7ca8-76a6-5530-a8bb-fb77d5b374a2",
|
||||
"type": "Text",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지 TEXT",
|
||||
"shapeData": {
|
||||
"label": "{{연도기번}}",
|
||||
"basePoint": {
|
||||
"x": 57.35,
|
||||
"y": 546.06
|
||||
},
|
||||
"options": {
|
||||
"textDirection": {
|
||||
"x": 1.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"textAlign": "left",
|
||||
"textColor": "#f5f7fa",
|
||||
"fontSize": 17.0,
|
||||
"fontFamily": "sans-serif"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "27a19191-67f5-5690-8a7c-e8b7ef5100f0",
|
||||
"type": "Text",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지 TEXT",
|
||||
"shapeData": {
|
||||
"label": "{{공사명}} 설계도",
|
||||
"basePoint": {
|
||||
"x": 788.95,
|
||||
"y": 451.36
|
||||
},
|
||||
"options": {
|
||||
"textDirection": {
|
||||
"x": 1.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"textAlign": "right",
|
||||
"textColor": "#f5f7fa",
|
||||
"fontSize": 30.0,
|
||||
"fontFamily": "sans-serif"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fd59d8c9-f563-591c-baad-111ed79bc9d4",
|
||||
"type": "Hatch",
|
||||
"lineColor": "#ff7f00",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지",
|
||||
"shapeData": {
|
||||
"points": [
|
||||
{
|
||||
"x": 602.95,
|
||||
"y": 423.76
|
||||
},
|
||||
{
|
||||
"x": 788.95,
|
||||
"y": 423.76
|
||||
},
|
||||
{
|
||||
"x": 788.95,
|
||||
"y": 425.36
|
||||
},
|
||||
{
|
||||
"x": 602.95,
|
||||
"y": 425.36
|
||||
},
|
||||
{
|
||||
"x": 602.95,
|
||||
"y": 423.76
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"style": "solid",
|
||||
"color": "#ff7f00",
|
||||
"spacing": 1,
|
||||
"angle": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "f7cdf4c3-5865-5978-a56f-793754d92efd",
|
||||
"type": "Text",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지 TEXT",
|
||||
"shapeData": {
|
||||
"label": "- 위 치 :",
|
||||
"basePoint": {
|
||||
"x": 464.95,
|
||||
"y": 364.76
|
||||
},
|
||||
"options": {
|
||||
"textDirection": {
|
||||
"x": 1.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"textAlign": "left",
|
||||
"textColor": "#f5f7fa",
|
||||
"fontSize": 14.0,
|
||||
"fontFamily": "sans-serif"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2f8ea109-735e-5462-9099-debf75d73370",
|
||||
"type": "Text",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지 TEXT",
|
||||
"shapeData": {
|
||||
"label": "{{위치}}",
|
||||
"basePoint": {
|
||||
"x": 579.95,
|
||||
"y": 364.76
|
||||
},
|
||||
"options": {
|
||||
"textDirection": {
|
||||
"x": 1.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"textAlign": "left",
|
||||
"textColor": "#f5f7fa",
|
||||
"fontSize": 14.0,
|
||||
"fontFamily": "sans-serif"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ba655100-11ba-5b9f-8da7-709315c7b5ad",
|
||||
"type": "Text",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지 TEXT",
|
||||
"shapeData": {
|
||||
"label": "- 사 업 량 :",
|
||||
"basePoint": {
|
||||
"x": 464.95,
|
||||
"y": 341.06
|
||||
},
|
||||
"options": {
|
||||
"textDirection": {
|
||||
"x": 1.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"textAlign": "left",
|
||||
"textColor": "#f5f7fa",
|
||||
"fontSize": 14.0,
|
||||
"fontFamily": "sans-serif"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "9ae6292e-58c8-590f-a574-e40fae45ae9b",
|
||||
"type": "Text",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지 TEXT",
|
||||
"shapeData": {
|
||||
"label": "{{사업량}}",
|
||||
"basePoint": {
|
||||
"x": 579.95,
|
||||
"y": 341.06
|
||||
},
|
||||
"options": {
|
||||
"textDirection": {
|
||||
"x": 1.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"textAlign": "left",
|
||||
"textColor": "#f5f7fa",
|
||||
"fontSize": 14.0,
|
||||
"fontFamily": "sans-serif"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6db8bf14-1841-5094-b5db-cc9a512d2e76",
|
||||
"type": "Text",
|
||||
"lineColor": "#f5f7fa",
|
||||
"lineWidth": 1,
|
||||
"layerId": "-00.표지 TEXT",
|
||||
"shapeData": {
|
||||
"label": "{{시행청}}",
|
||||
"basePoint": {
|
||||
"x": 784.35,
|
||||
"y": 84.06
|
||||
},
|
||||
"options": {
|
||||
"textDirection": {
|
||||
"x": 1.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"textAlign": "right",
|
||||
"textColor": "#f5f7fa",
|
||||
"fontSize": 18.0,
|
||||
"fontFamily": "sans-serif"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
{
|
||||
"id": "-00.표지 TEXT",
|
||||
"name": "-00.표지 TEXT",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
},
|
||||
{
|
||||
"id": "-00.표지",
|
||||
"name": "-00.표지",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -501,6 +501,23 @@ export const ui_locales_b1 = {
|
||||
"모델 확정에 실패했습니다.",
|
||||
"Failed to confirm model.",
|
||||
],
|
||||
B04_Surface_Build_Confirm: [
|
||||
"이 조합({filter} · {method})은 아직 만들어지지 않았습니다.\n지금 계산해 영구 저장할까요? 자료량에 따라 수 분이 걸립니다.",
|
||||
"This combination ({filter} · {method}) has not been built yet.\nBuild and store it now? This can take several minutes depending on data size.",
|
||||
],
|
||||
B04_Surface_Build_Action: ["계산하기", "Build"],
|
||||
B04_Surface_Build_Running: [
|
||||
"지표면 모델 계산 중… 창을 닫지 마세요.",
|
||||
"Building surface model… keep this window open.",
|
||||
],
|
||||
B04_Surface_Build_Done: [
|
||||
"계산 완료 — {filter} · {method} 모델을 저장했습니다.",
|
||||
"Build complete — stored the {filter} · {method} model.",
|
||||
],
|
||||
B04_Surface_Build_Failed: [
|
||||
"지표면 모델 계산에 실패했습니다.",
|
||||
"Failed to build the surface model.",
|
||||
],
|
||||
B04_Surface_Map_Title: [
|
||||
"2D 배경 지도 및 GIS 레이어",
|
||||
"2D Basemap and GIS Layers",
|
||||
|
||||
Reference in New Issue
Block a user