Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-10-30 15:00:46 +09:00
18 changed files with 1102 additions and 184 deletions
+17 -14
View File
@@ -30,23 +30,24 @@ export interface PlanBaseView {
site: { lon: number; lat: number; address: string | null } | null;
}
/** 진행(595-3) — 끊김 = running 거짓 · percent < 100 · message 없음 · interrupted(서버가 다시 켜짐) ·
* 받은 적 없음 = percent 100 · message = 「n/5 …」 단계 글. */
export interface PlanJob {
running: boolean;
percent: number;
message: string | null;
error: string | null;
/** 서버 다시 켜짐 등으로 끊김(595-3). */
interrupted?: boolean;
}
/** 받기 시작 답(595-2) — 받음 · 멂(받은 범위에서 5 km 넘음 → 묻고 `reset`) · 다른 TM 띠(노선 지움 확인 뒤 `reset`). */
/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */
export type TerrainStart =
| (PlanJob & { status?: "accepted" })
| ({ status: "accepted" } & PlanJob)
| { status: "far"; distance_m: number; message: string }
| { status: "band"; epsg: number; center_epsg: number; message: string };
/** 위치 칸 기준점(캐시 · [저장] 전) — 서버가 둘레 ±1 km 를 더 덮음 · `reset` = 옛 받은 범위 버림. */
export interface TerrainCenter {
/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */
export interface TerrainAim {
center?: { lon: number; lat: number } | null;
reset?: boolean;
}
@@ -62,6 +63,8 @@ export interface TerrainExtent {
covered: boolean;
sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>;
cells: number;
/** 받기 칸 상한(넘으면 받기 413). */
max_cells: number;
}
/** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */
@@ -110,12 +113,12 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const base = (projectId: string): string => `/projects/${projectId}/plan`;
/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */
/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점) · `center` = 위치 칸 기준점(둘레 ±1 km). */
export const fetchTerrainExtent = (
projectId: string,
marginM: number | null,
extra: Vertex[],
center: TerrainCenter["center"] = null,
center: { lon: number; lat: number } | null = null,
): Promise<TerrainExtent> => {
const query = new URLSearchParams();
if (marginM !== null) query.set("margin_m", String(marginM));
@@ -127,15 +130,16 @@ export const fetchTerrainExtent = (
return request(`${base(projectId)}/terrain/extent?${query}`);
};
/** 받기 — 칸 상한 넘음 = 오류(413 글). */
export const startTerrain = (
projectId: string,
marginM: number | null,
extra: Vertex[],
where: TerrainCenter = {},
aim: TerrainAim = {},
): Promise<TerrainStart> =>
request(`${base(projectId)}/terrain`, {
method: "POST",
body: JSON.stringify({ margin_m: marginM, extra, ...where }),
body: JSON.stringify({ margin_m: marginM, extra, ...aim }),
});
export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> =>
@@ -246,18 +250,17 @@ export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
request(`${base(projectId)}/layers/bands`);
/** 올린 임도망도(584-3) — 받은 범위로 자른 선 + 가닥 · 길이 m · 원본 선 수 · 좌표계 · 올린 파일. */
/** 올린 임도망도(584-3 · 595-4) — 기준점 ±5 km ∪ 받은 지형으로 자른 선 + 가닥 · 길이 m · 원본 선 수 ·
* 좌표계 · 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 올린 파일. */
export interface PlanForestRoads {
count: number;
length_m: number;
total: number;
crs: string[];
bounds: [number, number, number, number] | null;
distance_m: number | null;
source: { files: string[]; bytes: number; uploaded_at: string } | null;
collection: GeoJsonCollection;
/** 잘린 임도 상자 [서, 남, 동, 북] 경위도 · 없으면 null(595-4). */
bounds?: [number, number, number, number] | null;
/** 기준점 ~ 가장 가까운 임도(m). */
distance_m?: number | null;
}
/** 올린 적 없으면 오류(404 글 그대로). */
+61 -29
View File
@@ -1,12 +1,13 @@
"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 요구 443).
"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 595-4 · 요구 443 · 463).
- 출처 = 사용자가 받은 산림청 임도망도 SHP(FGIS 무료신청 · 산림빅데이터거래소 · 시군구 단위 · 선)
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 받은 범위 안
선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError`)
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위
안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리)
- 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로)
- 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) +
`임도망도.json`(파일 이름 · 올린 때)
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 받은 범위로 잘라 가닥 · 길이 →
파생 저장본(열쇠 = 원본 지문 · 범위)
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 ·
잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점)
계산 자리 = ③ 서버 단독.
"""
@@ -32,6 +33,7 @@ from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_now_text
Box = tuple[float, float, float, float]
XY = tuple[float, float]
FOLDER = "D03_PlanAnalysis"
SOURCE_ZIP = "임도망도.zip"
@@ -41,7 +43,17 @@ DBF_ENCODING = "cp949"
MAX_BYTES = 100 * 1024 * 1024
SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg")
ROAD_KIND = "plan_forest_road"
ROAD_VERSION = 1
ROAD_VERSION = 2 # 잘린 상자 · 기준점 거리
RADIUS_M = 5000.0 # 기준점 둘레(요구서_463 3장 18)
def clip_box(origin: XY, have: Box | None) -> Box:
"""자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형."""
x, y = origin
box = (x - RADIUS_M, y - RADIUS_M, x + RADIUS_M, y + RADIUS_M)
if have is None:
return box
return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3])
def source_dir(project_root: Path) -> Path:
@@ -100,18 +112,22 @@ def _plain(value: Any) -> Any:
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
"""SHP 선 → 받은 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 ·
좌표계."""
def clip_roads(zip_path: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]:
"""SHP 선 → 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · 좌표계 ·
잘린 상자(`bounds` [서, 남, 동, 북] · 없으면 None) · 기준점 ~ 가장 가까운 임도(`distance_m` ·
자르기 전 모든 선)."""
from pyproj import Transformer
from shapely.geometry import Point, mapping
from shapely.geometry import box as rect
from shapely.geometry import mapping
from shapely.ops import transform
frame = rect(*box)
here = Point(origin) if origin else None
to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True).transform
features: list[dict[str, Any]] = []
total, length, crs = 0, 0.0, []
near: float | None = None
edges: list[tuple[float, float, float, float]] = []
for shapes in read_shapes(zip_path):
crs.append(shapes.crs.to_string())
shapes = shapes[shapes.geometry.notna()]
@@ -121,19 +137,18 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
for (_, row), geometry in zip(
shapes.drop(columns=shapes.geometry.name).iterrows(), metric.geometry
):
if here is not None:
gap = geometry.distance(here)
near = gap if near is None else min(near, gap)
part = geometry.intersection(frame)
if part.is_empty or part.length <= 0:
continue
length += part.length
props = {str(k): _plain(v) for k, v in row.items()}
props["length_m"] = round(part.length, 1)
features.append(
{
"type": "Feature",
"geometry": mapping(transform(to_ll, part)),
"properties": props,
}
)
lonlat = transform(to_ll, part)
edges.append(lonlat.bounds)
features.append({"type": "Feature", "geometry": mapping(lonlat), "properties": props})
if not total:
raise ValueError("SHP 에 선 지물이 없습니다 — 임도망도(선) 파일인지 보세요.")
return {
@@ -141,23 +156,34 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
"length_m": round(length, 1),
"total": total,
"crs": sorted(set(crs)),
"bounds": [
min(e[0] for e in edges),
min(e[1] for e in edges),
max(e[2] for e in edges),
max(e[3] for e in edges),
]
if edges
else None,
"distance_m": None if near is None else round(near, 1),
"collection": {"type": "FeatureCollection", "features": features},
}
def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]:
"""올린 임도망도 — 원본 지문 · 범위 같으면 저장본 · 올린 적 없으면 `FileNotFoundError`."""
def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]:
"""올린 임도망도 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 올린 적 없으면
`FileNotFoundError`."""
path = source_dir(project_root) / SOURCE_ZIP
stamp = file_stamp(path)
if stamp is None:
raise FileNotFoundError(
"올린 임도망도가 없습니다 — 옆 올리기 단추로 시군구 SHP 를 올리세요."
"올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요."
)
key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box])
at = [round(v, 1) for v in origin] if origin else None
key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box], at)
saved = read_derived(project_root, ROAD_KIND, key)
if saved is not None:
return saved["value"]
value = clip_roads(path, box, epsg)
value = clip_roads(path, box, epsg, origin)
try:
value["source"] = json.loads((source_dir(project_root) / SOURCE_META).read_text("utf-8"))
except (OSError, ValueError):
@@ -167,10 +193,14 @@ def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]:
def save_roads(
project_root: Path, files: list[tuple[str, bytes]], box: Box, epsg: int
project_root: Path,
files: list[tuple[str, bytes]],
box: Box,
epsg: int,
origin: XY | None = None,
) -> dict[str, Any]:
"""올림 — 묶어 읽어 보고 받은 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError`
(옛 것 그대로)."""
"""올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError`
(옛 것 그대로 · 기준점 ~ 가장 가까운 임도 거리)."""
data = pack(files)
folder = source_dir(project_root)
folder.mkdir(parents=True, exist_ok=True)
@@ -178,11 +208,13 @@ def save_roads(
temporary.write(data)
trial = Path(temporary.name)
try:
got = clip_roads(trial, box, epsg)
got = clip_roads(trial, box, epsg, origin)
if not got["count"]:
gap = got["distance_m"]
where = f" — 가장 가까운 임도 {gap / 1000:.1f} km" if gap is not None else ""
raise ValueError(
f"받은 범위 안에 임도가 없습니다(파일 선 {got['total']}가닥) — "
"사업지 시군구 파일인지 보세요."
f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}"
f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요."
)
os.replace(trial, folder / SOURCE_ZIP)
finally:
@@ -195,4 +227,4 @@ def save_roads(
"uploaded_at": utc_now_text(),
},
)
return road_layer(project_root, box, epsg)
return road_layer(project_root, box, epsg, origin)
+17 -11
View File
@@ -179,31 +179,37 @@ def _covers(have: Any, box: Box) -> bool:
def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]:
"""지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 아니면 상자 통째로 다시 받음."""
"""지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 덮는데 실패 조각이 남았으면 그 조각만
다시(PLAN 595-1) · 못 덮으면 상자 통째로 다시 받음."""
path, key_path = processed / CADASTRAL_FILE, processed / CADASTRAL_KEY
key = _read_json(key_path) or {}
if path.is_file() and not key.get("failed") and _covers(key.get("box"), box):
covered = path.is_file() and _covers(key.get("box"), box)
if covered and not key.get("failed"):
return {"path": path, "fetched": False, "count": key.get("count"), "failed": 0}
parts = tiles(box)
retry = key.get("failed_tiles") if covered else None # 옛 열쇠(조각 기록 없음) = 통째로
old = ((_read_json(path) or {}).get("features") or []) if retry else []
frame = key["box"] if retry else [float(v) for v in box]
parts = [tuple(t) for t in retry] if retry else tiles(box)
with ThreadPoolExecutor(max_workers=WFS_WORKERS) as pool:
results = list(pool.map(lambda part: fetch_tile(part, epsg), parts))
failed = sum(bad for _, bad in results)
if failed == len(parts):
raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {failed}개 실패")
features = dedup_parcels([f for got, _ in results for f in got])
bad = [list(part) for part, (_, n) in zip(parts, results) if n]
if len(bad) == len(parts) and not old:
raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {len(bad)}개 실패")
features = dedup_parcels([*old, *(f for got, _ in results for f in got)])
processed.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
atomic_write_json(
key_path,
{
"box": [float(v) for v in box],
"box": frame,
"epsg": int(epsg),
"count": len(features),
"failed": failed,
"failed": len(bad),
"failed_tiles": bad,
},
)
logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), failed, len(features))
return {"path": path, "fetched": True, "count": len(features), "failed": failed}
logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), len(bad), len(features))
return {"path": path, "fetched": True, "count": len(features), "failed": len(bad)}
# ── 급경사 ──────────────────────────────────────────────
+158 -28
View File
@@ -11,6 +11,10 @@
범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐).
위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 ·
받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고
center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어
서버가 다시 켜지면 「끊김」(interrupted).
노선 정본(손 그은 선)은
`D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
"""
@@ -28,8 +32,9 @@ from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFil
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_crs import lonlat_to_tm
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road
@@ -50,26 +55,53 @@ _TASKS: set[asyncio.Task] = set()
KINDS = ("terrain", "analyze")
class Center(BaseModel):
lon: float
lat: float
class TerrainBody(BaseModel):
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m)."""
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m) ·
위치 칸 가운데(경위도 · 595) · 새로(옛 받은 범위 버림)."""
margin_m: float | None = None
extra: list[tuple[float, float]] = []
center: Center | None = None
reset: bool = False
async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]:
"""(실경로, 좌표계, [지점들]) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 둘레 두 귀."""
Ends = tuple[Path, int, list[tuple[float, float]], int | None]
async def _plan_ends(
project_id: UUID, center: tuple[float, float] | None = None, reset: bool = False
) -> Ends:
"""(실경로, 좌표계, [지점들], 띠 넘음 epsg) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점
둘레 두 귀 · `center`(위치 칸) = 그 둘레를 더함(`reset` = 둘레만) · 다른 TM 띠 = 확인 전이면
넷째 값에 그 띠(셋은 `center` 없이) · 확인 뒤(받은 지형이 그 띠) = 그 띠 · 둘레만."""
root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400
site = (await network.project_info(project_id))["site"]
epsg = network.work_epsg(plan, site)
meta = terrain.received_meta(root)
if center is None and meta.get("center") and meta.get("epsg") not in (None, epsg):
center = tuple(meta["center"]) # 띠 넘어 받은 뒤 [저장] 전 — 받은 지형 띠 · 가운데
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
if center is not None:
band = tm_epsg_for_lon(center[0])
if epsg is not None and band != epsg:
if not (reset or meta.get("epsg") == band):
return (*(await _plan_ends(project_id))[:3], band)
epsg, ends, reset = band, [], True # 옛 띠 지점 · 선 = 「노선 지움」 확인으로 빠짐
epsg = epsg or band
corners = terrain.site_corners(*lonlat_to_tm(center[0], center[1], epsg))
return root, epsg, corners if reset else [*ends, *corners], None
if epsg is None:
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
if site and not ends and not plan["lines"]: # 처음 = 기준점 둘레 · 그 뒤는 받은 범위가 덮음
ends = terrain.site_corners(*lonlat_to_tm(site["lon"], site["lat"], epsg))
if not ends:
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
return root, epsg, ends
return root, epsg, ends, None
def _query_xy(extra: str | None) -> list[tuple[float, float]]:
@@ -81,15 +113,49 @@ def _query_xy(extra: str | None) -> list[tuple[float, float]]:
raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error
def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False}
def _job_path(root: Path, kind: str) -> Path:
return terrain.models_dir_of(root) / f"plan_job_{kind}.json"
def _note_job(path: Path | None, job: dict[str, Any]) -> None:
"""진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속)."""
if path is None:
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, job)
except OSError as error:
logger.warning("D 진행 기록 실패: %s", error)
def _saved_job(root: Path, kind: str) -> dict[str, Any]:
"""기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 ·
message 없음)."""
try:
saved = json.loads(_job_path(root, kind).read_text(encoding="utf-8"))
except (OSError, ValueError):
return dict(IDLE)
if not (isinstance(saved, dict) and saved.get("running")):
return dict(IDLE)
percent = min(99, int(saved.get("percent") or 0))
return {**IDLE, "percent": percent, "interrupted": True}
def _start_job(project_id: UUID, kind: str, work: Any, root: Path | None = None) -> dict[str, Any]:
key = (str(project_id), kind)
if (_JOBS.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None}
job = {"running": True, "percent": 0, "message": "시작", "error": None, "interrupted": False}
_JOBS[key] = job
path = _job_path(root, kind) if root is not None else None
_note_job(path, job)
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
_note_job(path, job)
async def run() -> None:
try:
@@ -100,6 +166,7 @@ def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
_note_job(path, job)
task = asyncio.create_task(run(), name=f"D {kind} {project_id}")
_TASKS.add(task)
@@ -133,10 +200,16 @@ async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int
@router.get("/{project_id}/plan/terrain/extent", response_model=None)
async def plan_terrain_extent(
project_id: UUID, margin_m: float | None = None, extra: str | None = None
project_id: UUID,
margin_m: float | None = None,
extra: str | None = None,
lon: float | None = None,
lat: float | None = None,
) -> dict[str, Any]:
"""받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위."""
root, epsg, ends = await _plan_ends(project_id)
"""받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]` · `lon` `lat` 위치 칸 둘레) 상자 + 여유 ∪
받은 범위."""
center = (lon, lat) if lon is not None and lat is not None else None
root, epsg, ends, _ = await _plan_ends(project_id, center)
view = await asyncio.to_thread(
terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m
)
@@ -145,34 +218,73 @@ async def plan_terrain_extent(
@router.post("/{project_id}/plan/terrain", response_model=None)
async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]:
"""사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`."""
"""사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress` ·
위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413."""
body = body or TerrainBody()
root, epsg, ends = await _plan_ends(project_id)
center = (body.center.lon, body.center.lat) if body.center else None
root, epsg, ends, band = await _plan_ends(project_id, center, body.reset)
if band is not None:
return {
"status": "band",
"epsg": epsg,
"center_epsg": band,
"message": "다른 좌표 띠로 옮깁니다 — 그은 지점 · 선을 지우고 새로 받을까요?",
}
have = None if body.reset else terrain.received_box(root, epsg)
if center is not None and have is not None:
far = terrain.distance_to_box(lonlat_to_tm(center[0], center[1], epsg), have)
if far > terrain.FAR_M:
return {
"status": "far",
"distance_m": round(far),
"message": f"받은 지형에서 {far / 1000:.1f} km 떨어졌습니다 — "
"옛 받은 범위를 버리고 새로 받을까요?",
}
points = [*ends, *body.extra]
cells = terrain.cells_of(terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have))
if cells > terrain.MAX_CELLS:
raise HTTPException(
status_code=413,
detail=f"받을 범위가 너무 큽니다 — 칸 {cells / 1e4:.0f}만 · "
f"상한 {terrain.MAX_CELLS / 1e4:.0f}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)",
)
async def work(report: Any) -> None:
got = await asyncio.to_thread(
terrain.ensure_plan_terrain, root, points, epsg, body.margin_m, report
terrain.ensure_plan_terrain,
root,
points,
epsg,
body.margin_m,
report,
reset=have is None,
center=center,
)
await _save_models(project_id, got["models"], epsg)
report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매")
return _start_job(project_id, "terrain", work)
return _start_job(project_id, "terrain", work, root)
@router.get("/{project_id}/plan/progress", response_model=None)
async def plan_progress(project_id: UUID) -> dict[str, Any]:
idle = {"running": False, "percent": 0, "message": None, "error": None}
return {
"status": "success",
**{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS},
}
"""일마다 진행 — 기억에 없으면 기록을 봄(돌던 기록 = 끊김 · 없으면 percent 100)."""
jobs: dict[str, Any] = {}
for kind in KINDS:
job = _JOBS.get((str(project_id), kind))
if job is None:
try:
job = await asyncio.to_thread(_saved_job, await _tile_root(project_id), kind)
except HTTPException:
job = IDLE
jobs[kind] = dict(job)
return {"status": "success", **jobs}
async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]:
"""(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404."""
root, epsg, _ = await _plan_ends(project_id)
box = terrain.received_box(root)
root, epsg, _, _ = await _plan_ends(project_id)
box = terrain.received_box(root, epsg)
if box is None:
raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.")
return root, epsg, box
@@ -301,12 +413,30 @@ async def plan_layer_zero_order(
return {"status": "success", **value}
async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]:
"""(실경로, 좌표계, 자르는 범위, 기준점 m) — 기준점 = 위치 칸으로 받은 가운데 · 등록 기준점 ·
지점 가운데 차례 · 지형 받기 전에도(595-4)."""
root, epsg, ends, _ = await _plan_ends(project_id)
center = terrain.received_meta(root).get("center")
site = (await network.project_info(project_id))["site"]
if center:
origin = lonlat_to_tm(float(center[0]), float(center[1]), epsg)
elif site:
origin = lonlat_to_tm(site["lon"], site["lat"], epsg)
else:
xs, ys = [p[0] for p in ends], [p[1] for p in ends]
origin = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
box = forest_road.clip_box(origin, terrain.received_box(root, epsg))
return root, epsg, box, origin
@router.get("/{project_id}/plan/layers/forest-roads", response_model=None)
async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]:
"""올린 임도망도(받은 범위로 자른 선 · 가닥 · 길이) — 올린 적 없으면 404."""
root, epsg, box = await _layer_root(project_id)
"""올린 임도망도(기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 가닥 · 길이 · 잘린 상자 · 기준점
거리) — 올린 적 없으면 404."""
root, epsg, box, origin = await _road_frame(project_id)
try:
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg)
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return {"status": "success", **value}
@@ -317,8 +447,8 @@ async def plan_layer_forest_roads_upload(
project_id: UUID, files: list[UploadFile] = File(...)
) -> dict[str, Any]:
"""임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음 · 합계 100 MB) — 못 읽거나
받은 범위 안 선이 없으면 400(옛 것 그대로) · 넘치면 413."""
root, epsg, box = await _layer_root(project_id)
자르는 범위 안 선이 없으면 400(옛 것 그대로 · 가장 가까운 임도 거리) · 넘치면 413."""
root, epsg, box, origin = await _road_frame(project_id)
got: list[tuple[str, bytes]] = []
total = 0
for upload in files:
@@ -335,7 +465,7 @@ async def plan_layer_forest_roads_upload(
if not total:
raise HTTPException(status_code=400, detail="빈 파일입니다.")
try:
value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg)
value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg, origin)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return {"status": "success", **value}
+69 -33
View File
@@ -1,13 +1,17 @@
"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · PLAN 530-1 · 561-2).
"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · 463 · PLAN 530-1 ·
561-2 · 595).
지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0):
- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음)
- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) ·
새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 ·
칸 상한 `MAX_CELLS`(넘으면 라우터가 거절)
- 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음)
- 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀)
- 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀)
- 지적도 = 범위 상자 · 브이월드 WFS 1 km 조각(`D03_PlanAnalysis_Layers` · 실패해도 계속)
- 지적도 = 지형 일에서 뺌 — 겹을 켤 때 GET 이 받음(`D03_PlanAnalysis_Layers.ensure_cadastral`)
- DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json`
(상자 · 여유 · 좌표계 · 위치 칸 가운데)
- 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음)
- 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만
@@ -43,7 +47,6 @@ from config.config_system_terrain import (
SHEET_SURFACE_DEFAULT_METHOD,
SHEET_SURFACE_MARGIN_M,
)
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
logger = logging.getLogger(__name__)
@@ -52,6 +55,9 @@ DEFAULT_MARGIN_M = 350.0
MAX_MARGIN_M = 5000.0
# 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2)
SITE_RADIUS_M = 1000.0
# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 DEM 칸 상한(요구서_463 3장 4)
FAR_M = 5000.0
MAX_CELLS = 5_000_000
STAGE = Path("B04_PreProcess")
PLAN_MODELS = "models_plan"
TERRAIN_META = "plan_terrain.json"
@@ -74,9 +80,16 @@ def clamp_margin(margin_m: float | None) -> float:
return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m)))
def received_box(project_root: Path) -> Box | None:
"""받은 DEM 범위 — 받은 적 없거나 읽지 못하면 None."""
meta = _read_json(models_dir_of(project_root) / TERRAIN_META) or {}
def received_meta(project_root: Path) -> dict[str, Any]:
"""받은 범위 기록(`box` · `margin_m` · `epsg` · `center`) — 없으면 빈 dict."""
return _read_json(models_dir_of(project_root) / TERRAIN_META) or {}
def received_box(project_root: Path, epsg: int | None = None) -> Box | None:
"""받은 DEM 범위 — 받은 적 없거나 읽지 못하거나 다른 좌표계(`epsg` 줄 때)면 None."""
meta = received_meta(project_root)
if epsg is not None and meta.get("epsg") not in (None, int(epsg)):
return None
try:
x0, y0, x1, y1 = (float(v) for v in meta["box"])
except (KeyError, TypeError, ValueError):
@@ -99,6 +112,18 @@ def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box:
return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3])
def cells_of(box: Box) -> int:
"""DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 · 상한 판정."""
return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2)
def distance_to_box(xy: XY, box: Box) -> float:
"""점 ~ 상자 거리(m · 안이면 0)."""
dx = max(box[0] - xy[0], 0.0, xy[0] - box[2])
dy = max(box[1] - xy[1], 0.0, xy[1] - box[3])
return math.hypot(dx, dy)
def covers(have: Box | None, box: Box) -> bool:
return (
have is not None
@@ -135,7 +160,7 @@ def plan_extent(
) -> dict[str, Any]:
"""받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m)."""
margin = clamp_margin(margin_m)
have = received_box(project_root)
have = received_box(project_root, epsg)
box = wanted_box(points, margin, have)
store = get_project_map_sheets_dir(project_root)
sheets = []
@@ -157,8 +182,8 @@ def plan_extent(
"have": box_ring(have, epsg) if have else None,
"covered": covers(have, box),
"sheets": sheets,
# DEM 칸 수 어림 — 받기 시간 · 메모리 가늠
"cells": int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2),
"cells": cells_of(box),
"max_cells": MAX_CELLS,
}
@@ -181,10 +206,12 @@ def _read_json(path: Path) -> dict[str, Any] | None:
return None
def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[str]:
"""병합 = 지난번 병합 도엽(zip 남은 것) ∪ 이번 도엽 · 열쇠 같으면 건너뜀."""
def _merge_sheets(
processed: Path, store: Path, available: list[str], keep_old: bool = True
) -> list[str]:
"""병합 = 지난번 병합 도엽(zip 남은 것 · 새로면 뺌) ∪ 이번 도엽 · 열쇠 같으면 건너뜀."""
key_path = processed / "sheet_merge.key.json"
old = (_read_json(key_path) or {}).get("sheets") or []
old = ((_read_json(key_path) or {}).get("sheets") or []) if keep_old else []
sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)})
key = {"sheets": sheets, "parser": PARSER_VERSION}
if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))):
@@ -193,8 +220,15 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st
return sheets
def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], epsg: int) -> str:
"""위성 = 기준 도엽 도곽 ∪ 옛 범위 · 이미 덮으면 건너뜀. 돌려줌 = skip · done · failed."""
def _satellite(
processed: Path,
prj: Path,
points: list[tuple[float, float]],
epsg: int,
keep_old: bool = True,
) -> str:
"""위성 = 기준 도엽 도곽 ∪ 옛 범위(새로면 뺌) · 이미 덮으면 건너뜀. 돌려줌 = skip · done ·
failed."""
from pyproj import Transformer
to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
@@ -207,7 +241,7 @@ def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], ep
xs.append(x)
ys.append(y)
need = {"x": [min(xs), max(xs)], "y": [min(ys), max(ys)]}
meta = _read_json(processed / "vworld_satellite_meta.json")
meta = _read_json(processed / "vworld_satellite_meta.json") if keep_old else None
try:
old = [float(meta[k]) for k in ("x_min", "x_max", "y_min", "y_max")] if meta else None
except (KeyError, TypeError, ValueError):
@@ -234,8 +268,11 @@ def ensure_plan_terrain(
epsg: int,
margin_m: float | None = None,
on_progress: Progress | None = None,
reset: bool = False,
center: tuple[float, float] | None = None,
) -> dict[str, Any]:
"""사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).
"""사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m) ·
`reset` = 옛 받은 범위 버리고 새로 · `center` = 위치 칸 가운데(경위도 · 기록만).
돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록.
"""
@@ -250,28 +287,24 @@ def ensure_plan_terrain(
processed.mkdir(parents=True, exist_ok=True)
prj = write_plan_prj(project_root, epsg)
margin = clamp_margin(margin_m)
box = wanted_box(points, margin, received_box(project_root))
have = None if reset else received_box(project_root, epsg)
box = wanted_box(points, margin, have)
report(5, "수치지형도 도엽 고르는 중")
report(5, "1/5 수치지형도 도엽 고르는 중")
wanted = sheets_for_box(box, epsg)
store = get_project_map_sheets_dir(project_root)
before = {s for s in wanted if get_sheet_path(store, s) is not None}
report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중")
report(10, f"2/5 수치지형도 도엽 {len(wanted) - len(before)}매 받는 중")
got = ensure_sheets(store, wanted)
if not got["available"]:
raise ValueError(f"수치지형도 도엽을 하나도 받지 못했습니다: {got['failed']}")
report(45, "도엽 레이어 병합 중")
merged = _merge_sheets(processed, store, list(got["available"]))
report(60, "위성 지도 받는 중")
satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg)
report(62, "지적도 받는 중")
try:
cadastral = layers.ensure_cadastral(processed, box, epsg)["count"]
except Exception as exc: # noqa: BLE001 — 지적도는 겹 · 지형 받기를 막지 않음(켤 때 다시)
logger.warning("D 지적도 받기 실패: %s", exc)
cadastral = None
report(45, "3/5 도엽 레이어 병합 중")
merged = _merge_sheets(processed, store, list(got["available"]), keep_old=have is not None)
report(60, "4/5 위성 지도 받는 중")
ring = [(p[1], p[0]) for p in box_ring(box, epsg)]
satellite = _satellite(processed, prj, ring, epsg, keep_old=have is not None)
report(65, "사업 지형면 만드는 중")
report(65, f"5/5 사업 지형면 만드는 중 — 칸 {cells_of(box) / 1e4:.0f}만 · 수 분 걸릴 수 있음")
models_dir = models_dir_of(project_root)
models_dir.mkdir(parents=True, exist_ok=True)
# B 절취 = 넘긴 꼭짓점 상자 + `SHEET_SURFACE_MARGIN_M` → 그만큼 안쪽으로 넘김(여유 ≥ 그 값)
@@ -293,7 +326,11 @@ def ensure_plan_terrain(
"사업 지형면을 만들지 못했습니다 — 범위 안 도엽 등고선 높이 확인"
f"(병합 도엽 {len(merged)}매)"
)
atomic_write_json(models_dir / TERRAIN_META, {"box": list(box), "margin_m": margin})
meta: dict[str, Any] = {"box": list(box), "margin_m": margin, "epsg": int(epsg)}
kept = None if reset else received_meta(project_root).get("center")
if center or kept:
meta["center"] = list(center or kept)
atomic_write_json(models_dir / TERRAIN_META, meta)
report(100, "지형 받기 끝")
return {
"sheets": {
@@ -303,7 +340,6 @@ def ensure_plan_terrain(
"merged": merged,
},
"satellite": satellite,
"cadastral": cadastral,
"box_m": list(box),
"models": models,
}
+28 -7
View File
@@ -41,10 +41,11 @@ import {
removeLine,
reverseLine,
samePoint,
type NodeRecord,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
import { nodeRecord, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
import {
fmt,
lineAt,
@@ -122,8 +123,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let kind: DrawConstraint = "free";
let lengthM = DRAW_DEFAULT_LENGTH_M;
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점
let span: { line: string; from: number; to: number; through: Vertex[] } | null = null;
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록
let span: {
line: string;
from: number;
to: number;
through: Vertex[];
records: NodeRecord[];
} | null = null;
const spanLine = (): number =>
span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1;
@@ -272,7 +279,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return false;
}
setMode("draw");
span = { line: lineId, from, to, through: [] };
span = { line: lineId, from, to, through: [], records: [] };
panelState();
params.onMode();
return true;
@@ -381,9 +388,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
return false;
}
// 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2)
const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct);
if (span) {
if (!preview.snapped) return (span.through.push(preview.point), true);
const next = replaceSpan(params.network(), spanLine(), span.from, span.to, span.through);
if (!preview.snapped) {
span.through.push(preview.point);
span.records.push(record);
return true;
}
const next = replaceSpan(
params.network(),
spanLine(),
span.from,
span.to,
span.through,
span.records,
);
if (typeof next === "string") showToast(next, "info");
else params.commit(next);
span = null;
@@ -396,7 +416,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let next = params.network();
if (preview.onLine)
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
params.commit(appendVertex(next, line, preview.point));
params.commit(appendVertex(next, line, preview.point, record));
if (ended) {
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
setMode("idle");
@@ -494,6 +514,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return true;
}
if (mode === "draw" && span && keyIs(event, "plan.drawBack")) {
span.records.pop();
if (!span.through.pop()) setMode("idle");
return true;
}
@@ -6,7 +6,7 @@
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
* ========================================================================== */
import { pointColor } from "@ui/ui_template_palette";
import { pointColor, routeColor } from "@ui/ui_template_palette";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
distanceAlong,
@@ -18,6 +18,8 @@ import {
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
import {
planColors,
PLAN_STATION_INTERVAL_M,
@@ -127,7 +129,7 @@ export function paintNetwork(
context.lineJoin = "round";
network.lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : colors.line;
context.strokeStyle = index === active ? colors.active : routeColor(index);
context.lineWidth = line.branch ? 2.5 : 3;
const path = (vertices: ReadonlyArray<Vertex>): void => {
context.beginPath();
@@ -159,21 +161,28 @@ export function paintNetwork(
);
if (map.slopes) segmentLabels(context, map, line.vertices, z);
});
// 꺾임점
// 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
context.fillStyle = "#ffffff";
context.strokeStyle = colors.warn;
context.lineWidth = 2;
for (const line of network.lines)
for (const v of line.vertices.slice(1)) {
line.vertices.forEach((v, i) => {
if (!i) return;
const [sx, sy] = map.toScreen(v);
context.beginPath();
context.arc(sx, sy, 3, 0, Math.PI * 2);
context.fill();
}
if (!nodeOff(line, i, z)) return;
context.beginPath();
context.arc(sx, sy, 7, 0, Math.PI * 2);
context.stroke();
});
drawPieces(context, map, network);
extra();
}
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
export function pointLayer(points: () => Vertex[]): PlanMapLayer {
export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer {
return {
draw(context, map) {
points().forEach((point, i) => {
@@ -198,7 +207,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer {
context.fillStyle = "#ffffff";
context.fill();
});
planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px");
planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px");
});
},
};
+153 -20
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Points.ts
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
@@ -10,18 +11,27 @@
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createButton } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createListRow } from "@ui/ui_template_list_row";
import { pointColor } from "@ui/ui_template_palette";
import { createText } from "@ui/ui_template_text";
import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
addPoint,
MAX_POINTS,
movePointTo,
parseLonLat,
pointLabel,
pointNameError,
renamePoint,
type PointsState,
} from "./logic/D03_PlanAnalysis_PointEdit";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
const MAX_POINTS = 40;
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
const PIN_HEAD_PX = 18;
@@ -56,10 +66,22 @@ export interface PlanPoints {
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
const list = document.createElement("div");
list.className = "d03-plan__points";
const hint = createText("", "muted");
hint.classList.add("d03-plan__hint");
const addButton = createButton({
label: "추가",
iconStart: "plus",
variant: "ghost",
size: 24,
onClick: addAtCenter,
});
addButton.dataset.role = "points-add";
const head = document.createElement("div");
head.className = "d03-plan__points-head";
head.append(addButton);
const panel = document.createElement("div");
panel.append(list, hint);
panel.append(head, list);
let openIndex: number | null = null; // 펼친 줄(하나만)
let syncEditor: (() => void) | null = null;
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
let drag: {
@@ -69,7 +91,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
last: [Vertex[], PlanNetwork] | null;
} | null = null;
const name = (i: number): string => `지점 ${i + 1}`;
const name = (i: number): string => pointLabel(params.names(), i);
const state = (): PointsState => ({
points: params.points(),
names: params.names(),
network: params.network(),
});
const commitState = (next: PointsState): void =>
params.commit(next.points, next.names, next.network);
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
function pinAt(px: number, py: number, map: ScreenMap): number | null {
@@ -88,6 +117,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
}
function remove(i: number): void {
openIndex = null;
params.commit(
params.points().filter((_, k) => k !== i),
params.names().filter((_, k) => k !== i),
@@ -134,7 +164,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
return true;
}
const pins = pointLayer(params.points);
const pins = pointLayer(params.points, params.names);
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
const input: PlanMapLayer = {
@@ -163,12 +193,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
},
};
/** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */
function addAtCenter(): void {
const at = params.center();
if (!at || !params.editable() || params.points().length >= MAX_POINTS) return;
openIndex = params.points().length;
commitState(addPoint(state(), at));
}
/** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */
function editorOf(i: number): { root: HTMLElement; sync: () => void } {
const tm = tmParams(params.epsg());
const editable = params.editable();
const field = (label: string, role: string, placeholder?: string) => {
const f = createInputField({ label, size: 24, placeholder });
f.input.dataset.role = role;
f.input.disabled = !editable || (role !== "point-name" && !tm);
return f;
};
const nameField = field("이름", "point-name", `지점 ${i + 1}`);
const lonField = field("경도", "point-lon");
const latField = field("위도", "point-lat");
const lonLatNow = (): [number, number] | null => {
const p = params.points()[i];
return tm && p ? tmToLonLat(tm, p[0], p[1]) : null;
};
const sync = (): void => {
const set = (f: ReturnType<typeof field>, text: string): void => {
if (document.activeElement !== f.input) f.input.value = text;
};
set(nameField, params.names()[i] ?? "");
const ll = lonLatNow();
set(lonField, ll ? ll[0].toFixed(6) : "");
set(latField, ll ? ll[1].toFixed(6) : "");
};
nameField.input.addEventListener("input", () =>
nameField.setError(pointNameError(nameField.input.value.trim()) ?? undefined),
);
nameField.input.addEventListener("change", () => {
const text = nameField.input.value.trim();
const error = pointNameError(text);
nameField.setError(error ?? undefined);
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
});
const moved = (): void => {
const parsed = parseLonLat(lonField.input.value, latField.input.value);
const bad = typeof parsed === "string";
lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined);
latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined);
if (bad || !tm) return;
const now = lonLatNow();
if (
now &&
now[0].toFixed(6) === parsed[0].toFixed(6) &&
now[1].toFixed(6) === parsed[1].toFixed(6)
)
return;
commitState(movePointTo(state(), i, tm, parsed[0], parsed[1]));
};
lonField.input.addEventListener("change", moved);
latField.input.addEventListener("change", moved);
const root = document.createElement("div");
root.className = "d03-plan__point-edit";
root.append(nameField.root, lonField.root, latField.root);
sync();
return { root, sync };
}
function render(): void {
const tm = tmParams(params.epsg());
const editable = params.editable();
const points = params.points();
if (openIndex !== null && openIndex >= points.length) openIndex = null;
addButton.hidden = !editable;
addButton.disabled = points.length >= MAX_POINTS || !params.center();
const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`;
const lonLatOf = (i: number): string | undefined => {
const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null;
return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined;
};
if (key === shown) {
// 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움
points.forEach((_, i) => {
const row = list.children[i]?.querySelector(".ui-list-row");
const label = row?.querySelector(".ui-list-row__label");
const sub = row?.querySelector(".ui-list-row__sub");
if (label) label.textContent = name(i);
if (sub) sub.textContent = lonLatOf(i) ?? "";
});
syncEditor?.();
return;
}
shown = key;
syncEditor = null;
list.replaceChildren(
...params.points().map((point, i) => {
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
...points.map((_, i) => {
const drop = createIconButton({
icon: "trash",
title: `${name(i)} 지우기`,
@@ -176,22 +295,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
onClick: () => remove(i),
});
drop.hidden = !editable;
return createListRow({
const row = createListRow({
label: name(i),
sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined,
sub: lonLatOf(i),
stripe: pointColor(i),
size: 28,
trailing: drop,
active: i === openIndex,
});
row.dataset.point = String(i);
row.tabIndex = 0;
const toggle = (): void => {
openIndex = openIndex === i ? null : i;
render();
};
row.addEventListener("click", (event) => {
if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle();
});
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) toggle();
});
const item = document.createElement("div");
item.append(row);
if (i === openIndex) {
const editor = editorOf(i);
syncEditor = editor.sync;
item.append(editor.root);
}
return item;
}),
);
hint.textContent = !editable
? params.points().length
? "보기만 — 지점은 산림청이 놓습니다."
: "지점 없음"
: params.points().length
? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함."
: "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작).";
}
return {
@@ -196,6 +196,24 @@
font-weight: 600;
}
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
.d03-plan__points-head {
display: flex;
justify-content: flex-end;
margin-bottom: var(--spacing-8);
}
.d03-plan__point-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2);
}
.d03-plan__point-edit > .ui-field {
margin: 0;
}
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
.d03-plan__draw-kind {
display: flex;
+41 -11
View File
@@ -2,7 +2,8 @@
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1).
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1) ·
* 노드 기록이 꼭짓점과 같이 움직임(599-2 · 기록 만들기 · 어긋남 = `D03_PlanAnalysis_Span`).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
* ========================================================================== */
@@ -21,6 +22,15 @@ export interface NodeRecord {
slope_pct: number | null;
}
/** 꼭짓점이 바뀐 선 — 기록 있는 선만 `edit` 로 기록도 같이(없는 선 = 그대로 없음). */
function withNodes(
line: PlanLine,
vertices: Vertex[],
edit: (nodes: Array<NodeRecord | null>) => Array<NodeRecord | null>,
): PlanLine {
return line.nodes ? { ...line, vertices, nodes: edit([...line.nodes]) } : { ...line, vertices };
}
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0) · `name` 없음 = 차례 이름(`lineNames`) ·
* `nodes` = 꼭짓점마다 기록(같은 길이 · 칸 없음 = 기록 없음 · 594-1). */
@@ -201,7 +211,7 @@ export function insertVertex(
if (i === line) {
const vertices = [...entry.vertices];
vertices.splice(segment + 1, 0, point);
return { ...entry, vertices };
return withNodes(entry, vertices, (nodes) => (nodes.splice(segment + 1, 0, null), nodes));
}
const branch = entry.branch;
return branch && branch.line === id && branch.vertex > segment
@@ -211,13 +221,21 @@ export function insertVertex(
};
}
/** 선 끝에 점을 더함. */
export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork {
/** 선 끝에 점을 더함 — `record` = 그 노드 긋기 방식 · 값(599-2 · 처음 기록이면 앞 노드 = 기록 없음). */
export function appendVertex(
network: PlanNetwork,
line: number,
point: Vertex,
record: NodeRecord | null = null,
): PlanNetwork {
return {
...network,
lines: network.lines.map((entry, i) =>
i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry,
),
lines: network.lines.map((entry, i) => {
if (i !== line) return entry;
const vertices = [...entry.vertices, point];
const nodes = entry.nodes ?? (record ? entry.vertices.map(() => null) : null);
return nodes ? { ...entry, vertices, nodes: [...nodes, record] } : { ...entry, vertices };
}),
};
}
@@ -233,7 +251,9 @@ export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork
return {
...network,
lines: network.lines.map((other, i) =>
i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other,
i === line
? withNodes(other, other.vertices.slice(0, last), (nodes) => nodes.slice(0, last))
: other,
),
};
}
@@ -430,7 +450,8 @@ export function removeVertex(
};
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices };
if (i === line)
return withNodes(other, vertices, (nodes) => nodes.filter((_, k) => k !== vertex));
const branch = other.branch;
return branch?.line === entry.id && branch.vertex > vertex
? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } }
@@ -448,7 +469,10 @@ export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | s
const length = round3(lineLength(entry.vertices));
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices: [...other.vertices].reverse() };
if (i === line)
return withNodes(other, [...other.vertices].reverse(), (nodes) =>
nodes.map((_, k) => (k ? nodes[n - k] : null)),
);
const branch = other.branch;
return branch?.line === entry.id
? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } }
@@ -502,7 +526,13 @@ function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork {
);
const lines = network.lines
.map((line, i) => {
if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] };
if (i === a) {
const vertices = [...line.vertices, ...tail.vertices.slice(1)];
if (!line.nodes && !tail.nodes) return { ...line, vertices };
const nodesOf = (entry: PlanLine): Array<NodeRecord | null> =>
entry.nodes ?? entry.vertices.map(() => null);
return { ...line, vertices, nodes: [...nodesOf(line), ...nodesOf(tail).slice(1)] };
}
const branch = line.branch;
return branch?.line === tail.id
? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } }
@@ -0,0 +1,76 @@
/* =============================================================================
* D03_PlanAnalysis_PointEdit.ts
* D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/).
* 이름 = 20 자 안 · 파일 이름에 못 쓰는 글자(`/ \ : * ? " < > |`) 막음 · 빈 값 = 「지점 N」 보임.
* 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`).
* ⚠ 이름 한도 · 글자 = `Plan.ts` 의 `NAME_MAX` · `nameError`(594-1)와 같은 규칙 — 그쪽이 들어오면 거기서 가져옴.
* ========================================================================== */
import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan";
import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
export const MAX_POINTS = 40;
export const POINT_NAME_MAX = 20;
const BAD_CHARS = /[\\/:*?"<>|]/;
/** 이름 까닭 글 — 쓸 수 있으면 null. */
export function pointNameError(name: string): string | null {
if (name.length > POINT_NAME_MAX) return `이름은 ${POINT_NAME_MAX} 자까지`;
if (BAD_CHARS.test(name)) return '이름에 / \\ : * ? " < > | 는 못 씁니다';
return null;
}
/** 보이는 이름 — 빈 값 = 「지점 N」. */
export function pointLabel(names: string[], index: number): string {
return names[index]?.trim() || `지점 ${index + 1}`;
}
/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */
export function parseLonLat(lonText: string, latText: string): [number, number] | string {
const lon = Number(lonText.trim());
const lat = Number(latText.trim());
if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180";
if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90";
return [lon, lat];
}
export interface PointsState {
points: Vertex[];
names: string[];
network: PlanNetwork;
}
/** 지점 더함 — 이름 비움. 상한이면 그대로. */
export function addPoint(state: PointsState, at: Vertex): PointsState {
if (state.points.length >= MAX_POINTS) return state;
return {
...state,
points: [...state.points, [at[0], at[1]]],
names: [...state.names, ""],
};
}
/** 이름 바꿈 — 빈 값 허용. */
export function renamePoint(state: PointsState, index: number, name: string): PointsState {
const names = state.points.map((_, k) => state.names[k] ?? "");
names[index] = name.trim();
return { ...state, names };
}
/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */
export function movePointTo(
state: PointsState,
index: number,
tm: TmParams,
lon: number,
lat: number,
): PointsState {
const to = lonLatToTm(tm, lon, lat);
const from = state.points[index];
return {
...state,
points: state.points.map((p, k): Vertex => (k === index ? to : p)),
network: moveNodesAt(state.network, from, to),
};
}
@@ -3,14 +3,35 @@
* D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈.
* 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 ·
* 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠).
* 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유.
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* ========================================================================== */
import { lineLength } from "./D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
import { groundSlopePct, lineLength, segmentLength, type HeightAt } from "./D03_PlanAnalysis_Draw";
import type { NodeKind, NodeRecord, PlanLine, PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** 그리기 칸 방식 · 값 → 노드 기록(안 쓴 값 = null · 599-2). */
export function nodeRecord(kind: NodeKind, lengthM: number, slopePct: number): NodeRecord {
return {
kind,
length_m: kind === "length" || kind === "both" ? lengthM : null,
slope_pct: kind === "slope" || kind === "both" ? slopePct : null,
};
}
/** 노드 기록과 지금 앞 토막이 어긋남(옮긴 노드 ⚠ · 599-2) — 길이 1 cm · |기울기| 0.1 % 넘게 · 지형 밖 = 안 봄. */
export function nodeOff(line: PlanLine, vertex: number, z: HeightAt): boolean {
const record = line.nodes?.[vertex];
if (!record || vertex < 1) return false;
const [a, b] = [line.vertices[vertex - 1], line.vertices[vertex]];
if (record.length_m !== null && Math.abs(segmentLength(a, b) - record.length_m) > 0.01)
return true;
const slope = record.slope_pct === null ? null : groundSlopePct(a, b, z);
return slope !== null && Math.abs(Math.abs(slope) - Math.abs(record.slope_pct!)) > 0.1;
}
/** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */
export function respanBlocked(
network: PlanNetwork,
@@ -30,13 +51,15 @@ export function respanBlocked(
return null;
}
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글.
* `records` = 속 점 기록(`through` 와 같은 길이) · 뒤 노드 기록 = 자유 · 기록 없는 선 + 기록 없음 = 그대로 없음. */
export function replaceSpan(
network: PlanNetwork,
line: number,
from: number,
to: number,
through: ReadonlyArray<Vertex>,
records: ReadonlyArray<NodeRecord | null> = [],
): PlanNetwork | string {
const blocked = respanBlocked(network, line, from, to);
if (blocked) return blocked;
@@ -56,13 +79,22 @@ export function replaceSpan(
: m >= oldEndM
? m - oldEndM + newEndM
: startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM);
const branched = (other: PlanLine): PlanLine =>
other.branch?.line === entry.id && other.branch.vertex >= to
? { ...other, branch: { ...other.branch, vertex: other.branch.vertex + shift } }
: other;
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices };
const branch = other.branch;
return branch?.line === entry.id && branch.vertex >= to
? { ...other, branch: { ...branch, vertex: branch.vertex + shift } }
: other;
if (i !== line) return branched(other);
if (!other.nodes && !records.some(Boolean)) return { ...other, vertices };
const nodes = other.nodes ?? old.map(() => null);
const inner = through.map((_, k) => records[k] ?? null);
const end = nodeRecord("free", 0, 0);
return {
...other,
vertices,
nodes: [...nodes.slice(0, from + 1), ...inner, end, ...nodes.slice(to + 1)],
};
}),
pieces: network.pieces.map((piece) =>
piece.line === entry.id
+36 -3
View File
@@ -1,5 +1,6 @@
"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 ·
옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음.
"""D 예정노선 지형 받기(PLAN 530-1 · 561-2 · 595) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 ·
옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음 · 지적도 뺌 · 새로(reset) = 옛 범위 버림 ·
받은 범위 좌표계 · 칸 상한 · 거리.
./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q
"""
@@ -54,7 +55,6 @@ def fake(tmp_path, monkeypatch):
monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge)
monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite)
monkeypatch.setattr(terrain, "build_sheet_surface_model", dem)
monkeypatch.setattr(terrain.layers, "ensure_cadastral", lambda *a: {"count": 0}) # 569-1
return tmp_path, seen
@@ -104,6 +104,39 @@ def test_one_set_and_extend_keeps_old(fake) -> None:
assert len(seen["merge"]) == merges and third["box_m"] == grown
def test_reset_drops_old_box_sheets_satellite(fake) -> None:
"""595-2 — 새로 = 옛 받은 범위 · 병합 도엽 · 위성 범위 버림 · 좌표계 · 가운데 기록 ·
다른 좌표계로 물으면 받은 범위 없음 · 지적도는 지형 일에 없음."""
root, seen = fake
terrain.ensure_plan_terrain(root, [A, B], EPSG, center=(127.5, 36.0))
old = set(seen["merge"][-1])
far = [(A[0] + 40000.0, A[1]), (A[0] + 41000.0, A[1] + 1000.0)]
got = terrain.ensure_plan_terrain(root, far, EPSG, reset=True, center=(128.0, 36.0))
m = terrain.DEFAULT_MARGIN_M
assert got["box_m"] == pytest.approx(
[far[0][0] - m, far[0][1] - m, far[1][0] + m, far[1][1] + m]
)
assert not old & set(seen["merge"][-1]) # 옛 도엽 병합에서 빠짐(zip 은 남음)
assert seen["satellite"][-1]["x"][0] > A[0] + 30000 # 옛 위성 범위와 안 합침
assert "cadastral" not in got
meta = terrain.received_meta(root)
assert meta["epsg"] == EPSG and meta["center"] == [128.0, 36.0]
assert terrain.received_box(root, EPSG) == pytest.approx(tuple(got["box_m"]))
assert terrain.received_box(root, 5187) is None # 다른 띠 = 받은 범위 아님
# 새로 아닌 이어 받기 = 가운데 기록 그대로
terrain.ensure_plan_terrain(root, far, EPSG)
assert terrain.received_meta(root)["center"] == [128.0, 36.0]
def test_cells_distance_and_limit(fake) -> None:
root, _ = fake
assert terrain.cells_of((0.0, 0.0, 2000.0, 2000.0)) == 1_000_000 # 2 m 격자
assert terrain.distance_to_box((5.0, 5.0), (0.0, 0.0, 10.0, 10.0)) == 0
assert terrain.distance_to_box((13.0, 14.0), (0.0, 0.0, 10.0, 10.0)) == 5
assert terrain.MAX_CELLS == 5_000_000 and terrain.FAR_M == 5000
assert terrain.plan_extent(root, [A, B], EPSG)["max_cells"] == terrain.MAX_CELLS
def test_extent_no_download_and_covered(fake) -> None:
root, seen = fake
before = terrain.plan_extent(root, [A, B], EPSG)
+159 -4
View File
@@ -1,5 +1,6 @@
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이
없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌.
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2 · 595) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 ·
선이 없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌 · 위치 칸 가운데 = 둘레 덧 · 멀다 · 다른 띠
물음 · 새로(reset) · 칸 상한 413 · 서버 다시 켜짐 = 끊김.
옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) ·
노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`.
@@ -20,6 +21,8 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
P0, P2 = (0.0, 0.0), (900.0, 400.0)
URL = f"/api/projects/{PID}/plan/terrain"
OX, OY = 200000.0, 500000.0 # 595 시험 = 중부 띠 안(경도 127°)
Q0, Q2 = (OX + P0[0], OY + P0[1]), (OX + P2[0], OY + P2[1])
class _Pool:
@@ -51,6 +54,7 @@ def client(tmp_path, monkeypatch):
test = TestClient(app)
test.plan = plan
test.info = info
test.root = tmp_path
return test
@@ -90,14 +94,14 @@ def test_post_runs_one_set(client, monkeypatch) -> None:
saved: list = []
works: list = []
def ensure(root, points, epsg, margin_m, report):
def ensure(root, points, epsg, margin_m, report, **kw):
calls.append((points, epsg, margin_m))
return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]}
async def save(project_id, models, epsg):
saved.append((models, epsg))
def start(project_id, kind, work):
def start(project_id, kind, work, root=None):
works.append(work)
return {"status": "accepted", "kind": kind}
@@ -116,3 +120,154 @@ def test_post_runs_one_set(client, monkeypatch) -> None:
# 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점
post({"points": []})
assert calls[-1] == ([P0, P2], 5186, None)
# ── 595 위치 바꿈 · 상한 · 끊김 ──
@pytest.fixture
def posting(client, monkeypatch):
"""POST 받기 — 일은 바로 돌림 · ensure 부름(점 · 좌표계 · reset · center) 기록."""
calls: list = []
def ensure(root, points, epsg, margin_m, report, reset=False, center=None):
calls.append({"points": points, "epsg": epsg, "reset": reset, "center": center})
return {"sheets": {"downloaded": []}, "models": []}
async def save(project_id, models, epsg):
return None
works: list = []
def start(project_id, kind, work, root=None):
works.append(work)
return {"status": "accepted", "kind": kind}
def post(body):
res = client.post(URL, json=body)
while works: # 일은 응답 뒤 돌림(앱 고리 밖)
asyncio.run(works.pop()(lambda percent, message: None))
return res
monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure)
monkeypatch.setattr(router_mod, "_save_models", save)
monkeypatch.setattr(router_mod, "_start_job", start)
client.plan["points"] = [{"x": Q0[0], "y": Q0[1]}, {"x": Q2[0], "y": Q2[1]}]
client.calls = calls
client.run_post = post
return client
def _have(client, box, epsg=5186, center=None):
meta = {"box": list(box), "margin_m": 350.0, "epsg": epsg}
if center:
meta["center"] = list(center)
folder = router_mod.terrain.models_dir_of(client.root)
folder.mkdir(parents=True, exist_ok=True)
(folder / router_mod.terrain.TERRAIN_META).write_text(json.dumps(meta), "utf-8")
def test_center_near_adds_site_ring(posting) -> None:
"""가까운 위치 바꿈 = 지점 + 새 가운데 둘레 · 받은 범위와 합침."""
from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat
_have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0))
lon, lat = tm_to_lonlat(OX + 3000.0, OY, 5186) # 받은 범위에서 1.5 km
res = posting.run_post({"center": {"lon": lon, "lat": lat}}).json()
assert res["status"] == "accepted"
call = posting.calls[-1]
x, y = lonlat_to_tm(lon, lat, 5186)
r = router_mod.terrain.SITE_RADIUS_M - router_mod.terrain.DEFAULT_MARGIN_M
assert call["points"][:2] == [Q0, Q2] and call["points"][2] == pytest.approx((x - r, y - r))
assert call["reset"] is False and call["center"] == (lon, lat)
# extent 에도 캐시 가운데 = 둘레가 상자에 들어감
got = posting.get(f"{URL}/extent", params={"lon": lon, "lat": lat}).json()
assert got["box_m"][2] == pytest.approx(x + router_mod.terrain.SITE_RADIUS_M)
def test_center_far_asks_then_reset(posting) -> None:
"""5 km 넘음 = 「far」(받지 않음) · reset = 옛 범위 버리고 새 ±1 km(지점 안 넣음)."""
from common_util.common_util_crs import tm_to_lonlat
_have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0))
lon, lat = tm_to_lonlat(OX + 8000.0, OY, 5186) # 6.5 km
body = {"center": {"lon": lon, "lat": lat}}
far = posting.run_post(body).json()
assert far["status"] == "far" and far["distance_m"] == pytest.approx(6500, abs=2)
assert "km" in far["message"] and posting.calls == []
res = posting.run_post({**body, "reset": True}).json()
assert res["status"] == "accepted"
call = posting.calls[-1]
assert call["reset"] is True and len(call["points"]) == 2 # 둘레 두 귀만
assert call["points"][0][0] == pytest.approx(OX + 8000 - 650, abs=1)
def test_center_other_band_asks_then_moves(posting) -> None:
"""다른 TM 띠 = 「band」 · 확인(reset) 뒤 그 띠 · 그 뒤엔 받은 지형 띠를 따름."""
posting.plan["lines"] = [{"id": "a"}]
body = {"center": {"lon": 129.1017, "lat": 36.8128}} # 용화(동부 5187)
ask = posting.run_post(body).json()
assert ask == {
"status": "band",
"epsg": 5186,
"center_epsg": 5187,
"message": ask["message"],
}
assert posting.calls == []
# 확인 전 extent = 가운데 무시(옛 띠 그대로)
assert posting.get(f"{URL}/extent", params={"lon": 129.1, "lat": 36.8}).json()["epsg"] == 5186
posting.run_post({**body, "reset": True})
call = posting.calls[-1]
assert call["epsg"] == 5187 and call["reset"] is True and len(call["points"]) == 2
# 받은 뒤([저장] 전) — 가운데 없이 물어도 받은 지형 띠 · 가운데 둘레
(x0, y0), (x1, y1) = call["points"]
box = [x0 - 400, y0 - 400, x1 + 400, y1 + 400]
_have(posting, box, epsg=5187, center=(129.1017, 36.8128))
got = posting.get(f"{URL}/extent").json()
assert got["epsg"] == 5187 and got["covered"] is True
def test_cells_limit_413(posting) -> None:
posting.plan["points"].append(
{"x": OX + 6000.0, "y": OY + 6000.0}
) # 6.7 × 6.7 km = 1,100 만 칸
res = posting.run_post({})
assert res.status_code == 413 and "상한 500만" in res.json()["detail"]
assert posting.calls == []
def test_progress_interrupted_after_restart(client, monkeypatch) -> None:
"""도는 기록이 남았는데 기억에 없음(서버 다시 켜짐) = 끊김 · 받은 적 없음 = percent 100."""
root = client.root
async def tile_root(project_id):
return root
monkeypatch.setattr(router_mod, "_tile_root", tile_root)
url = f"/api/projects/{PID}/plan/progress"
router_mod._JOBS.clear()
idle = client.get(url).json()["terrain"]
assert idle == {**router_mod.IDLE}
path = router_mod._job_path(root, "terrain")
seen = []
async def work(report):
report(65, "5/5 사업 지형면 만드는 중")
seen.append(json.loads(path.read_text("utf-8")))
async def run():
router_mod._start_job(PID, "terrain", work, root)
await asyncio.sleep(0.05)
asyncio.run(run())
assert seen[0]["running"] is True and seen[0]["percent"] == 65 # 도는 동안 기록
assert json.loads(path.read_text("utf-8"))["running"] is False # 끝 기록
path.write_text(json.dumps(seen[0]), "utf-8") # 도는 중 꺼짐
router_mod._JOBS.clear() # 다시 켜짐 = 기억 빔
got = client.get(url).json()["terrain"]
assert got["running"] is False and got["interrupted"] is True
assert got["percent"] == 65 and got["message"] is None and got["error"] is None
# 끝난 기록 = 끊김 아님
path.write_text(json.dumps({**seen[0], "running": False}), "utf-8")
assert client.get(url).json()["terrain"] == {**router_mod.IDLE}
+23 -3
View File
@@ -1,5 +1,5 @@
"""D 노선분석 지도 겹(PLAN 569-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
겹친 필지 거름 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
겹친 필지 거름 · 실패 조각만 다시 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q
"""
@@ -127,9 +127,29 @@ def test_cadastral_failures(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=1))
first = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert first["failed"] == 1 and (tmp_path / layers.CADASTRAL_FILE).exists()
assert (
len(json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))["failed_tiles"]) == 1
)
# 595-1 — 실패 조각만 다시 · 필지는 옛 것 + 그 조각 = 통째로 받은 것과 같음
again = FakeWfs()
monkeypatch.setattr(layers, "request_vworld_wfs", again)
assert layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 15
retry = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert retry["fetched"] and again.calls == 1 and retry["failed"] == 0
assert retry["count"] == 15 * 2 + 1
assert not layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 1
# 옛 열쇠(실패 조각 기록 없음) = 통째로
key = json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))
key.update(failed=1)
key.pop("failed_tiles")
(tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert again.calls == 1 + 15
# 다시 받기도 모두 실패 = 옛 필지 그대로 · 실패 조각 남김(오류 아님)
key.update(failed=1, failed_tiles=[list(layers.tiles(BOX)[0])])
(tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=99))
kept = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert kept["failed"] == 1 and kept["count"] == 15 * 2 + 1
# ── 급경사 ──
@@ -1,5 +1,6 @@
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 5179 ·
cp949 · 받은 범위로 자름 · 범위 밖 = 받지 않음 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3 · 595-4) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면
5179 · cp949 · 기준점 ±5 km ∪ 받은 지형으로 자름 · 잘린 상자 · 기준점 거리 · 범위 밖 = 받지 않음 +
거리 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
도분초 · 지번) · 점 · 지적 필지 맞춤 · 표 없는 시도 · 화면 겹 정의.
./venv/Scripts/python.exe -m pytest resources/tester/test_584_3_plan_forest_road_zones.py -q
@@ -81,7 +82,7 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
([("a.zip", b"not a zip")], "zip 파일을 열지"),
([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"),
([("a.zip", _zip({"readme.txt": b"x"}))], ".shp 파일이 없습니다"),
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 범위 안에 임도가 없습니다"),
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 지형 밖뿐입니다"),
]
for upload, text in bad:
with pytest.raises(ValueError, match=re.escape(text)):
@@ -91,6 +92,26 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
assert not list((tmp_path / fr.FOLDER).glob("*.new"))
def test_clip_box_site_5km_and_distance(tmp_path) -> None:
"""595-4 — 자르는 범위 = 기준점 ±5 km ∪ 받은 지형 · 잘린 상자 · 가장 가까운 임도 거리 ·
밖뿐 = 거절 + 거리."""
site = (231000.0, 450600.0)
frame = fr.clip_box(site, BOX)
assert frame == (226000.0, 445600.0, 236000.0, 455600.0) # 5 km 상자가 받은 지형을 덮음
assert fr.clip_box(site, (220000.0, 450000.0, 232000.0, 451000.0))[0] == 220000.0 # ∪
assert fr.clip_box(site, None) == frame
got = fr.save_roads(tmp_path, _shp_files(tmp_path / "a", LINES), frame, EPSG, site)
assert (got["count"], got["total"]) == (2, 3) # 범위 밖 한 가닥(25 km)만 빠짐
assert got["length_m"] == pytest.approx(2000 + 300, abs=1) # 받은 지형 밖 반도 들어옴
assert got["distance_m"] == pytest.approx(100, abs=0.5) # 450600 ~ 450500
w, s, e, n = got["bounds"]
assert (w, s) == pytest.approx(tm_to_lonlat(229000, 450100, EPSG), abs=1e-4)
assert (e, n) == pytest.approx(tm_to_lonlat(231500, 450500, EPSG), abs=1e-4)
far = _shp_files(tmp_path / "far", [LINES[2]])
with pytest.raises(ValueError, match=r"가장 가까운 임도 2[0-9]\.[0-9] km\(파일 선 1가닥\)"):
fr.save_roads(tmp_path, far, frame, EPSG, site)
def test_no_upload_is_not_found(tmp_path) -> None:
with pytest.raises(FileNotFoundError):
fr.road_layer(tmp_path, BOX, EPSG)
+111
View File
@@ -0,0 +1,111 @@
"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김
(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`.
./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js";
import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js";
const Y = 2026;
const tm = tmx.tmParams(5187);
// 지점 (100,0) 에서 시작하는 선 · 지점 둘
let n = plan.newLine(plan.emptyNetwork(), [100, 0]);
n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y);
const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n };
const added = pe.addPoint(state, [7, 8]);
const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n };
const [lon, lat] = tmx.tmToLonLat(tm, 100, 0);
const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat);
const back = tmx.tmToLonLat(tm, ...moved.points[0]);
writeFileSync(process.argv[2], JSON.stringify({
errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "x".repeat(20), "x".repeat(21)]
.map((s) => pe.pointNameError(s)),
labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)],
parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")],
added: [added.points, added.names, added.network === n],
full: pe.addPoint(full, [1, 1]) === full,
renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names],
moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names },
}));
"""
@pytest.fixture(scope="module")
def got(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp_path = tmp_path_factory.mktemp("pe")
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT),
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler",
"--ignoreConfig"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_name_rule(got: dict) -> None:
ok, bad = got["errors"][:2], got["errors"][2:11]
assert ok == [None, None] # 빈 이름 · 한글 = 됨
assert all(msg and "못 씁니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉
assert got["errors"][11] is None # 20 자
assert "20 자" in got["errors"][12] # 21 자 = 막음
def test_label_blank_falls_back(got: dict) -> None:
assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"]
def test_parse_lonlat(got: dict) -> None:
ok, no_lon, bad_lat, big_lon, big_lat = got["parse"]
assert ok == [128.5, 36.9]
assert no_lon.startswith("경도") and bad_lat.startswith("위도")
assert big_lon.startswith("경도") and big_lat.startswith("위도")
def test_add_and_limit(got: dict) -> None:
points, names, network_same = got["added"]
assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""]
assert network_same # 더해도 노선 그대로
assert got["full"] # 40 개 = 그대로
def test_rename(got: dict) -> None:
assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌
assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤
def test_move_by_lonlat_drags_node(got: dict) -> None:
moved = got["moved"]
assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복
assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이
assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로
assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"]
+55 -3
View File
@@ -1,7 +1,8 @@
"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`.
"""D03 다시 긋기 · 노드 기록(PLAN 599-2 · 599-3 · 요구 463-11 · 12) — TS `logic/D03_PlanAnalysis_Span.ts` · `_Plan.ts`.
두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 ·
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로.
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로 · 노드 기록 = 꼭짓점과 같은 길이로 따라감
(더함 · 끼움 · 뺌 · 지움 · 뒤집기 · 잇기 · 다시 긋기) · 옮긴 노드 = 기록 남고 어긋남.
./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q
"""
@@ -34,7 +35,32 @@ const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]);
const bad = span.replaceSpan(n, 0, 2, 2, []);
const straight = span.replaceSpan(n, 0, 0, 2, []);
writeFileSync(process.argv[2], JSON.stringify({ before: n, done, wide, bad, straight }));
// 599-2 노드 기록
const L = span.nodeRecord("length", 20, 9);
const S = span.nodeRecord("slope", 20, 9);
let r = plan.newLine(plan.emptyNetwork(), [0, 0]);
r = plan.appendVertex(r, 0, [10, 0]);
r = plan.appendVertex(r, 0, [30, 0], L);
r = plan.appendVertex(r, 0, [30, 5], S);
const recorded = r;
const inserted = plan.insertVertex(r, 0, 0, [5, 0]);
const dropped = plan.dropLastVertex(r, 0);
const removed = plan.removeVertex(r, 0, 1);
const reversed = plan.reverseLine(r, 0);
let t = plan.appendVertex(plan.newLine(r, [30, 5]), 1, [40, 5], L);
const joined = plan.attachHead(t, 1);
const moved = plan.moveVertex(r, 0, 2, [33, 0]);
const flat = () => 0;
const off = [1, 2, 3].map((v) => span.nodeOff(moved.lines[0], v, flat));
const respanned = span.replaceSpan(r, 0, 1, 3, [[20, 9]], [L]);
const plain = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
writeFileSync(
process.argv[2],
JSON.stringify({
before: n, done, wide, bad, straight,
recorded, inserted, dropped, removed, reversed, joined, moved, off, respanned, plain,
}),
);
"""
@@ -99,3 +125,29 @@ def test_blocked_and_straight(tmp_path: Path) -> None:
straight = r["straight"]["lines"][0]["vertices"]
assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게
assert r["straight"]["lines"][1]["branch"]["vertex"] == 1
L = {"kind": "length", "length_m": 20, "slope_pct": None}
S = {"kind": "slope", "length_m": None, "slope_pct": 9}
F = {"kind": "free", "length_m": None, "slope_pct": None}
def _nodes(network: dict, i: int = 0) -> list:
line = network["lines"][i]
assert len(line.get("nodes") or line["vertices"]) == len(line["vertices"]) # 서버 400 막음
return line.get("nodes")
def test_node_records_follow_vertices(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
assert _nodes(r["recorded"]) == [None, None, L, S] # 첫 기록 = 앞 노드 기록 없음
assert _nodes(r["inserted"]) == [None, None, None, L, S]
assert _nodes(r["dropped"]) == [None, None, L]
assert _nodes(r["removed"]) == [None, L, S]
assert _nodes(r["reversed"]) == [None, S, L, None] # 토막 끝 노드 몫 = 한 칸 밀림
assert len(r["joined"]["lines"]) == 1 and _nodes(r["joined"]) == [None, None, L, S, L]
assert _nodes(r["moved"]) == [None, None, L, S] # 옮겨도 기록 남음
# 2 = 길이 20 기록 · 지금 23 · 3 = 기울기 9 % 기록 · 평지 0 % → 둘 다 어긋남 · 1 = 기록 없음
assert r["off"] == [False, True, True]
assert _nodes(r["respanned"]) == [None, None, L, F] # 속 점 = 그때 기록 · 뒤 노드 = 자유
assert "nodes" not in r["plain"]["lines"][0] # 기록 없는 선 = 그대로 없음