Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1

This commit is contained in:
2026-09-01 19:05:28 +09:00
19 changed files with 273 additions and 85 deletions
@@ -56,7 +56,7 @@ SHEET_SOURCE_FILTER = "sheet"
def _load_features_metric(
processed_dir: Path, epsg: int, filename: str = _CONTOUR_FILE
processed_dir: Path, crs: str, filename: str = _CONTOUR_FILE
) -> list[dict[str, Any]]:
"""병합 도엽 레이어(WGS84)를 읽어 사업지 CRS(m)로 재투영한다."""
path = processed_dir / filename
@@ -72,7 +72,7 @@ def _load_features_metric(
features = data.get("features")
if not isinstance(features, list):
return []
transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
def _map(coords: Any) -> Any:
if not isinstance(coords, list):
@@ -98,6 +98,28 @@ def _load_features_metric(
return converted
def _scene_bounds(project_root: Path, bounds: np.ndarray) -> np.ndarray:
"""프리뷰 메시를 놓을 **화면 원점 기준 상자**.
`write_glb()`는 넘겨받은 상자의 중심을 원점으로 삼아 정점을 옮긴다. 모델마다 자기
범위를 주면 도엽 서피스와 라이다 지표면이 서로 다른 원점에 서서, 겹쳐 보기·계획선이
어긋난다(2026-09-01 실측: 수평 7.85m·높이 1.28m). 그래서 라이다 포인트 상자가 있으면
그 상자를 같이 쓴다 — 화면이 기준으로 삼는 상자(`setReferenceBounds`)와 같은 값이다.
라이다가 없는 사업지(도엽만)는 기준이 이 서피스뿐이라 자기 상자를 그대로 쓴다.
"""
structured = project_root / "B04_PreProcess" / "processed" / "structured.npz"
if not structured.is_file():
return bounds
try:
with np.load(structured) as data:
if "bounds" not in data:
return bounds
return np.asarray(data["bounds"], dtype=float)
except (OSError, ValueError) as exc:
logger.warning("도엽 서피스: 라이다 상자를 읽지 못해 자기 범위로 놓습니다 — %s", exc)
return bounds
def _preview_mesh(
x: np.ndarray, y: np.ndarray, z: np.ndarray, valid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
@@ -125,6 +147,7 @@ def _write_smoothed(
z: np.ndarray,
valid: np.ndarray,
bounds: np.ndarray,
scene: np.ndarray,
) -> None:
"""`{stem}_smooth.npz`·`_smooth_preview.glb`를 만든다 — LAS DTM 스무딩과 같은 절차.
@@ -180,10 +203,11 @@ def _write_smoothed(
z=sz,
valid_mask=svalid,
bounds=bounds,
scene_bounds=scene,
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)
write_glb(models_dir / f"{stem}_smooth_preview.glb", vertices, faces, scene)
def _rasterize_contour_levels(spec: Any, features: list[dict[str, Any]]) -> np.ndarray:
@@ -339,6 +363,8 @@ def _write_method_model(
[float(finite_z.min()), float(finite_z.max())],
]
)
# 표고 격자 상자(`bounds`)는 절취 범위 그대로 두고, 화면 원점만 라이다와 맞춘다.
scene = _scene_bounds(project_root, bounds)
atomic_npz(
model_path,
x=x_coords,
@@ -346,11 +372,12 @@ def _write_method_model(
z=z_grid,
valid_mask=valid_grid,
bounds=bounds,
scene_bounds=scene,
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)
write_glb(preview_path, vertices, faces, scene)
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds, scene)
return {
"model_type": "dtm",
"source_filter": source_filter,
@@ -381,7 +408,7 @@ def build_sheet_surface_model(
processed_dir: Path,
models_dir: Path,
route_xy: np.ndarray,
epsg: int,
crs: str,
methods: list[str] | None = None,
) -> list[dict[str, Any]]:
"""도엽등고선으로 방식별 DTM npz·프리뷰 glb를 만들고 등록용 dict 목록을 돌려준다.
@@ -390,10 +417,10 @@ def build_sheet_surface_model(
화면에서 바꿔 보며 정해야 한다(2026-08-30 사용자 지시). 실패하면 빈 목록 —
호출측은 분석을 계속한다(도엽 미확보 지역 폴백).
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m).
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m), `crs`: 그 좌표계(pyproj 입력 문자열).
"""
started = time.monotonic()
features = _load_features_metric(processed_dir, epsg)
features = _load_features_metric(processed_dir, crs)
if not features:
return []
@@ -456,22 +483,20 @@ def build_sheet_surface_from_route(
`methods`를 주지 않으면 `SHEET_SURFACE_METHODS` 전체를 만든다 — 관리자가 화면에서
한 방식을 요청할 때 그 목록만 넘긴다.
"""
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route,
)
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_route_geometry import load_design_route
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
if route_file is None:
logger.warning("도엽 서피스: 계획 노선 파일이 없습니다.")
return []
planned = read_planned_route(route_file)
# 노선 CSV의 `crs_epsg` 열은 표시용 라벨이라 LAS(.prj) 좌표계와 다를 수 있다
# (2026-09-01 실측: 라벨 5179, 실제 5176 — 도엽 서피스만 딴 자리에 만들어졌다).
# `load_design_route()`가 .prj 좌표계로 재투영해 주므로 라이다 지표면과 한 자리에 선다.
planned = load_design_route(project_root)
if planned is None or len(planned.vertices) < 2:
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다: %s", route_file.name)
logger.warning("도엽 서피스: 계획 노선 파일을 읽지 못했습니다.")
return []
crs = planned.crs_input or project_epsg_from_prj(project_root)
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, methods
project_root, processed_dir, models_dir, route_xy, crs, methods
)
@@ -527,7 +552,9 @@ def run_sheet_surface_analysis(
)
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, epsg)
models = build_sheet_surface_model(
project_root, processed_dir, models_dir, route_xy, f"EPSG:{epsg}"
)
if not models:
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
@@ -312,7 +312,10 @@ async def put_pipe_points(
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)
save_detail_basins,
context.stored_path,
_basin_features(context, detail, points),
context.crs,
)
result = _payload(project_id, context, detail, points, saved=True)
result["saved_count"] = saved
+11 -11
View File
@@ -139,22 +139,22 @@ async def get_surface_model_contour(
interval,
time.monotonic() - generation_started,
)
# 화면은 이 상자의 중심을 원점 삼아 등고선을 놓는다 — 메시(glb)를 만들 때 쓴
# 상자와 같아야 등고선이 지형 위에 얹힌다. 도엽 서피스는 라이다와 원점을 맞추려
# `scene_bounds`를 따로 갖고 있으므로 그 값이 있으면 먼저 쓴다(2026-09-01).
with np.load(contour_model_path) as model_data:
if "bounds" in model_data:
if "scene_bounds" in model_data:
model_bounds = np.asarray(model_data["scene_bounds"], dtype=float)
elif "bounds" in model_data:
model_bounds = np.asarray(model_data["bounds"], dtype=float)
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
else:
with np.load(structured_path) as structured:
model_bounds = np.asarray(structured["bounds"], dtype=float)
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
payload = {
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
"project_id": str(project_id),
+17 -10
View File
@@ -25,10 +25,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES, draina
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import polygonize_labels
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route,
)
from common_util.common_util_route_geometry import load_design_route
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
@@ -74,15 +71,25 @@ def _load_routing(stored_path: str) -> dict[str, Any] | None:
async def _resolve_epsg(project_id: UUID, stored_path: str) -> str:
"""분석에 쓰인 좌표계를 그대로 되찾는다(노선 파일이 명시하면 그 값 우선)."""
"""분석에 쓰인 좌표계를 그대로 되찾는다.
격자 산출물은 `load_design_route()`가 맞춘 **사업지(.prj) 좌표계**에 있다. 노선 CSV의
`crs_epsg` 열은 표시용 라벨이라 그 값을 쓰면 좌표가 딴 곳으로 간다
(2026-09-01 실측: 라벨 5179, 실제 5176 — 유입 폴리곤이 1,500km 밖에 찍혔다).
"""
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
project_root = Path(resolve_stored_project_path(stored_path))
planned = load_design_route(project_root)
if planned is not None and planned.crs_input:
return planned.crs_input
prj_crs = project_epsg_from_prj(project_root)
if prj_crs:
return prj_crs
pool = get_db_pool()
async with pool.acquire() as connection:
epsg = await get_surface_crs_epsg(connection, project_id, 0)
project_root = Path(resolve_stored_project_path(stored_path))
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route(route_file) if route_file else None
source = (planned.epsg if planned else None) or epsg or 5186
return f"EPSG:{source}"
return f"EPSG:{epsg or 5186}"
def _collect_inflow(
+3
View File
@@ -59,6 +59,8 @@ export interface RouteSolveRequest {
balance_segment_length_m?: number | null;
start_elevation_offset_m?: number | null;
end_elevation_offset_m?: number | null;
/** 횡단배수 최소 계획고 강제 — 기본 해제(2026-09-01 사용자 지시). */
enforce_pipe_clearance?: boolean | null;
}
/** 계획선 산출 요약 (실패 시 null) */
@@ -140,6 +142,7 @@ export interface RouteLatestResponse {
balance_segment_length_m?: number | null;
start_elevation_offset_m?: number | null;
end_elevation_offset_m?: number | null;
enforce_pipe_clearance?: boolean | null;
} | null;
}
+12
View File
@@ -61,6 +61,10 @@ class GradeDesignOptions:
balance_segment_length_m: float | None = None
start_elevation_offset_m: float = 0.0
end_elevation_offset_m: float = 0.0
# 횡단배수 최소고를 계획선에 강제할지(2026-09-01 사용자 지시). 켜면 배관 자리 계획고를
# 시설 여유(`facility_clearance_m`)만큼 들어 올리고 그 아래로 내리는 편집도 막는다.
# 기본은 해제 — 자동설계가 배수 자리마다 계획고를 잡아당겨 제어가 어려웠다.
enforce_pipe_clearance: bool = False
warnings: tuple[str, ...] = field(default_factory=tuple)
def validate(self) -> None:
@@ -95,6 +99,7 @@ class GradeDesignOptions:
"balance_segment_length_m": self.balance_segment_length_m,
"start_elevation_offset_m": self.start_elevation_offset_m,
"end_elevation_offset_m": self.end_elevation_offset_m,
"enforce_pipe_clearance": self.enforce_pipe_clearance,
}
@@ -211,6 +216,13 @@ def resolve_grade_options(
requested.get("end_elevation_offset_m"), stored.get("end_elevation_offset_m"), 0.0
)
),
enforce_pipe_clearance=bool(
_pick(
requested.get("enforce_pipe_clearance"),
stored.get("enforce_pipe_clearance"),
False,
)
),
warnings=tuple(warnings),
)
@@ -380,6 +380,7 @@ def rebuild_alignment_profile(
terrain_type=str(criteria.get("terrain_type") or "normal"),
paved=policy.paved,
main_direction=str(criteria.get("main_direction") or "auto"),
enforce_pipe_clearance=bool(criteria.get("enforce_pipe_clearance") or False),
)
direction = str(criteria.get("resolved_main_direction") or "none")
return alignment, _profile_entry(
+8 -2
View File
@@ -368,8 +368,14 @@ def run_section_generation(
# 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점(측점 생성과 같은 목록).
pipe_chainages=[pipe.chainage_m for pipe in pipes],
# 시설 제원이 요구하는 최소 여유(관경+토피 등) — 계획선이 그만큼 들려야 관·구체가
# 들어갈 자리가 생긴다(2026-08-23 사용자 지시).
pipe_clearances=dict(pipe_anchor_clearances(pipes)),
# 들어갈 자리가 생긴다(2026-08-23 사용자 지시). 다만 이 들어올림이 배수 자리마다
# 계획고를 잡아당겨 제어가 어려워, 적용 여부를 사용자 스위치로 뺐다(2026-09-01
# 사용자 지시 — 기본 해제). 산식은 그대로이고 켜고 끄는 것만 바뀐다.
pipe_clearances=(
dict(pipe_anchor_clearances(pipes))
if grade_options and grade_options.enforce_pipe_clearance
else None
),
)
# 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적.
try:
+6
View File
@@ -105,6 +105,11 @@ class RouteSolveRequest(BaseModel):
balance_segment_length_m: float | None = Field(default=None, gt=0)
start_elevation_offset_m: float | None = None
end_elevation_offset_m: float | None = None
# 횡단배수 최소고 강제 여부(2026-09-01 사용자 지시). 기본 해제 — 켜면 계획선이 배관
# 자리에서 시설 여유만큼 들리고, 그 아래로 내리는 편집도 화면에서 막힌다.
enforce_pipe_clearance: bool | None = Field(
default=None, description="횡단배수 최소 계획고 강제 (기본 해제)"
)
@model_validator(mode="after")
def validate_choices(self) -> "RouteSolveRequest":
@@ -132,6 +137,7 @@ class RouteSolveRequest(BaseModel):
"balance_segment_length_m": self.balance_segment_length_m,
"start_elevation_offset_m": self.start_elevation_offset_m,
"end_elevation_offset_m": self.end_elevation_offset_m,
"enforce_pipe_clearance": self.enforce_pipe_clearance,
}
def points_data(self) -> dict[str, Any]:
+5 -4
View File
@@ -445,7 +445,8 @@ export async function saveCorridorIfDirty(projectId: string, routeId: number): P
if (ok) entry.dirty = false;
}
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). */
/** Page ( ).
* [3D ] Promise를 (2026-09-01). */
export function refreshCorridor(
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
projectId: string,
@@ -453,9 +454,9 @@ export function refreshCorridor(
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
): void {
if (!routeId) return;
void ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
): Promise<void> {
if (!routeId) return Promise.resolve();
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
.then((build) => viewer.setCorridor(build))
.catch(() => viewer.setCorridor(null));
}
+30 -23
View File
@@ -95,26 +95,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
() => bridge.clearProjectedStations(),
{
// 계획선 편집 프리뷰가 횡단 설계선을 갱신하면 3D 코리도도 재빌드(2026-08-23).
// 연타·드래그 중 프리뷰 응답마다 전체 파이프라인(빌드+스냅+클립)을 돌리면
// 프리즈가 오므로, 편집이 잦아든 뒤 1회만 돌린다(2026-08-23 사용자 보고).
onCrossDesignsUpdated: () => {
window.clearTimeout(corridorRefreshTimer);
corridorRefreshTimer = window.setTimeout(() => {
if (currentSectionDetail && latest?.route?.id && (latest.route_points?.length ?? 0) > 1) {
refreshCorridor(
viewer,
activeProjectId,
latest.route.id,
currentSectionDetail,
latest.route_points,
profilePanel.alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
renderStationLines(currentSectionDetail);
}
}, 500);
},
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
// (2026-09-01 사용자 지시). 디바운스 자동 갱신조차 편집 중 조작을 무겁게 만들어,
// 갱신은 상단 [3D 업데이트] 버튼으로 모았다. 여기서는 밀린 편집이 있다는 표시만 한다.
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다.
// 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다.
onPipesChanged: (pipes) => {
@@ -184,8 +168,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | null = null;
/** 계획선 편집 중 코리도 재빌드 디바운스 — 연타 프리즈 방지(2026-08-23). */
let corridorRefreshTimer = 0;
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
let corridorVisible = true;
let routeReady = false;
@@ -282,6 +264,22 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
onCorridorRefresh: async () => {
if (!currentSectionDetail || !latest?.route?.id) return;
const routePoints = latest.route_points ?? [];
if (routePoints.length <= 1) return;
await refreshCorridor(
viewer,
activeProjectId,
latest.route.id,
currentSectionDetail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
renderStationLines(currentSectionDetail);
},
onMovePoint: viewer.beginMoveSelected,
onDeletePoint: viewer.markers.deleteSelected,
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
@@ -333,6 +331,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
function markStale(): void {
if (restoring || !routeReady) return;
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
@@ -386,6 +386,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined,
endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined,
enforcePipeClearance:
(next.route_params?.enforce_pipe_clearance as boolean | undefined) ?? undefined,
});
viewer.markers.setPoints(restorePoints(next));
}
@@ -454,6 +456,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). 이렇게 하지 않으면
// 진입 직후 상단 표시줄이 옛 상한을 그대로 보여 준다.
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 여부도 그릴 때마다 현재 설정으로 맞춘다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
profilePanel.setStationDisplay(panel.stationDisplayOffset());
profilePanel.setIrregularStations(bridge.irregularStations());
renderStationLines(detail);
@@ -462,7 +466,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지).
const routePoints = latest?.route_points ?? [];
if (routePoints.length > 1) {
refreshCorridor(
void refreshCorridor(
viewer,
activeProjectId,
routeId ?? latest?.route?.id,
@@ -470,6 +474,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
// 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01).
panel.setCorridorPending(false);
}
}
@@ -599,6 +605,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
min_tangent_length_m: values.minTangentLength,
start_elevation_offset_m: values.startElevationOffset,
end_elevation_offset_m: values.endElevationOffset,
enforce_pipe_clearance: values.enforcePipeClearance,
});
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
uphillOverrides.clear();
+43
View File
@@ -32,6 +32,9 @@ export interface RoutePanelValues {
minTangentLength: number | null;
startElevationOffset: number | null;
endElevationOffset: number | null;
/** · (2026-09-01 ).
* . . */
enforcePipeClearance: boolean;
}
/**
@@ -102,6 +105,9 @@ interface PanelCallbacks {
onSurfaceGrayscale: (grayscale: boolean) => void;
onView: (view: "iso" | "top" | "front" | "side") => void;
onResetView: () => void;
/** [3D ] 3D · (2026-09-01
* ). . */
onCorridorRefresh: () => void | Promise<void>;
onMovePoint: () => void;
onDeletePoint: () => void;
onRadiusChange: (radius: number) => void;
@@ -226,15 +232,36 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
);
// [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이
// 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은
// 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장).
const corridorRefresh = button(
"3D 업데이트",
() => {
corridorRefresh.disabled = true;
corridorRefresh.textContent = "갱신 중…";
void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => {
corridorRefresh.disabled = false;
corridorRefresh.textContent = "3D 업데이트";
corridorRefresh.classList.remove("is-pending");
});
},
"glass",
);
corridorRefresh.title =
"계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다.";
const separator1 = document.createElement("span");
separator1.className = "b05-route__view-separator";
const separator2 = separator1.cloneNode() as HTMLSpanElement;
const separator3 = separator1.cloneNode() as HTMLSpanElement;
viewControls.append(
viewButtons,
separator1,
visibilityButtons,
separator2,
button("뷰 초기화", callbacks.onResetView, "glass"),
separator3,
corridorRefresh,
);
// 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합).
@@ -373,6 +400,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const terrainType = terrainField.select;
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
// 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관
// 자리를 시설 여유만큼 들어 올리고, 그 아래로 내리는 편집도 막힌다. 꺼도 부족분 경고는
// 종단 상단 표시줄에 계속 뜬다.
const enforcePipeClearance = checkbox("횡단배수 최소고 강제", false);
enforcePipeClearance.title =
"켜면 계획선이 배수관·BOX암거·세월교 자리에서 관경(구체 높이)+토피 0.5m 만큼 들립니다.\n" +
"끄면 들어 올리지 않고 부족분은 경고로만 알립니다(기본).";
const maxGradePct = numberField("최대 종단기울기 (%)");
const minVerticalRadius = numberField("종단곡선 최소 반경 (m)");
const minTangentLength = numberField("최소 직선 길이 (m)");
@@ -405,6 +439,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
speedField.root,
terrainField.root,
criteriaNote,
enforcePipeClearance.wrapper,
gradeAdvanced,
);
@@ -531,6 +566,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minTangentLength,
startElevationOffset,
endElevationOffset,
enforcePipeClearance,
];
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
@@ -557,6 +593,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
stationDisplayOffset,
/** 지금 적용되는 종단기울기 상한(%) — 그래프 위반 판정 즉시 반영용(2026-08-19). */
gradeLimitPct,
/** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */
setCorridorPending(pending: boolean) {
corridorRefresh.classList.toggle("is-pending", pending);
},
values(): RoutePanelValues {
return {
contourInterval: Number(contourInterval.value) || 1,
@@ -579,6 +619,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minTangentLength: parseOptional(minTangentLength),
startElevationOffset: parseOptional(startElevationOffset),
endElevationOffset: parseOptional(endElevationOffset),
enforcePipeClearance: enforcePipeClearance.checked,
};
},
restore(values: Partial<RoutePanelValues>) {
@@ -611,6 +652,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
startElevationOffset.value = String(values.startElevationOffset);
if (values.endElevationOffset != null)
endElevationOffset.value = String(values.endElevationOffset);
if (values.enforcePipeClearance != null)
enforcePipeClearance.checked = values.enforcePipeClearance;
syncCriteria();
},
setSelected(point: PlacedRoutePoint | null) {
@@ -189,6 +189,8 @@ export function createRouteProfilePanel(
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
let minCoverTargets: MinCoverPoint[] = [];
/** 최소고를 편집에서 강제할지 — 사이드 「페이지 설정」 체크박스를 따라온다(기본 해제). */
let enforceMinCover = false;
const drainagePanel = createDrainagePanel({
onPipesChanged: (pipes) => {
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
@@ -502,6 +504,7 @@ export function createRouteProfilePanel(
stationInterval: () => stationInterval,
irregularStations: () => irregularStations,
minCoverTargets: () => minCoverTargets,
enforceMinCover: () => enforceMinCover,
structures: () => structures,
structureTypes: () => structureTypes,
selectedStationId: () => selectedStationId,
@@ -624,6 +627,10 @@ export function createRouteProfilePanel(
/** (%) ··
* (2026-08-19
* 6). . */
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
enforceMinCover = enforce;
},
setGradeLimit(maxGradePct: number) {
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
@@ -68,6 +68,8 @@ export interface ProfileRenderContext {
irregularStations: () => IrregularStation[];
/** 횡단배수 최소 계획고 대상(시설·제원 반영) — 편집 차단 가드가 쓴다. */
minCoverTargets: () => MinCoverPoint[];
/** 최소고를 편집에서 강제할지 — 꺼져 있으면 차단하지 않는다(2026-09-01, 기본 해제). */
enforceMinCover: () => boolean;
structures: () => StructureInstance[];
structureTypes: () => StructureType[];
selectedStationId: () => string | null;
@@ -292,6 +294,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
};
const blocksMinCover = (next: AlignmentEdits): boolean => {
if (!base) return false;
// 최소고 강제가 꺼져 있으면 막지 않는다(2026-09-01 사용자 지시 — 기본 해제).
// 부족분은 상단 표시줄 경고(minCoverWarningText)로 계속 알린다.
if (!ctx.enforceMinCover()) return false;
const targets = ctx.minCoverTargets();
if (!targets.length) return false;
// 판정점 = 제어점 z(라운드 중심). 곡선 샘플로 재면 이웃 틸팅이 라운드 형상만
+17
View File
@@ -208,6 +208,23 @@
background: color-mix(in srgb, var(--color-border) 75%, transparent);
}
/* [3D 업데이트] 대기 표시 — 계획선 편집이 3D에 아직 안 들어갔음을 점으로 알린다. */
.b05-route__view-controls .ui-btn.is-pending {
position: relative;
border-color: var(--color-warning, #d98a00);
}
.b05-route__view-controls .ui-btn.is-pending::after {
content: "";
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-warning, #d98a00);
}
.b05-route__panel {
display: flex;
flex-direction: column;
+3
View File
@@ -385,6 +385,9 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes
"balance_segment_length_m",
"start_elevation_offset_m",
"end_elevation_offset_m",
# 횡단배수 최소고 강제 스위치도 확정 당시 값을 따라야 한다 — 빠뜨리면 재생성이
# 기본(해제)으로 돌아가 켜 둔 계획선이 내려앉는다(2026-09-01).
"enforce_pipe_clearance",
)
if stage_params.get(key) is not None
}
@@ -44,6 +44,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingItem,
)
from common_util.common_util_drainage_pipes import detail_basins_path
from common_util.common_util_route_geometry import find_planned_route_file, read_planned_route
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import DRAWING_SCALE_BASIN
@@ -123,19 +124,46 @@ def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
temporary.replace(path)
def _geojson_features(path: Path) -> list[dict[str, Any]]:
"""GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록."""
def _geojson_payload(path: Path) -> dict[str, Any]:
"""GeoJSON 전체를 읽는다. 파일이 없거나 깨졌으면 빈 dict."""
if not path.is_file():
return []
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
logger.warning("B07 유역도: GeoJSON을 읽지 못했습니다 — %s", path)
return []
features = payload.get("features") if isinstance(payload, dict) else None
return {}
return payload if isinstance(payload, dict) else {}
def _geojson_features(path: Path) -> list[dict[str, Any]]:
"""GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록."""
features = _geojson_payload(path).get("features")
return features if isinstance(features, list) else []
def _basins_crs(context: Any, payload: dict[str, Any]) -> str:
"""저장본을 미터로 되돌릴 좌표계.
저장할 좌표계를 파일이 갖고 있으면 값이다. 없으면 좌표계 기록 이전에 저장된
파일이라 노선 CSV의 EPSG 라벨로 쓰였다 라벨로 되돌려야 왕복이 맞는다
(2026-09-01). 라벨을 읽으면 현재 사업지 좌표계로 둔다.
"""
stored = payload.get("crs_input")
if isinstance(stored, str) and stored:
return stored
route_file = find_planned_route_file(context.project_root / "B03_FileInput" / "input")
planned = read_planned_route(route_file) if route_file else None
if planned is not None and planned.epsg:
logger.warning(
"B07 유역도: 좌표계 기록이 없는 옛 저장본 — 노선 CSV 라벨 EPSG:%s로 되돌립니다. "
"B04에서 유역을 다시 확정하면 현재 좌표계로 새로 남습니다.",
planned.epsg,
)
return f"EPSG:{planned.epsg}"
return context.crs
def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]:
"""LineString·MultiLineString·Polygon을 점열 목록으로 편다."""
if not isinstance(geometry, dict):
@@ -222,7 +250,10 @@ def watershed_source(context: Any) -> dict[str, Any]:
저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향).
"""
to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{context.epsg}", always_xy=True)
basins_payload = _geojson_payload(detail_basins_path(context.stored_path))
to_metric = Transformer.from_crs(
"EPSG:4326", _basins_crs(context, basins_payload), always_xy=True
)
def metric(point: tuple[float, float]) -> tuple[float, float]:
x, y = to_metric.transform(point[0], point[1])
@@ -230,7 +261,7 @@ def watershed_source(context: Any) -> dict[str, Any]:
route_xy = [(vertex.x, vertex.y) for vertex in context.vertices]
basins: list[dict[str, Any]] = []
for feature in _geojson_features(detail_basins_path(context.stored_path)):
for feature in basins_payload.get("features") or []:
properties = feature.get("properties") or {}
if properties.get("kind") != "detail_basin":
continue
+8 -4
View File
@@ -52,7 +52,9 @@ class DrainageContext:
project_root: Path
vertices: list[RouteVertex] = field(default_factory=list)
z_source: str = Z_SOURCE_CSV
epsg: int = 5186
# 사업지 좌표계 — pyproj 입력 문자열(`EPSG:n` 또는 .prj WKT). 노선 CSV의 `crs_epsg`
# 열은 표시용 라벨이라 실좌표계와 다를 수 있다(2026-09-01 실측: 라벨 5179, 실제 5176).
crs: str = "EPSG:5186"
route_id: int | None = None
to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y)
@@ -96,15 +98,17 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non
sampler=sampler,
)
epsg = int(planned.epsg or db_epsg or 5186)
transformer = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True)
# 노선을 실제로 담고 있는 좌표계를 쓴다 — `load_design_route()`가 .prj 좌표계로
# 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다.
crs = planned.crs_input or f"EPSG:{planned.epsg or db_epsg or 5186}"
transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
return (
DrainageContext(
stored_path=stored_path,
project_root=project_root,
vertices=vertices,
z_source=z_source,
epsg=epsg,
crs=crs,
route_id=int(route["id"]) if route else None,
to_lonlat=lambda x, y: transformer.transform(x, y),
),
+8 -3
View File
@@ -351,11 +351,16 @@ def clear_pipe_points(stored_path: str) -> bool:
return removed
def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path:
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다)."""
def save_detail_basins(stored_path: str, features: list[dict[str, Any]], crs: str) -> Path:
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다).
`crs` 좌표를 WGS84로 바꿀 **사업지 좌표계**. 되읽는 (B07 유역도) 같은
좌표계로 되돌려야 하는데, 예전에는 값을 남겨 노선 CSV의 EPSG 라벨로 되돌렸다
(2026-09-01: 라벨과 실좌표계가 갈린 프로젝트에서 유역이 자리로 갔다).
"""
path = detail_basins_path(stored_path)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
atomic_write_json(path, {"type": "FeatureCollection", "crs_input": crs, "features": features})
logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name)
return path