Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -263,7 +263,7 @@ async def run_auto_design_chain(
|
||||
# [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정).
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
await save_initial_snapshot(connection, project_root, route_id)
|
||||
await save_initial_snapshot(connection, project_root, route_id, points)
|
||||
except Exception as exc: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다
|
||||
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
|
||||
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
|
||||
|
||||
@@ -797,6 +797,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
applyLasFreeState();
|
||||
pageError.textContent = "";
|
||||
// 이 토글이 LAS 카드의 필수 여부를 바꾼다 — 버튼 판정을 다시 돌리지 않으면 파일을 다
|
||||
// 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(2026-09-03 실측: 파일을 먼저 고르고
|
||||
// 토글을 나중에 켠 순서에서 재현).
|
||||
updateUploadButton();
|
||||
});
|
||||
|
||||
// LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다.
|
||||
|
||||
@@ -500,7 +500,10 @@ export interface DetailBasin {
|
||||
index: number;
|
||||
chainage_m: number;
|
||||
outlet_lonlat: [number, number];
|
||||
/** 가장 넓은 조각의 외곽 링 하나 — 중심 계산처럼 링 하나면 되는 자리에 쓴다. */
|
||||
polygon_lonlat: Array<[number, number]>;
|
||||
/** 조각·구멍을 모두 편 링 목록. 도넛 유역과 떨어진 조각을 그대로 그린다(even-odd). */
|
||||
polygon_rings_lonlat?: Array<Array<[number, number]>>;
|
||||
area_m2: number;
|
||||
relief_m: number;
|
||||
flow_length_m: number;
|
||||
|
||||
@@ -510,7 +510,8 @@ def run_sheet_surface_analysis(
|
||||
|
||||
반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환).
|
||||
"""
|
||||
from common_util.common_util_route_geometry import 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
|
||||
|
||||
def _report(percent: int, stage: str, message: str) -> None:
|
||||
if on_progress is not None:
|
||||
@@ -522,10 +523,14 @@ def run_sheet_surface_analysis(
|
||||
processed_dir.mkdir(parents=True, exist_ok=True)
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
planned = read_planned_route(route_csv_path)
|
||||
# 노선을 **사업지(.prj) 좌표계로 옮긴 뒤** 도엽을 뜬다. 원본 좌표 그대로 뜨면 노선이
|
||||
# 5179, 지표면이 5176처럼 갈려 설계 계통(`load_design_route`)이 재투영한 노선이 지표면
|
||||
# 밖으로 나가고 트림이 노선을 통째로 지운다(2026-09-03 실측: shapefile 노선 + LAS 없는
|
||||
# 설계). LAS 경로·`build_sheet_surface_from_route`와 같은 창구를 쓰는 것이 요지다.
|
||||
planned = load_design_route(project_root)
|
||||
if planned is None or len(planned.vertices) < 2:
|
||||
raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}")
|
||||
epsg = planned.epsg or 5186
|
||||
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)
|
||||
|
||||
bounds_dict = {
|
||||
@@ -545,16 +550,15 @@ def run_sheet_surface_analysis(
|
||||
project_root,
|
||||
processed_dir,
|
||||
bounds_dict,
|
||||
route_csv_path.parent,
|
||||
# 노선 세트 폴더의 PRJ는 노선 좌표계다 — 지형 PRJ를 고르게 지형 폴더를 준다.
|
||||
project_root / "B03_FileInput" / "input" / "prj",
|
||||
rebuild=False,
|
||||
default_epsg=f"EPSG:{epsg}",
|
||||
default_epsg=crs,
|
||||
report=_report,
|
||||
)
|
||||
|
||||
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
|
||||
models = build_sheet_surface_model(
|
||||
project_root, processed_dir, models_dir, route_xy, f"EPSG:{epsg}"
|
||||
)
|
||||
models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, crs)
|
||||
if not models:
|
||||
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
|
||||
|
||||
|
||||
@@ -560,6 +560,25 @@ def polygonize_labels(
|
||||
return merged
|
||||
|
||||
|
||||
def polygon_parts(geometry: Polygon | MultiPolygon) -> list[list[list[tuple[float, float]]]]:
|
||||
"""폴리곤을 **조각 목록**으로 편다 — 조각마다 [외곽 링, 구멍 링...] 순.
|
||||
|
||||
`largest_ring()`은 가장 큰 조각의 외곽 하나만 낸다. 세부유역에서는 그 자리가 곧
|
||||
중첩·빈공간이었다(2026-09-03 합성 실측): 아래 유역이 위 유역을 감싸면 구멍이 사라져
|
||||
위 유역 256㎡가 통째로 덮이고, 한 관의 유역이 두 조각(225㎡+144㎡)이면 작은 144㎡가
|
||||
빠져 빈공간이 됐다. 조각은 넓은 것부터, 좌표는 조각 안에서 외곽 다음에 구멍이다.
|
||||
"""
|
||||
if geometry.is_empty:
|
||||
return []
|
||||
parts = list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry]
|
||||
result: list[list[list[tuple[float, float]]]] = []
|
||||
for part in sorted(parts, key=lambda item: item.area, reverse=True):
|
||||
rings = [[(float(x), float(y)) for x, y in part.exterior.coords]]
|
||||
rings.extend([(float(x), float(y)) for x, y in hole.coords] for hole in part.interiors)
|
||||
result.append(rings)
|
||||
return result
|
||||
|
||||
|
||||
def largest_ring(geometry: Polygon | MultiPolygon) -> list[tuple[float, float]]:
|
||||
"""폴리곤(또는 멀티폴리곤)에서 가장 큰 조각의 외곽 링 좌표를 뽑는다."""
|
||||
if geometry.is_empty:
|
||||
|
||||
@@ -161,7 +161,12 @@ def _payload(
|
||||
"index": basin.index,
|
||||
"chainage_m": round(basin.chainage_m, 2),
|
||||
"outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)),
|
||||
# 옛 소비처를 위한 외곽 링 하나. 그리기는 아래 링 목록을 쓴다.
|
||||
"polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy],
|
||||
# 조각·구멍을 모두 편 링 목록 — 도넛 유역과 떨어진 조각을 그대로 그린다.
|
||||
"polygon_rings_lonlat": [
|
||||
[list(to_lonlat(x, y)) for x, y in ring] for ring in basin.boundary_rings
|
||||
],
|
||||
"area_m2": round(basin.area_m2, 1),
|
||||
"relief_m": round(basin.relief_m, 2),
|
||||
"flow_length_m": round(basin.flow_length_m, 1),
|
||||
@@ -192,9 +197,19 @@ def _basin_features(
|
||||
to_lonlat = context.to_lonlat
|
||||
features: list[dict[str, Any]] = []
|
||||
for basin in detail.basins:
|
||||
ring = [list(to_lonlat(x, y)) for x, y in basin.boundary_xy]
|
||||
if len(ring) < 4:
|
||||
# GeoJSON 규격 그대로 — 조각마다 [외곽, 구멍...], 조각이 여럿이면 MultiPolygon.
|
||||
parts = [
|
||||
[[list(to_lonlat(x, y)) for x, y in ring] for ring in part if len(ring) >= 4]
|
||||
for part in basin.boundary_parts
|
||||
]
|
||||
parts = [part for part in parts if part]
|
||||
if not parts:
|
||||
continue
|
||||
geometry = (
|
||||
{"type": "Polygon", "coordinates": parts[0]}
|
||||
if len(parts) == 1
|
||||
else {"type": "MultiPolygon", "coordinates": parts}
|
||||
)
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
@@ -213,7 +228,7 @@ def _basin_features(
|
||||
"design_flow_m3s": basin.design_flow_m3s,
|
||||
"bridge_required": basin.bridge_required,
|
||||
},
|
||||
"geometry": {"type": "Polygon", "coordinates": [ring]},
|
||||
"geometry": geometry,
|
||||
}
|
||||
)
|
||||
for pipe, point in zip(detail.pipes, points):
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pointInRings } from "./B04_PreProcess_UI_MapOverlays";
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
@@ -71,17 +72,6 @@ const BASIN_ALPHA_PLAIN = 0.22;
|
||||
const BASIN_ALPHA_SELECTED = 0.38;
|
||||
const BASIN_ALPHA_MUTED = 0.06;
|
||||
|
||||
/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */
|
||||
function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
|
||||
let inside = false;
|
||||
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
||||
const [xi, yi] = ring[index];
|
||||
const [xj, yj] = ring[previous];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
interface PipeMarker {
|
||||
chainage: number;
|
||||
source: PipeSource;
|
||||
@@ -289,10 +279,11 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
|
||||
let smallest = Number.POSITIVE_INFINITY;
|
||||
basins.forEach((basin) => {
|
||||
if (basin.polygon_lonlat.length < 3) return;
|
||||
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
|
||||
lonLatToScreen(normalizerRef as Normalizer, view, lon, lat),
|
||||
// 구멍 안(= 안에 든 다른 유역)을 누르면 바깥 유역이 잡히지 않는다.
|
||||
const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) =>
|
||||
ring.map(([lon, lat]) => lonLatToScreen(normalizerRef as Normalizer, view, lon, lat)),
|
||||
);
|
||||
if (!pointInRing(ring, x, y)) return;
|
||||
if (!pointInRings(rings, x, y)) return;
|
||||
if (basin.area_m2 < smallest) {
|
||||
smallest = basin.area_m2;
|
||||
hit = basin.index;
|
||||
@@ -508,12 +499,18 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
|
||||
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
|
||||
lonLatToScreen(normalizer, view, lon, lat),
|
||||
);
|
||||
// 조각·구멍을 한 경로에 담아 even-odd로 채운다 — 구멍이 실제로 뚫린다.
|
||||
const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((part) =>
|
||||
part.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)),
|
||||
);
|
||||
context.beginPath();
|
||||
ring.forEach(([px, py], order) => {
|
||||
if (order === 0) context.moveTo(px, py);
|
||||
else context.lineTo(px, py);
|
||||
rings.forEach((part) => {
|
||||
part.forEach(([px, py], order) => {
|
||||
if (order === 0) context.moveTo(px, py);
|
||||
else context.lineTo(px, py);
|
||||
});
|
||||
context.closePath();
|
||||
});
|
||||
context.closePath();
|
||||
// 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게.
|
||||
const muted = selectedBasin !== null && selectedBasin !== basin.index;
|
||||
const alpha = muted
|
||||
@@ -522,7 +519,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
|
||||
? BASIN_ALPHA_SELECTED
|
||||
: BASIN_ALPHA_PLAIN;
|
||||
context.fillStyle = basinColor(index, alpha);
|
||||
context.fill();
|
||||
context.fill("evenodd");
|
||||
context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95);
|
||||
context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8;
|
||||
context.stroke();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* B04 지표면 3D 뷰어 방위 콤파스 — 바늘이 늘 도북(N)을 가리킨다.
|
||||
*
|
||||
* 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y. 그래서 세계에서 북쪽은
|
||||
* **-z** 다. 화면 위쪽은 카메라가 보는 수평 방향이므로, 카메라 오프셋(카메라 − 타깃)만
|
||||
* 알면 북쪽이 화면에서 몇 도 돌아가 있는지 나온다 — `Math.atan2(offset.x, offset.z)`.
|
||||
* (기본 시점 offset=(0, d, d·tilt)면 0° = 북쪽이 화면 위, 카메라가 동쪽으로 가면 +90°.)
|
||||
*
|
||||
* 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한).
|
||||
*/
|
||||
|
||||
export interface TerrainCompass {
|
||||
root: HTMLElement;
|
||||
/** 카메라 오프셋(카메라 위치 − 타깃)으로 바늘 각도를 맞춘다. */
|
||||
update(offsetX: number, offsetZ: number): void;
|
||||
setVisible(visible: boolean): void;
|
||||
}
|
||||
|
||||
const NEEDLE_SVG = `
|
||||
<svg viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
|
||||
<circle cx="22" cy="22" r="20" class="b04-surface__compass-ring" />
|
||||
<polygon points="22,5 27,22 22,19 17,22" class="b04-surface__compass-north" />
|
||||
<polygon points="22,39 17,22 22,25 27,22" class="b04-surface__compass-south" />
|
||||
<text x="22" y="14" class="b04-surface__compass-letter">N</text>
|
||||
</svg>`;
|
||||
|
||||
export function createTerrainCompass(): TerrainCompass {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b04-surface__compass";
|
||||
root.hidden = true;
|
||||
root.title = "도북(N)";
|
||||
|
||||
const dial = document.createElement("div");
|
||||
dial.className = "b04-surface__compass-dial";
|
||||
dial.innerHTML = NEEDLE_SVG;
|
||||
root.append(dial);
|
||||
|
||||
// 프레임마다 style을 다시 쓰지 않도록 직전 각도를 들고 있는다.
|
||||
let lastHeading = Number.NaN;
|
||||
|
||||
return {
|
||||
root,
|
||||
update(offsetX: number, offsetZ: number): void {
|
||||
const heading = (Math.atan2(offsetX, offsetZ) * 180) / Math.PI;
|
||||
if (Number.isFinite(lastHeading) && Math.abs(heading - lastHeading) < 0.5) return;
|
||||
lastHeading = heading;
|
||||
dial.style.transform = `rotate(${heading.toFixed(1)}deg)`;
|
||||
},
|
||||
setVisible(visible: boolean): void {
|
||||
root.hidden = !visible;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
|
||||
export type FilledRing = {
|
||||
ring: ReadonlyArray<readonly [number, number]>;
|
||||
/**
|
||||
* 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** —
|
||||
* 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만.
|
||||
*/
|
||||
rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
|
||||
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
|
||||
label?: string;
|
||||
};
|
||||
@@ -35,19 +40,27 @@ export function drawFilledRing(
|
||||
color: string,
|
||||
): void {
|
||||
if (entry.ring.length < 3) return;
|
||||
const rings = entry.rings?.length ? entry.rings : [entry.ring];
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
context.beginPath();
|
||||
entry.ring.forEach(([lon, lat], index) => {
|
||||
rings.forEach((ring) => {
|
||||
if (ring.length < 3) return;
|
||||
ring.forEach(([lon, lat], index) => {
|
||||
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.closePath();
|
||||
});
|
||||
// 번호 자리는 바깥 링만 보고 잡는다 — 구멍까지 섞으면 중심이 유역 밖으로 밀린다.
|
||||
entry.ring.forEach(([lon, lat]) => {
|
||||
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.closePath();
|
||||
context.fillStyle = color;
|
||||
context.fill();
|
||||
context.fill("evenodd");
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 1.6;
|
||||
context.stroke();
|
||||
@@ -87,6 +100,26 @@ export function drawRingBadge(
|
||||
}
|
||||
|
||||
/** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */
|
||||
/**
|
||||
* 점이 조각·구멍으로 이루어진 유역 안에 있는가 — 링마다 홀짝을 뒤집는 even-odd 판정.
|
||||
* 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않는다.
|
||||
*/
|
||||
export function pointInRings(
|
||||
rings: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (const ring of rings) {
|
||||
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
||||
const [xi, yi] = ring[index];
|
||||
const [xj, yj] = ring[previous];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function ringCenterOnScreen(
|
||||
ring: ReadonlyArray<readonly [number, number]>,
|
||||
normalizer: Normalizer,
|
||||
|
||||
@@ -557,6 +557,44 @@
|
||||
text-shadow: 0 1px 2px var(--color-surface-raised);
|
||||
}
|
||||
|
||||
/* 방위 콤파스 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝. */
|
||||
.b04-surface__compass {
|
||||
position: absolute;
|
||||
right: var(--spacing-16);
|
||||
bottom: var(--spacing-16);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 1px 2px var(--color-surface-raised));
|
||||
}
|
||||
|
||||
.b04-surface__compass-dial {
|
||||
transform-origin: 50% 50%;
|
||||
transition: transform 80ms linear;
|
||||
}
|
||||
|
||||
.b04-surface__compass-ring {
|
||||
fill: var(--color-surface-raised);
|
||||
fill-opacity: 0.75;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b04-surface__compass-north {
|
||||
fill: var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
.b04-surface__compass-south {
|
||||
fill: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b04-surface__compass-letter {
|
||||
fill: var(--color-text-body);
|
||||
font-family: var(--font-body);
|
||||
font-size: 9px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
/* --- 하단 2D 지도 --- */
|
||||
.b04-map {
|
||||
--b04-map-vector: var(--color-accent);
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
|
||||
import { createTerrainCompass } from "./B04_PreProcess_UI_Compass";
|
||||
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
|
||||
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
@@ -166,6 +167,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
scaleBar.append(scaleLabel);
|
||||
viewerArea.append(scaleBar);
|
||||
|
||||
// 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다.
|
||||
const compass = createTerrainCompass();
|
||||
viewerArea.append(compass.root);
|
||||
|
||||
// Elevation bounds legend bar overlay (I-403)
|
||||
const legendBar = document.createElement("div");
|
||||
legendBar.style.position = "absolute";
|
||||
@@ -794,6 +799,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
// Render scale bar dynamically
|
||||
if (terrainMesh && terrainMesh.visible) {
|
||||
scaleBar.hidden = false;
|
||||
compass.setVisible(true);
|
||||
compass.update(camera.position.x - controls.target.x, camera.position.z - controls.target.z);
|
||||
const dist = camera.position.distanceTo(controls.target);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
|
||||
const roughMeters = 100 * metersPerPixel;
|
||||
@@ -803,6 +810,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
||||
} else {
|
||||
scaleBar.hidden = true;
|
||||
compass.setVisible(false);
|
||||
}
|
||||
|
||||
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { pointInRing } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import type { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
|
||||
@@ -123,10 +123,11 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
|
||||
let smallest = Number.POSITIVE_INFINITY;
|
||||
params.getBasins().forEach((basin) => {
|
||||
if (basin.polygon_lonlat.length < 3) return;
|
||||
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
|
||||
lonLatToScreen(normalizer, view, lon, lat),
|
||||
// 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않도록 링 전체로 판정한다.
|
||||
const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) =>
|
||||
ring.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)),
|
||||
);
|
||||
if (!pointInRing(ring, x, y)) return;
|
||||
if (!pointInRings(rings, x, y)) return;
|
||||
// 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선).
|
||||
if (basin.area_m2 < smallest) {
|
||||
smallest = basin.area_m2;
|
||||
|
||||
@@ -88,7 +88,7 @@ export function drawDrainageScene(
|
||||
}
|
||||
drawFilledRing(
|
||||
context,
|
||||
{ ring: basin.polygon_lonlat },
|
||||
{ ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat },
|
||||
normalizer,
|
||||
view,
|
||||
// 하나를 고르면 나머지는 옅게 물러난다.
|
||||
|
||||
@@ -34,7 +34,7 @@ import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import find_inflow_hotspots
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import largest_ring, polygonize_labels
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import polygon_parts, polygonize_labels
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_BOX,
|
||||
@@ -104,7 +104,10 @@ class WatershedBasin:
|
||||
chainage_m: float
|
||||
outlet_x: float
|
||||
outlet_y: float
|
||||
boundary_xy: list[tuple[float, float]] = field(default_factory=list)
|
||||
# 유역 경계 — 조각마다 [외곽 링, 구멍 링...]. 도넛(아래 유역이 위 유역을 감싼 경우)과
|
||||
# 떨어진 조각을 그대로 싣는다. 단일 링만 쓰던 시절에는 이 둘이 소실돼 화면에서 중첩·
|
||||
# 빈공간으로 보였다(2026-09-03).
|
||||
boundary_parts: list[list[list[tuple[float, float]]]] = field(default_factory=list)
|
||||
area_m2: float = 0.0
|
||||
relief_m: float = 0.0
|
||||
flow_length_m: float = 0.0
|
||||
@@ -123,6 +126,16 @@ class WatershedBasin:
|
||||
recommended_facility: str = "pipe"
|
||||
recommended_diameter_mm: int | None = None
|
||||
|
||||
@property
|
||||
def boundary_xy(self) -> list[tuple[float, float]]:
|
||||
"""가장 넓은 조각의 외곽 링 — 링 하나만 받는 옛 소비처를 위한 자리."""
|
||||
return self.boundary_parts[0][0] if self.boundary_parts else []
|
||||
|
||||
@property
|
||||
def boundary_rings(self) -> list[list[tuple[float, float]]]:
|
||||
"""조각 구분 없이 편 링 목록 — 캔버스는 even-odd로 한 번에 채운다."""
|
||||
return [ring for part in self.boundary_parts for ring in part]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoadRouting:
|
||||
@@ -460,46 +473,43 @@ def assign_road_cells_to_pipes(
|
||||
) -> np.ndarray:
|
||||
"""도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다.
|
||||
|
||||
노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고
|
||||
같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은
|
||||
가장 가까운 관이 받는 것으로 본다.
|
||||
노면 물은 측구를 타고 종단 내리막으로 흐르므로 종단 계획선을 1차원 지형으로 본다.
|
||||
1차원에서는 물이 **마루(구간 최고점)를 넘지 못한다** — 이웃한 두 관 사이의 최고점이
|
||||
곧 분수령이고, 그 왼쪽은 앞 관이, 오른쪽은 뒤 관이 받는다. 첫 관 앞과 마지막 관 뒤는
|
||||
그 관이 받는다.
|
||||
|
||||
옛 방식(한 칸 이웃만 보는 국소 하강 + 관 없는 저점은 최근접 관)은 계획고의 미세
|
||||
요철에 걸려 멈췄다. 용화 실측: 측점 2,138개 중 1,898개(88.8%)가 저점에 갇혀 흐름이
|
||||
아니라 **누가거리 최근접**으로 배정됐고, 그 결과 도로 셀 43.2%가 자기보다 높은 관에
|
||||
배정됐다(최대 6.82m 오르막). 마루 기준은 미세 요철을 타지 않으므로 오르막 배정이
|
||||
구조적으로 생기지 않는다(2026-09-03 사용자 확정).
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5)
|
||||
stations = np.arange(0.0, total_length + step, step)
|
||||
heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations])
|
||||
pipe_chainages = np.array([pipe.chainage_m for pipe in pipes])
|
||||
pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1)
|
||||
|
||||
# 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리).
|
||||
back_z = np.full(stations.size, np.inf)
|
||||
back_z[1:] = heights[:-1]
|
||||
forward_z = np.full(stations.size, np.inf)
|
||||
forward_z[:-1] = heights[1:]
|
||||
go_back = (back_z < heights) & (back_z <= forward_z)
|
||||
go_forward = (forward_z < heights) & ~go_back
|
||||
receiver = np.arange(stations.size, dtype=np.int64)
|
||||
receiver[go_back] -= 1
|
||||
receiver[go_forward] += 1
|
||||
receiver[pipe_station] = pipe_station # 관은 물을 흡수한다
|
||||
|
||||
owner = np.full(stations.size, -1, dtype=np.int64)
|
||||
owner[pipe_station] = np.arange(pipe_chainages.size)
|
||||
jump = receiver
|
||||
for _ in range(40):
|
||||
next_jump = jump[jump]
|
||||
if np.array_equal(next_jump, jump):
|
||||
break
|
||||
jump = next_jump
|
||||
resolved = owner[jump]
|
||||
# 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다.
|
||||
orphan = resolved < 0
|
||||
if orphan.any() and pipe_chainages.size:
|
||||
nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1)
|
||||
resolved[orphan] = nearest
|
||||
|
||||
slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1)
|
||||
return resolved[slot_station].astype(np.int32)
|
||||
if pipe_chainages.size == 0:
|
||||
return np.full(road_chainage.size, -1, dtype=np.int32)
|
||||
|
||||
heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations])
|
||||
# 관 순서는 호출자가 준 그대로 돌려줘야 한다 — 누가거리로 정렬해 풀고 끝에 되돌린다.
|
||||
order = np.argsort(pipe_chainages, kind="stable")
|
||||
pipe_station = np.clip(
|
||||
np.round(pipe_chainages[order] / step).astype(np.int64), 0, stations.size - 1
|
||||
)
|
||||
|
||||
owner = np.full(stations.size, pipe_station.size - 1, dtype=np.int64) # 마지막 관 뒤
|
||||
owner[: pipe_station[0] + 1] = 0 # 첫 관 앞
|
||||
for index in range(pipe_station.size - 1):
|
||||
left = pipe_station[index]
|
||||
right = pipe_station[index + 1]
|
||||
if right <= left:
|
||||
continue
|
||||
ridge = left + int(np.argmax(heights[left : right + 1]))
|
||||
owner[left : ridge + 1] = index
|
||||
owner[ridge + 1 : right + 1] = index + 1
|
||||
return order[owner][slot_station].astype(np.int32)
|
||||
|
||||
|
||||
# ── ⑩ 세부유역 조립 ────────────────────────────────────────────────────────
|
||||
@@ -517,7 +527,11 @@ def assemble_basins(
|
||||
reached = routing.road_slot >= 0
|
||||
labels[reached] = pipe_of_slot[routing.road_slot[reached]]
|
||||
|
||||
polygons = polygonize_labels(spec, labels)
|
||||
# 최소면적 필터를 끈다 — 그 필터가 곧 빈공간이었다. 떨어진 조각을 100㎡ 미만이라고
|
||||
# 버리면 유역 면적과 그림이 어긋난다(실측: 용화 5.76%→1.86%, S자 3.56%→1.42%,
|
||||
# 조각 유역 0→2·0→3 복원). 링 목록이 조각을 싣게 된 뒤로는 버릴 이유가 없고, 좌표점은
|
||||
# 583→622·806→829로 거의 늘지 않는다(2026-09-03). 남은 오차는 simplify(2.0m) 몫.
|
||||
polygons = polygonize_labels(spec, labels, min_area_m2=0.0)
|
||||
cell_area = spec.cell_area_m2
|
||||
basins: list[WatershedBasin] = []
|
||||
for order, pipe in enumerate(pipes):
|
||||
@@ -540,7 +554,7 @@ def assemble_basins(
|
||||
chainage_m=pipe.chainage_m,
|
||||
outlet_x=pipe.x,
|
||||
outlet_y=pipe.y,
|
||||
boundary_xy=largest_ring(geometry) if geometry is not None else [],
|
||||
boundary_parts=polygon_parts(geometry) if geometry is not None else [],
|
||||
area_m2=area,
|
||||
relief_m=relief,
|
||||
flow_length_m=flow_length,
|
||||
|
||||
@@ -9,6 +9,7 @@ CLAUDE.md 5장(조작·데이터 흐름 정책)의 **초기값** 층이다. 자
|
||||
`pipe_points.json` 편집분이 그대로 남아 초기값과 다른 결과가 나온다(2026-08-29).
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -19,6 +20,10 @@ import aiomysql
|
||||
# 스냅샷 폴더는 워크플로우 단계가 아니므로 PROJECT_STORAGE_LAYOUT_V2에 넣지 않는다.
|
||||
SNAPSHOT_DIRNAME = "initial_snapshot"
|
||||
_DB_DUMP_NAME = "db.json"
|
||||
# 설계가 쓰는 계획노선 정본 — shapefile로 온 노선도 여기서는 CSV 한 벌이다.
|
||||
# 사업지(.prj) 좌표계로 옮기고 지표면 밖을 잘라 조밀화까지 끝낸 값이라, 설계 계통은
|
||||
# 이 파일만 읽으면 매번 같은 노선을 본다(2026-09-03 사용자 확정).
|
||||
DESIGN_ROUTE_CSV_NAME = "planned_route.csv"
|
||||
# 초기 설계 체인이 도는 동안만 존재하는 마커(진입 차단 판정용).
|
||||
DESIGNING_LOCK_NAME = "initial_design.lock"
|
||||
# 초기 설계 체인이 실패로 끝났음을 남기는 마커.
|
||||
@@ -40,6 +45,11 @@ def snapshot_dir(project_root: Path) -> Path:
|
||||
return Path(project_root) / SNAPSHOT_DIRNAME
|
||||
|
||||
|
||||
def design_route_csv_path(project_root: Path) -> Path:
|
||||
"""설계용 계획노선 CSV 정본의 자리."""
|
||||
return snapshot_dir(Path(project_root)) / DESIGN_ROUTE_CSV_NAME
|
||||
|
||||
|
||||
def designing_lock_path(project_root: Path) -> Path:
|
||||
"""초기 설계 체인이 도는 동안만 존재하는 마커.
|
||||
|
||||
@@ -158,14 +168,24 @@ def _is_json_native(value: Any) -> bool:
|
||||
|
||||
|
||||
async def save_initial_snapshot(
|
||||
connection: aiomysql.Connection, project_root: Path, route_id: int
|
||||
connection: aiomysql.Connection,
|
||||
project_root: Path,
|
||||
route_id: int,
|
||||
design_route_points: list[dict[str, float]] | None = None,
|
||||
) -> None:
|
||||
"""자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다."""
|
||||
"""자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다.
|
||||
|
||||
`design_route_points`는 체인이 이미 만들어 둔 계획노선 정점(사업지 좌표계·트림·조밀화
|
||||
후)이다. 받으면 CSV 정본으로 함께 남긴다 — 노선이 shapefile로 왔더라도 설계 계통이
|
||||
읽는 것은 이 CSV 한 벌이다.
|
||||
"""
|
||||
root = Path(project_root)
|
||||
target = snapshot_dir(root)
|
||||
if has_initial_snapshot(root):
|
||||
return
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
if design_route_points:
|
||||
_write_design_route_csv(target / DESIGN_ROUTE_CSV_NAME, design_route_points)
|
||||
|
||||
for tree in _FILE_TREES:
|
||||
_copy_tree(root / tree, target / tree.replace("/", "__"))
|
||||
@@ -181,6 +201,17 @@ async def save_initial_snapshot(
|
||||
(target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_design_route_csv(path: Path, points: list[dict[str, float]]) -> None:
|
||||
"""계획노선 정점을 CSV로 적는다. 열 이름은 `read_planned_route_csv()`가 아는 것으로."""
|
||||
with path.open("w", encoding="utf-8", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(("sequence", "x", "y"))
|
||||
writer.writerows(
|
||||
(index, round(point["x"], 4), round(point["y"], 4))
|
||||
for index, point in enumerate(points)
|
||||
)
|
||||
|
||||
|
||||
def wipe_edited_masters(project_root: Path) -> list[str]:
|
||||
"""사용자 편집 정본을 걷어낸다 — 재계산으로 **진짜 초기값**을 만들기 위한 사전 정리.
|
||||
|
||||
|
||||
@@ -227,12 +227,29 @@ def load_design_route(
|
||||
주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다.
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
|
||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||
from config.config_system import (
|
||||
ROUTE_DIRECT_LINK_CELL_FACTOR,
|
||||
ROUTE_GRID_RES_M,
|
||||
ROUTE_PLANNED_DENSIFY_SAFETY,
|
||||
)
|
||||
|
||||
# 체인이 남긴 CSV 정본이 있으면 그것이 설계 노선이다 — 좌표계 변환·트림·조밀화가 이미
|
||||
# 끝난 값이라 다시 하지 않는다(노선이 shapefile로 왔어도 여기서는 CSV 한 벌이다).
|
||||
# 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로
|
||||
# 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 —
|
||||
# surface_params 없음)은 여기를 타지 않는다.
|
||||
if surface_params:
|
||||
master = design_route_csv_path(project_root)
|
||||
if master.is_file():
|
||||
stored = read_planned_route_csv(master)
|
||||
if stored is not None and len(stored.vertices) >= 2:
|
||||
return replace_vertices(
|
||||
stored,
|
||||
[(v.x, v.y) for v in stored.vertices],
|
||||
crs_input=project_epsg_from_prj(project_root),
|
||||
)
|
||||
|
||||
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
|
||||
planned = read_planned_route(route_file) if route_file else None
|
||||
if planned is None or len(planned.vertices) < 2:
|
||||
|
||||
Reference in New Issue
Block a user