From 0c463bcc6470fab8bdf3af193d9f626c8c91a36e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:45:26 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EC=A7=80=ED=98=95=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=E2=80=94=20=EC=A7=80=EC=A0=81=20=EB=BA=8C?= =?UTF-8?q?=20=C2=B7=20=EC=8B=A4=ED=8C=A8=20=EC=A1=B0=EA=B0=81=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EC=9C=84=EC=B9=98=20=EB=B0=94=EA=BF=88=20=EB=A9=80?= =?UTF-8?q?=EB=8B=A4=20=C2=B7=20=EB=9D=A0=20=C2=B7=20reset=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B8=20=EC=83=81=ED=95=9C=20=C2=B7=20=EB=81=8A=EA=B9=80=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A8=EA=B3=84=20=EA=B8=80=20=C2=B7=20=EC=9E=84?= =?UTF-8?q?=EB=8F=84=EB=A7=9D=20=C2=B15=20km=20(PLAN=20595=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지적도를 지형 일에서 뺌(겹 GET 이 받음) · 지적 실패 조각만 다시(열쇠 failed_tiles) - 받기 몸 center · reset — 5 km 넘음 far · 다른 TM 띠 band 물음 · reset = 옛 범위 · 병합 도엽 · 위성 버림 - 받을 칸 500 만 넘음 413 · extent max_cells · lon/lat - 진행 기록 파일 — 서버 다시 켜짐 = interrupted(percent < 100 · message 없음) · 받은 적 없음 percent 100 - 진행 단계 글 n/5 · 받은 범위에 epsg · center 기록 - 임도망 자르기 = 기준점 ±5 km ∪ 받은 지형 · 지형 전에도 올림 · bounds · distance_m · 밖뿐 = 거리 글 - 시험 530_1 · 530_2 · 569_1 · 584_3 덧 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 35 +++- .../D03_PlanAnalysis_ForestRoad.py | 90 ++++++--- D03_PlanAnalysis/D03_PlanAnalysis_Layers.py | 28 +-- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 186 +++++++++++++++--- D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py | 102 ++++++---- resources/tester/test_530_1_plan_terrain.py | 39 +++- resources/tester/test_530_2_plan_router.py | 163 ++++++++++++++- resources/tester/test_569_1_plan_layers.py | 26 ++- .../test_584_3_plan_forest_road_zones.py | 27 ++- 9 files changed, 578 insertions(+), 118 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 1e7cb00e4..acaf8b07a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -27,11 +27,26 @@ 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; + interrupted?: boolean; +} + +/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */ +export type TerrainStart = + | ({ status: "accepted" } & PlanJob) + | { status: "far"; distance_m: number; message: string } + | { status: "band"; epsg: number; center_epsg: number; message: string }; + +/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */ +export interface TerrainAim { + center?: { lon: number; lat: number } | null; + reset?: boolean; } /** 받기 전 범위(561-2) — 상자(작업 좌표계 m · 경위도 네 귀) · 받은 범위 · 도엽 · 칸 수 어림. */ @@ -45,6 +60,8 @@ export interface TerrainExtent { covered: boolean; sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>; cells: number; + /** 받기 칸 상한(넘으면 받기 413). */ + max_cells: number; } /** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */ @@ -89,26 +106,33 @@ async function request(path: string, init: RequestInit = {}): Promise { const base = (projectId: string): string => `/projects/${projectId}/plan`; -/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */ +/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점) · `center` = 위치 칸 기준점(둘레 ±1 km). */ export const fetchTerrainExtent = ( projectId: string, marginM: number | null, extra: Vertex[], + center: { lon: number; lat: number } | null = null, ): Promise => { const query = new URLSearchParams(); if (marginM !== null) query.set("margin_m", String(marginM)); if (extra.length) query.set("extra", JSON.stringify(extra)); + if (center) { + query.set("lon", String(center.lon)); + query.set("lat", String(center.lat)); + } return request(`${base(projectId)}/terrain/extent?${query}`); }; +/** 받기 — 칸 상한 넘음 = 오류(413 글). */ export const startTerrain = ( projectId: string, marginM: number | null, extra: Vertex[], -): Promise => + aim: TerrainAim = {}, +): Promise => request(`${base(projectId)}/terrain`, { method: "POST", - body: JSON.stringify({ margin_m: marginM, extra }), + body: JSON.stringify({ margin_m: marginM, extra, ...aim }), }); export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> => @@ -206,12 +230,15 @@ export const fetchPlanZeroOrder = (projectId: string): Promise => 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; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py index 052a24828..d18a2b9bd 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py @@ -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) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 534b74e5e..1d365152e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -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)} # ── 급경사 ────────────────────────────────────────────── diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 6059b375e..3b430b71b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -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} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index 866937b52..6827181ef 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -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, } diff --git a/resources/tester/test_530_1_plan_terrain.py b/resources/tester/test_530_1_plan_terrain.py index 082c77c00..74af4f6a1 100644 --- a/resources/tester/test_530_1_plan_terrain.py +++ b/resources/tester/test_530_1_plan_terrain.py @@ -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) diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 483c6932e..10bfe6970 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -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} diff --git a/resources/tester/test_569_1_plan_layers.py b/resources/tester/test_569_1_plan_layers.py index 56fdf0019..60c745a83 100644 --- a/resources/tester/test_569_1_plan_layers.py +++ b/resources/tester/test_569_1_plan_layers.py @@ -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 # ── 급경사 ── diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 87ad0b83e..f5e43748c 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -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) From 0bf234e2b5fa8badbc9bce92442aeeb4000016ae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:46:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):?= =?UTF-8?q?=20=EC=A7=80=EC=A0=90=20=EC=B9=B8=20[=EC=B6=94=EA=B0=80]=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B2=BD=EC=9C=84?= =?UTF-8?q?=EB=8F=84=20=EC=86=90=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EC=B9=B8?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EA=B8=80=20=EA=B1=B7=EC=9D=8C=20=C2=B7?= =?UTF-8?q?=20=EC=84=A0=EB=A7=88=EB=8B=A4=20=EC=B0=A8=EB=A1=80=20=EC=83=89?= =?UTF-8?q?=20(PLAN=20597-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../D03_PlanAnalysis_UI_DrawPaint.ts | 9 +- .../D03_PlanAnalysis_UI_Points.ts | 173 ++++++++++++++++-- .../D03_PlanAnalysis_UI_Style.css | 18 ++ .../logic/D03_PlanAnalysis_PointEdit.ts | 76 ++++++++ resources/tester/test_597_point_edit.py | 111 +++++++++++ 5 files changed, 363 insertions(+), 24 deletions(-) create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts create mode 100644 resources/tester/test_597_point_edit.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 0e81c63f5..4aa572b55 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -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,7 @@ import { type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit"; import { planColors, PLAN_STATION_INTERVAL_M, @@ -126,7 +127,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; context.beginPath(); line.vertices.forEach((v, i) => { @@ -163,7 +164,7 @@ export function paintNetwork( } /** 지점 핀 겹(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) => { @@ -188,7 +189,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"); }); }, }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index 7565f8f35..ba4044b6b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -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, 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 { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 14f869430..c97b85f4c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -195,3 +195,21 @@ .d03-plan__piece-year { 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; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts new file mode 100644 index 000000000..06008fd45 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts @@ -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), + }; +} diff --git a/resources/tester/test_597_point_edit.py b/resources/tester/test_597_point_edit.py new file mode 100644 index 000000000..f8f25848f --- /dev/null +++ b/resources/tester/test_597_point_edit.py @@ -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', "ab", "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"] == ["", "가"] From 9febb63e131c077af274de8faba12cd05b63f15a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:49:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=85=B8=EB=93=9C=EB=A7=88=EB=8B=A4=20=EA=B8=8B?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=A9=EC=8B=9D=20=C2=B7=20=EA=B0=92=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=E2=80=94=20=EA=BC=AD=EC=A7=93=EC=A0=90=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EC=9B=80=EC=A7=81=EC=9E=84=20=C2=B7=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B4=20=EB=85=B8=EB=93=9C=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B0=EA=B3=A0=20=EA=B2=BD=EA=B3=A0=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20(PLAN=20599-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463-1?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 점 찍을 때 그때 방식 · 값을 nodes 칸에(594-1 꼴 · 붙은 점 = 자유 · 처음 기록 = 앞 노드 기록 없음) - 끼움 · 더함 · 뺌 · 지움 · 뒤집기(토막 끝 노드 몫 한 칸 밀림) · 잇기 · 다시 긋기(뒤 노드 = 자유)가 기록 길이를 꼭짓점과 맞춤 — [저장] 400 막음 - nodeRecord · nodeOff(길이 1 cm · 기울기 0.1 % 넘게 어긋남) — Span.ts(Plan 700 줄 넘지 않게) · 598 노드 판이 씀 - 시험 599_3 에 기록 따라감 · 어긋남 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 35 ++++++++--- .../D03_PlanAnalysis_UI_DrawPaint.ts | 14 ++++- .../logic/D03_PlanAnalysis_Plan.ts | 52 +++++++++++++---- .../logic/D03_PlanAnalysis_Span.ts | 48 ++++++++++++--- resources/tester/test_599_3_plan_span.py | 58 ++++++++++++++++++- 5 files changed, 175 insertions(+), 32 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 66db04074..7d18054d1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -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; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 5332eb23b..2e3a0a29a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -18,6 +18,7 @@ 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 { planColors, PLAN_STATION_INTERVAL_M, @@ -159,15 +160,22 @@ 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(); } diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index a56e04d06..07266668f 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -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) => Array, +): 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 => + 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 } } diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts index 06b7fd896..ad88e9d36 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts @@ -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, + records: ReadonlyArray = [], ): 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 diff --git a/resources/tester/test_599_3_plan_span.py b/resources/tester/test_599_3_plan_span.py index 2a7a3a83c..1138265e8 100644 --- a/resources/tester/test_599_3_plan_span.py +++ b/resources/tester/test_599_3_plan_span.py @@ -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] # 기록 없는 선 = 그대로 없음