From a96a60fb86a2d4ced65b8adf3c3e7b656678643c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 14:23:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(524-1):=20B07=20=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=20=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20=EA=B8=B0=EB=B3=B8=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=20=EC=A7=80=ED=98=95=EB=8F=84=20=ED=95=98=EB=82=98=20=C2=B7=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=20=EC=B9=B8=20=EC=88=A8=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EC=B2=99=EB=8F=84=202=EC=97=B4=20=C2=B7?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=20=EA=B7=B8=EB=A3=B9=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EB=8C=80=ED=94=BC=EC=86=8C=20=EC=82=AC?= =?UTF-8?q?=ED=86=A0=EC=9E=A5=20=EC=9E=90=EB=A6=AC=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본 종류 = 지형도 하나(옛 기본 이관 더하기 지움) · 대피소 조각 · 자리 폼 · plan-marks 끝점 지움 - 모달 양식 칸 = 평면도면 숨김 · 종류 척도 영역 2열 · 항목 3열 유지 + 그룹 구분선(PIECE_GROUPS 한 곳) - 낡은 시험 수선: 기본 종류가 하나로 줄어 route · layout · landuse · watershed 기본 전제를 add_kind 로 세움 · 대피소 자리 시험 지움 Co-Authored-By: Claude Sonnet 5.5 --- .../B07_DesignDetail_Api_Fetch.ts | 17 +-- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 42 +----- .../B07_DesignDetail_Engine_Cad_PlanLabels.py | 4 +- .../B07_DesignDetail_Router_PlanKinds.py | 48 +------ .../B07_DesignDetail_Router_Support_Plan.py | 116 +-------------- .../B07_DesignDetail_UI_PlanKinds.ts | 103 +++++-------- .../B07_DesignDetail_UI_PlanManage.ts | 11 +- .../B07_DesignDetail_UI_Style.css | 58 +++----- .../tester/helper_130_b07_plan_manage.cjs | 5 - resources/tester/test_130_b07_plan_manage.py | 6 +- .../tester/test_141_4_b07_plan_sheets.py | 2 +- .../tester/test_512_1_b07_basin_contour.py | 13 +- resources/tester/test_b07_plan_kinds.py | 136 +++++------------- 13 files changed, 126 insertions(+), 435 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index bbce8b861..dd822905f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -273,7 +273,7 @@ export function saveDrawingScales( }); } -/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */ +/** 계획평면도 종류 · 고를 조각 (PLAN 60장). */ export interface PlanKind { id: string; name: string; @@ -286,11 +286,6 @@ export interface PlanKind { contour_interval_m?: number; } export type PlanKindLayout = "plan" | "watershed"; -export interface PlanMark { - chainage_m: number; - refuge: boolean; - spoil: boolean; -} export interface PlanKindsState { pieces: { id: string; name: string }[]; /** 고를 수 있는 평면도 축척 분모(서버 `DRAWING_SCALE_PLAN_CHOICES`) · 기본 · 유역도 고정. */ @@ -301,9 +296,6 @@ export interface PlanKindsState { contour_intervals: number[]; default_contour_interval: number; kinds: PlanKind[]; - marks: PlanMark[]; - /** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */ - station_interval_m?: number | null; } export function fetchPlanKinds(projectId: string): Promise { @@ -352,13 +344,6 @@ export function deletePlanKind(projectId: string, kindId: string): Promise { - return requestJson(`/projects/${projectId}/plan-marks`, { - method: "PUT", - body: JSON.stringify({ marks }), - }); -} - /** 도각 편집 화면이 싣는 도각 한 장 (실치수 1:1, 잠금 없는 도면층). */ export interface FrameTemplateResponse { status: string; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 5994e55d2..09f5de3ad 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -1,8 +1,8 @@ """B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장). -계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다. +계획평면도는 여러 종류(지형도 · 사용자 추가)가 **같은 양식**이다. 정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO · -시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩 +시종점 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음). @@ -83,7 +83,6 @@ PIECES: tuple[tuple[str, str, bool], ...] = ( ("ip_table", "IP 표", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), - ("refuge", "대피소 · 사토장", False), ("drainage", "배수시설 기호", False), # 용지도(93장) — 연속지적도 · 행정구역 ("parcel", "필지 경계", False), @@ -99,33 +98,9 @@ LEGACY_PIECES: dict[str, tuple[str, ...]] = { _OUTLINE = LEGACY_PIECES["slope_outline"] _TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius") -# 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다. +# 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain)와 같아 확정본이 이어진다. DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( - {"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]}, - { - "id": "route", - "name": "노선배치도", - "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints", "ip_table"], - }, - { - "id": "layout", - "name": "대피소", - "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "refuge"], - }, - # 라이다 = 지형과 같은 정보 · 배경만 라이다(등고선 · 계류 대신). - { - "id": "lidar", - "name": "라이다", - "pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"], - }, - # 용지도 = 옛 따로 도면과 같은 정보(등고선 · 노선 · 지적 · 행정 경계). - { - "id": "landuse", - "name": "용지도", - "pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"], - }, - # 배수유역도 = 양식이 다른 종류(1/6,000 한 장 · 정보표) — 조각을 고르지 않는다(109장). - {"id": "watershed", "name": "배수유역도", "pieces": [], "layout": "watershed"}, + {"id": "terrain", "name": "지형도", "pieces": [*_TERRAIN, "station"]}, ) _COLORS = { @@ -140,7 +115,6 @@ _COLORS = { "radius": "#4d7dff", "station": "#ffe066", "endpoints": "#ff9d4d", - "refuge": "#7ddc7d", "drainage": STRUCTURE_COLOR, "parcel": "#8c6b4f", "parcel_info": "#a8703f", # 밝음 · 어두움 바탕 둘 다 읽히는 중간 밤색 @@ -422,14 +396,6 @@ def _piece_entities( ], (cx, cy), ) - elif piece == "refuge": - size = 2.0 # 네모 기호 반크기(종이 mm) - for index, item in enumerate(data.get("items") or []): - x, y, half = item["x"], item["y"], size / mm - corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)] - line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}") - cx, cy = paper((x, y)) - text(f"{index}", item["label"], _LABEL_FONT, _side_spots(cx, cy, size), (cx, cy)) elif piece == "drainage": size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm) for index, item in enumerate(data.get("items") or []): diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py index 53b368cf5..4241cbe17 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py @@ -2,7 +2,7 @@ 조각(`_piece_entities`)은 글을 바로 그리지 않고 후보 자리째 모은다. 여기서 우선순위 차례로 후보 중 첫 빈 자리를 고르고(못 찾으면 첫 후보) 멀리 놓인 글엔 지시선을 단다. -우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소. 서버 단독 계산(CLAUDE.md 5장 ③). +우선순위 = 시종점 > 측점 NO > IP > R > 배수. 서버 단독 계산(CLAUDE.md 5장 ③). 좌표 = 종이 mm. 글 사각 = 글 크기 × (한글 1.0 · 그 밖 0.6) 폭 · 글 크기 높이(세로 가운데 기준 — CAD 가 textBaseline middle 로 그림) · 방향이 있으면 돌린 사각의 바깥 상자. @@ -13,7 +13,7 @@ from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _text_entity, polyline_entity -PRIORITY = ("endpoints", "station", "ip", "radius", "drainage", "refuge") +PRIORITY = ("endpoints", "station", "ip", "radius", "drainage") _GAP_MM = 0.6 # 글 사이 틈(종이 mm) _PUSH = (2.0, 3.0) # 먼 고리 — 후보 거리의 배수 diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index 805e8478b..04e60102a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -1,7 +1,6 @@ -"""B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장). +"""B07 계획평면도 종류 라우터 (PLAN 60장). 종류 = 정보 조각 고르기 목록. 추가 · 업데이트 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다. -대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다. """ import asyncio @@ -13,19 +12,14 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from pydantic import BaseModel, Field -from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES -from B07_DesignDetail.B07_DesignDetail_Router import _confirmed_source from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root -from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_json from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( add_kind, delete_kind, list_kinds, - read_marks, reorder_kinds, update_kind, - write_marks, ) from config.config_system import ( BASIN_CONTOUR_INTERVAL_CHOICES, @@ -54,16 +48,6 @@ class PlanKindsOrderRequest(BaseModel): ids: list[str] = Field(default_factory=list) -class PlanMark(BaseModel): - chainage_m: float = Field(ge=0) - refuge: bool = False - spoil: bool = False - - -class PlanMarksRequest(BaseModel): - marks: list[PlanMark] = Field(default_factory=list) - - def _state(root: Any) -> dict[str, Any]: return { "status": "success", @@ -74,7 +58,6 @@ def _state(root: Any) -> dict[str, Any]: "contour_intervals": list(BASIN_CONTOUR_INTERVAL_CHOICES), "default_contour_interval": BASIN_CONTOUR_INTERVAL_M, "kinds": list_kinds(root), - "marks": read_marks(root), } @@ -82,22 +65,11 @@ def _error(status: int, exc: Exception) -> JSONResponse: return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)}) -async def _station_interval(project_id: UUID) -> float | None: - """종단 측점 간격(m) — 측점 NO + 거리 입력을 누가거리로 바꾸는 기준. 종단이 없으면 None.""" - try: - _route_id, _root, longitudinal_path, _bypassed = await _confirmed_source(project_id) - longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) - except (FileNotFoundError, PermissionError, OSError, ValueError): - return None - return infer_station_interval(longitudinal.get("stations") or []) - - @router.get("/{project_id}/plan-kinds") async def get_plan_kinds(project_id: UUID) -> Any: - """고를 수 있는 조각 · 지금 종류 · 대피소 자리 · 측점 간격.""" + """고를 수 있는 조각 · 지금 종류.""" root = await _project_root(project_id) - state = await asyncio.to_thread(_state, root) - return {**state, "station_interval_m": await _station_interval(project_id)} + return await asyncio.to_thread(_state, root) @router.post("/{project_id}/plan-kinds") @@ -153,7 +125,7 @@ async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest @router.delete("/{project_id}/plan-kinds/{kind_id}") async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any: - """종류 지우기 — 기본 넷도 지울 수 있다.""" + """종류 지우기 — 기본 종류도 지울 수 있다.""" try: root = await _project_root(project_id) await asyncio.to_thread(delete_kind, root, kind_id) @@ -162,15 +134,3 @@ async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any: return _error(400, exc) except FileNotFoundError as exc: return _error(404, exc) - - -@router.put("/{project_id}/plan-marks") -async def put_plan_marks(project_id: UUID, request: PlanMarksRequest) -> Any: - """사용자가 정한 대피소 · 사토장 자리를 통째로 갈아 쓴다.""" - try: - root = await _project_root(project_id) - marks = [mark.model_dump() for mark in request.marks] - await asyncio.to_thread(write_marks, root, marks) - return await asyncio.to_thread(_state, root) - except ValueError as exc: - return _error(400, exc) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index a637630a3..4aef25d20 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -4,10 +4,8 @@ plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout, scale} — 파일 하나씩 (유역도 양식은 + contour_interval_m 등고선 간격 5 · 10 m) plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m) - plan_marks.json 사용자가 정한 대피소 · 사토장 자리 -`plan_kinds/` 가 없으면 기본 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도)를 -쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 +`plan_kinds/` 가 없으면 기본 종류(지형도 하나)를 쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 없음). 처음 고칠 때 기본 종류를 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 종류도 고치고 지울 수 있다. 조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경) @@ -24,7 +22,6 @@ from B05_Profile.B05_Profile_Engine_Sections import ( cross_station, find_cross_file, ) -from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( infer_station_interval, @@ -86,8 +83,6 @@ PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$") _NAME_MAX = 40 WATERSHED_LAYOUT = "watershed" _LAYOUTS = ("plan", WATERSHED_LAYOUT) -_REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"} -_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 기호를 중심선에서 띄우는 거리(좌측 · 1/1,200 실거리) def plan_title(name: str) -> str: @@ -101,8 +96,9 @@ def _kinds_dir(project_root: Path) -> Path: return _design_root(project_root) / "plan_kinds" -# 이관 표시 `_{id}_added` — 있으면 그 기본 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음). -_ADDED_ONCE = ("landuse", "watershed") +def _marks_path(project_root: Path) -> Path: + """옛 대피소 · 사토장 자리 파일 — 읽지 않음 · [초기화]가 지우기만.""" + return _design_root(project_root) / "plan_marks.json" def _scale(layout: str, value: Any) -> int: @@ -132,27 +128,6 @@ def _document(kind: dict[str, Any], order: int) -> dict[str, Any]: } -def _add_defaults_once(folder: Path) -> None: - """기본 종류가 늘기 전에 종류 파일을 내린 프로젝트 — 늘어난 기본 종류를 한 번 더한다.""" - pending = [kind_id for kind_id in _ADDED_ONCE if not (folder / f"_{kind_id}_added").exists()] - if not pending: - return - documents = [] - for path in folder.glob("*.json"): - try: - documents.append(_read_json(path)) - except (OSError, ValueError): - continue - names = {str(document.get("name")) for document in documents} - order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1 - for kind_id in pending: - kind = next(kind for kind in DEFAULT_KINDS if kind["id"] == kind_id) - if not (folder / f"{kind_id}.json").exists() and kind["name"] not in names: - atomic_write_json(folder / f"{kind_id}.json", _document(kind, order)) - order += 1 - (folder / f"_{kind_id}_added").touch() - - def list_kinds(project_root: Path) -> list[dict[str, Any]]: """종류 목록 [{id, name, pieces, layout, scale}] — 차례대로. 파일이 없으면 기본 종류.""" folder = _kinds_dir(project_root) @@ -168,7 +143,6 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: } for kind in DEFAULT_KINDS ] - _add_defaults_once(folder) rows: list[tuple[int, str, dict[str, Any]]] = [] for path in folder.glob("*.json"): if not KIND_ID.fullmatch(path.stem): @@ -206,8 +180,6 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]: if not folder.is_dir(): for order, kind in enumerate(kinds): atomic_write_json(folder / f"{kind['id']}.json", _document(kind, order)) - for kind_id in _ADDED_ONCE: - (folder / f"_{kind_id}_added").touch() return kinds @@ -350,40 +322,6 @@ def delete_kind(project_root: Path, kind_id: str) -> None: path.unlink() -# ── 대피소 · 사토장 자리(사용자 지정) ───────────────── - - -def _marks_path(project_root: Path) -> Path: - return _design_root(project_root) / "plan_marks.json" - - -def read_marks(project_root: Path) -> list[dict[str, Any]]: - path = _marks_path(project_root) - if not path.is_file(): - return [] - try: - marks = _read_json(path).get("marks") - except (OSError, ValueError): - return [] - return [mark for mark in marks or [] if isinstance(mark, dict)] - - -def write_marks(project_root: Path, marks: list[dict[str, Any]]) -> list[dict[str, Any]]: - clean: list[dict[str, Any]] = [] - for mark in marks: - chainage = mark.get("chainage_m") - if not isinstance(chainage, (int, float)) or chainage < 0: - raise ValueError("대피소 · 사토장 자리의 측점(m)이 올바르지 않습니다.") - refuge, spoil = bool(mark.get("refuge")), bool(mark.get("spoil")) - if refuge or spoil: - clean.append( - {"chainage_m": round(float(chainage), 2), "refuge": refuge, "spoil": spoil} - ) - clean.sort(key=lambda mark: mark["chainage_m"]) - atomic_write_json(_marks_path(project_root), {"marks": clean}) - return clean - - # ── 정보 조각 ───────────────────────────────────────── @@ -457,50 +395,6 @@ def slope_records( return records -def _refuge_items( - project_root: Path, - route: list[tuple[float, float, float]], - start_m: float, - end_m: float, - scale: int = DRAWING_SCALE_PLAN, -) -> dict[str, Any]: - """대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능. - - 띄움은 종이 거리가 같게 축척 따라(141-2).""" - offset = _REFUGE_OFFSET_M * scale / DRAWING_SCALE_PLAN - spots: dict[float, set[str]] = {} - _revision, structures = load_structures(str(project_root)) - for structure in structures: - label = _REFUGE_TYPES.get(structure.type_id) - if not label: - continue - chainage = structure.chainage_m - if chainage is None and structure.start_m is not None and structure.end_m is not None: - chainage = (structure.start_m + structure.end_m) / 2.0 - if chainage is not None: - spots.setdefault(round(float(chainage), 1), set()).add(label) - for mark in read_marks(project_root): - key = round(float(mark["chainage_m"]), 1) - if mark.get("refuge"): - spots.setdefault(key, set()).add("대피소") - if mark.get("spoil"): - spots.setdefault(key, set()).add("사토장") - items = [] - for chainage, labels in sorted(spots.items()): - if not (start_m <= chainage <= end_m) or len(route) < 2: - continue - x, y, nx, ny = route_frame(route, chainage) - items.append( - { - "chainage_m": chainage, - "label": " · ".join(name for name in ("대피소", "사토장") if name in labels), - "x": x + nx * offset, - "y": y + ny * offset, - } - ) - return {"items": items} - - def build_pieces( context: Any, longitudinal: dict[str, Any], @@ -582,8 +476,6 @@ def build_pieces( label += f" L={length:.1f}m" items.append({"label": label, "x": x, "y": y, "nx": nx, "ny": ny}) pieces["endpoints"] = {"items": items} - if "refuge" in wanted: - pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m, scale) if "drainage" in wanted: min_x, min_y, max_x, max_y = box pieces["drainage"] = { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 35ce74f8b..5c25cae18 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -1,34 +1,28 @@ /* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts - * 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · + * 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형도 · * 사용자 추가)별 도면 단추 + [평면도 관리] 단추 (PLAN 60 · 67 · 130장). - * 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 폼은 [평면도 관리] 모달(`_UI_PlanManage.ts`)이 씀. + * 종류 추가 · 업데이트 폼은 [평면도 관리] 모달(`_UI_PlanManage.ts`)이 씀. * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. - * 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. + * 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`). * ========================================================================== */ import { createCheckbox } from "@ui/ui_template_checkbox"; import { createPanelCard } from "@ui/ui_template_collapsible"; import { createSelectField, showToast } from "@ui/ui_template_elements"; -import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, deletePlanKind, - savePlanMarks, updatePlanKind, type DesignDrawingItem, type PlanKindLayout, type PlanKindsState, - type PlanMark, } from "./B07_DesignDetail_Api_Fetch"; -/** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */ -const DEFAULT_STATION_INTERVAL_M = 20; - export interface PlanKindsControls { projectId: string; - /** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */ + /** 종류를 고친 뒤 — 목록과 도면을 다시 받는다. */ onChanged: () => void; } @@ -56,6 +50,24 @@ export async function run( } } +/** 항목 체크박스 그룹 — 그룹 사이는 구분선만 · 그룹 추가 · 바꿈은 이 목록 한 곳. + * 어느 그룹에도 없는 항목은 맨 끝 그룹에 붙는다. */ +const PIECE_GROUPS: string[][] = [ + ["lidar", "contour", "stream", "parcel", "parcel_info", "emd", "sgg"], // 수치지형 + ["basin", "basin_no", "drainage"], // 배수유역 + [ + "centerline", + "road_edge", + "fill_outline", + "cut_outline", + "slope_ticks", + "ip", + "radius", + "ip_table", + ], // 노선 + ["station", "endpoints"], // 측점 관련 +]; + /** 양식 — 평면도(조각 고름 · 축척 고름 · 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */ const LAYOUTS: { value: PlanKindLayout; text: string }[] = [ { value: "plan", text: "평면도" }, @@ -114,7 +126,16 @@ export function kindForm( name.maxLength = 40; const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id })); const pieceList = el("div", "b07-plan-tools__pieces"); - pieceList.append(...boxes.map((box) => box.root)); + const grouped = PIECE_GROUPS.map((ids) => boxes.filter(({ input }) => ids.includes(input.value))); + grouped[grouped.length - 1].push( + ...boxes.filter((box) => !PIECE_GROUPS.some((ids) => ids.includes(box.input.value))), + ); + grouped + .filter((group) => group.length) + .forEach((group, index) => { + if (index) pieceList.append(el("hr", "b07-plan-tools__divider")); + pieceList.append(...group.map((box) => box.root)); + }); const action = el("button", "b07-plan-tools__button"); action.type = "button"; const remove = el("button", "b07-plan-tools__button", "지우기"); @@ -147,6 +168,7 @@ export function kindForm( // 양식은 새로 만들 때만 고른다 — 있는 종류는 그 양식을 보이기만. layout.select.value = kind?.layout ?? "plan"; layout.select.disabled = Boolean(kind); + layout.root.hidden = kind?.layout === "plan"; // 평면도는 늘 같은 값 — 새 종류 · 유역도만 보임 action.textContent = kind ? "업데이트" : "추가"; remove.hidden = !kind; showPieces(); @@ -177,7 +199,9 @@ export function kindForm( }); const actions = el("div", "b07-plan-tools__actions"); actions.append(action, remove); - form.append(pick.root, layout.root, scale.root, contour.root, name, pieceList, actions); + const fields = el("div", "b07-plan-tools__fields"); + fields.append(pick.root, layout.root, scale.root, contour.root); + form.append(fields, name, pieceList, actions); return { root: form, pick: (kindId) => { @@ -187,61 +211,6 @@ export function kindForm( }; } -export function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { - const form = el("div", "b07-plan-tools__block"); - form.append(el("strong", "", "대피소 · 사토장 자리")); - form.append( - el( - "p", - "b07-plan-tools__hint", - "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.", - ), - ); - const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M; - const rows = el("div", "b07-plan-tools__marks"); - const readers = new Map PlanMark | null>(); - const addRow = (mark: PlanMark): void => { - const row = el("div", "b07-plan-tools__mark"); - const station = stationFields("측점", () => interval); - station.write(mark.chainage_m, interval); - const refuge = createCheckbox("대피소", { checked: mark.refuge }); - const spoil = createCheckbox("사토장", { checked: mark.spoil }); - const remove = el("button", "b07-plan-tools__remove", "×"); - remove.type = "button"; - remove.title = "이 자리 빼기"; - remove.addEventListener("click", () => { - readers.delete(row); - row.remove(); - }); - row.append(station.wrap, refuge.root, spoil.root, remove); - readers.set(row, () => { - const chainage = station.read(interval, true); - return chainage === null - ? null - : { chainage_m: chainage, refuge: refuge.input.checked, spoil: spoil.input.checked }; - }); - rows.append(row); - }; - state.marks.forEach(addRow); - const more = el("button", "b07-plan-tools__button", "자리 추가"); - more.type = "button"; - more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false })); - const save = el("button", "b07-plan-tools__button", "업데이트"); - save.type = "button"; - save.addEventListener("click", () => { - const marks = [...readers.values()].map((read) => read()); - if (marks.some((mark) => mark === null)) { - showToast("측점 NO + 거리를 바르게 적어 주세요.", "error"); - return; - } - void run(() => savePlanMarks(controls.projectId, marks as PlanMark[]), controls); - }); - const actions = el("div", "b07-plan-tools__actions"); - actions.append(more, save); - form.append(rows, actions); - return form; -} - /** 장을 종류별로 묶음 — 여러 장이면 「이름 N장」 · 한 장이면 이름(좌측 목록 · 관리 모달 공용). */ export function groupPlanSheets( items: DesignDrawingItem[], diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts index 3b552d98d..ffa86eca9 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts @@ -1,9 +1,9 @@ /* ============================================================================= * B07_DesignDetail_UI_PlanManage.ts - * [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음. + * [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 장 보기를 한 창에 모음. * * 왼쪽 = [종류 ▼] 상세 폼(추가 · 업데이트 · 지우기) · 평면도 장(종류마다 한 줄 [≡] 이름 [1][2] · - * 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 저장 140-2) · 대피소 · 사토장 자리 + * 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 저장 140-2) * 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기] * 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고, * 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음. @@ -25,7 +25,6 @@ import { openDrawing, syncDrawings } from "./B07_DesignDetail_Prefetch"; import { groupPlanSheets, kindForm, - marksForm, run, type KindForm, type PlanKindsControls, @@ -33,7 +32,7 @@ import { export interface PlanManageControls { projectId: string; - /** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */ + /** 모달을 닫음 — changed = 종류 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */ onClosed: (changed: boolean, openId?: string) => void; /** 머리 단추 — ⚙ 로 열 때 [원본에서 다시 그리기](280-5). */ actions?: HTMLElement[]; @@ -80,7 +79,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo const view = el("div", "b07-plan-manage__view"); view.append(head, preview.element); - // 종류 · 자리 폼은 좌측에 있던 것 그대로 — 고친 뒤 모달 안만 다시 세움(화면은 닫을 때). + // 종류 폼은 좌측에 있던 것 그대로 — 고친 뒤 모달 안만 다시 세움(화면은 닫을 때). const edits: PlanKindsControls = { projectId, onChanged: () => { @@ -233,7 +232,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo if (first) void show(first.drawing, first.label); else markActive(); }); - side.replaceChildren(form.root, sheetBlock(state), marksForm(state, edits)); + side.replaceChildren(form.root, sheetBlock(state)); if (keep) void show(keep.drawing, keep.label); else { picked = undefined; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 34b77dd09..5479a477f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -282,7 +282,6 @@ } .b07-plan-kind__head, -.b07-plan-tools__mark, .b07-plan-tools__actions { display: flex; align-items: center; @@ -406,6 +405,24 @@ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); } +.b07-plan-tools__fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-4); +} + +.b07-plan-tools__fields > [hidden] { + display: none; +} + +.b07-plan-tools__divider { + grid-column: 1 / -1; + width: 100%; + margin: var(--spacing-4) 0; + border: 0; + border-top: 1px solid var(--color-border); +} + .b07-plan-tools__pieces[hidden] { display: none; } @@ -414,40 +431,7 @@ min-width: 0; } -.b07-plan-tools__hint { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -/* 측점 NO + 거리 두 칸(B05 stationFields) — 한 줄: [측점][NO][+m] */ -.b07-plan-tools__mark .b05-route__field { - display: flex; - flex: 1; - align-items: center; - gap: var(--spacing-4); - min-width: 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.b07-plan-tools__mark .b05-structure__station-pair { - display: flex; - gap: var(--spacing-4); - min-width: 0; -} - -.b07-plan-tools__mark .b05-structure__station-pair input { - width: 52px; - min-width: 0; -} - -.b07-plan-tools__mark input.is-invalid { - border-color: var(--color-danger); -} - -.b07-plan-tools__button, -.b07-plan-tools__remove { +.b07-plan-tools__button { padding: var(--spacing-4) var(--spacing-8); border: 1px solid var(--color-border); border-radius: var(--radius-buttons); @@ -456,10 +440,6 @@ cursor: pointer; } -.b07-plan-tools__remove { - padding: 0 var(--spacing-8); -} - /* 목록에서 연 도면 탭 줄 + CAD(82-4) */ .b07-cad-stack { display: flex; diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 7129fb2e8..38666947f 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -144,9 +144,6 @@ const STUBS = { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), }, - "../B05_Profile/B05_Profile_UI_Structures_Fields": { - stationFields: () => ({ wrap: new FakeNode("div"), write: () => {}, read: () => 0 }), - }, }; // ── 서버 길 가짜 — 종류 파일 · 장 목록이 메모리에 ─────────────────────────────── @@ -165,8 +162,6 @@ const state = { { id: "k_terrain", name: "지형", pieces: ["terrain"], layout: "plan", scale: 1200 }, { id: "k_route", name: "노선배치도", pieces: ["route"], layout: "plan", scale: 1200 }, ], - marks: [], - station_interval_m: 20, }; let confirmed = new Set(["plan_k_route"]); /** 종류 하나 = 장 하나 · 「지형」만 두 장. */ diff --git a/resources/tester/test_130_b07_plan_manage.py b/resources/tester/test_130_b07_plan_manage.py index e635d36b7..7e88fd065 100644 --- a/resources/tester/test_130_b07_plan_manage.py +++ b/resources/tester/test_130_b07_plan_manage.py @@ -2,8 +2,8 @@ """130-1 B07 [평면도 관리] 모달. 못박는 것 - - 좌측 「평면도」 = 장 단추 + [평면도 관리] 하나 · 종류 폼 · 대피소 칸 · × 없음. - - 단추 → 공용 모달(큰 창) · 안에 [종류 ▼] 폼 · 평면도 장 블록(종류마다 한 줄 · 작은 장 단추) · 대피소 · 사토장 자리 · + - 좌측 「평면도」 = 장 단추 + [평면도 관리] 하나 · 종류 폼 · × 없음. + - 단추 → 공용 모달(큰 창) · 안에 [종류 ▼] 폼 · 평면도 장 블록(종류마다 한 줄 · 작은 장 단추) · 첫 장 미리보기(보기 전용). - 종류 추가 · 업데이트(축척 포함) · 지우기(폼 안) = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음. - 장 단추 → 드롭다운 따라감 · 드롭다운 → 그 종류 첫 장 · 줄 끌어 놓기 = 종류 순서(놓는 즉시 · 140-2). @@ -52,7 +52,7 @@ def test_단추를_누르면_모달(out): assert opened["dialogClass"] == "b07-plan-manage" assert opened["kinds"] == ["k_terrain", "k_route"] assert opened["sheets"] == ["plan_k_terrain", "plan_k_terrain_2", "plan_k_route"] - assert opened["hasKindForm"] and opened["hasMarks"] + assert opened["hasKindForm"] and not opened["hasMarks"] # 524 대피소 · 사토장 자리 지움 def test_첫_장_미리보기는_보기_전용(out): diff --git a/resources/tester/test_141_4_b07_plan_sheets.py b/resources/tester/test_141_4_b07_plan_sheets.py index d9b87dbe9..43725a34f 100644 --- a/resources/tester/test_141_4_b07_plan_sheets.py +++ b/resources/tester/test_141_4_b07_plan_sheets.py @@ -80,4 +80,4 @@ def test_목록_도면명_n_N장(tmp_path: Path): items = [ i for i in plan_kind_items(tmp_path, {"stations": stations}) if i["plan_kind"] == "terrain" ] - assert [item["label"] for item in items] == [f"평면도(지형) {n}/3장" for n in (1, 2, 3)] + assert [item["label"] for item in items] == [f"평면도(지형도) {n}/3장" for n in (1, 2, 3)] diff --git a/resources/tester/test_512_1_b07_basin_contour.py b/resources/tester/test_512_1_b07_basin_contour.py index 964981e51..b5dce6eda 100644 --- a/resources/tester/test_512_1_b07_basin_contour.py +++ b/resources/tester/test_512_1_b07_basin_contour.py @@ -23,6 +23,7 @@ sys.path.insert(0, str(ROOT)) from B07_DesignDetail import B07_DesignDetail_Router_Support_Fingerprint as fp # noqa: E402 from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _metric_lines # noqa: E402 from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( # noqa: E402 + _kinds_dir, list_kinds, update_kind, ) @@ -46,6 +47,14 @@ def test_간격_배수_높이만(tmp_path): def _watershed(root: Path) -> dict: + folder = _kinds_dir(root) + if not folder.is_dir(): # 기본 종류는 지형도뿐(524-1) — 유역도 종류를 파일로 둠 + folder.mkdir(parents=True) + for order, (name, layout) in enumerate((("지형도", "plan"), ("배수유역도", "watershed"))): + document = {"name": name, "pieces": ["contour"], "order": order, "layout": layout} + (folder / f"{'terrain' if layout == 'plan' else 'watershed'}.json").write_text( + json.dumps(document), "utf-8" + ) return next(kind for kind in list_kinds(root) if kind["layout"] == "watershed") @@ -88,9 +97,9 @@ def test_지문_유역도만(tmp_path, monkeypatch): def changed(before: dict, after: dict) -> set: return {key for key in before if before[key] != after[key]} - first = prints() kind = _watershed(root) - update_kind(root, kind["id"], kind["name"], [], None, 5) # 기본 종류를 파일로 내림 · 같은 값 + first = prints() + update_kind(root, kind["id"], kind["name"], [], None, 5) # 같은 값 assert changed(first, prints()) == set() update_kind(root, kind["id"], kind["name"], [], None, 10) ten = prints() diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index 9829d848e..0ad38dde0 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -1,14 +1,14 @@ """B07 계획평면도 종류 · 정보 조각 (PLAN 60장). ① 측점 둘에서 성토사면 외곽선 폭 = 횡단 설계 값(노견 끝 · 설계선이 지반에 닿는 자리) -② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음 -③ 대피소 · 사토장 자리 — 한 자리에 둘 다 +② 종류 더하기 · 지우기(기본 종류 포함) · 다시 읽어 같음 · 기본 = 지형도 하나 +③ (없앰 — 대피소 · 사토장 자리는 524-1 에서 지움) ④ 고른 조각만 도면 층으로 얹힘 -⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · - 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 +⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 처음 체크 = 옛 그림 · + 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 포함) · 확정본 풀림 · 배수시설 기호 ⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층 -⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층 -⑧ 배수유역도 종류(109장) — 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 · +⑦ 용지도 조각(93장) — 용지 조각 층 +⑧ 배수유역도 종류(109장) — 새 종류로 더함 · 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 · 용지도 상자 안 선 0 ⑨ 종류 차례(140-2) — 끌어 놓은 차례 그대로 · 다시 읽어 같음 · 앞선 종류가 바뀐 종류만 확정 풂 · 목록 어긋나면 거절 """ @@ -38,16 +38,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( _kinds_dir, - _refuge_items, add_kind, delete_kind, list_kinds, plan_kind_items, - read_marks, reorder_kinds, slope_records, update_kind, - write_marks, ) @@ -139,54 +136,23 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No def test_kinds_add_delete_persist(tmp_path: Path) -> None: root = tmp_path / "project" - assert [k["name"] for k in list_kinds(root)] == [ - "지형", - "노선배치도", - "대피소", - "라이다", - "용지도", - "배수유역도", - ] + assert [k["name"] for k in list_kinds(root)] == ["지형도"] # 기본 = 하나 + assert [k["id"] for k in DEFAULT_KINDS] == ["terrain"] + assert "refuge" not in DEFAULT_KINDS[0]["pieces"] added = add_kind(root, "내 평면도", ["centerline", "station", "nope"]) assert added["pieces"] == ["centerline", "station"] with pytest.raises(ValueError): add_kind(root, "내 평면도", ["centerline"]) # 같은 이름 with pytest.raises(ValueError): add_kind(root, "빈 것", []) - delete_kind(root, "terrain") # 기본 넷도 지움 + delete_kind(root, "terrain") # 기본 종류도 지움 again = list_kinds(root) # 다시 읽어 같음 - assert [k["name"] for k in again] == [ - "노선배치도", - "대피소", - "라이다", - "용지도", - "배수유역도", - "내 평면도", - ] + assert [k["name"] for k in again] == ["내 평면도"] assert again[-1]["pieces"] == ["centerline", "station"] with pytest.raises(FileNotFoundError): delete_kind(root, "terrain") - for kind in list(again): - delete_kind(root, kind["id"]) - assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음) - assert {k["id"] for k in DEFAULT_KINDS} == { - "terrain", - "route", - "layout", - "lidar", - "landuse", - "watershed", - } - - -def test_refuge_marks_same_spot(tmp_path: Path) -> None: - root = tmp_path / "project" - write_marks(root, [{"chainage_m": 20, "refuge": True, "spoil": True}, {"chainage_m": 5}]) - assert read_marks(root) == [{"chainage_m": 20.0, "refuge": True, "spoil": True}] - route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)] - items = _refuge_items(root, route, 0.0, 40.0)["items"] - assert [item["label"] for item in items] == ["대피소 · 사토장"] - assert items[0]["y"] == pytest.approx(20.0) + delete_kind(root, again[0]["id"]) + assert list_kinds(root) == [] # 다 지우면 비어 있음(기본이 되살아나지 않음) def test_drawing_has_only_chosen_pieces() -> None: @@ -223,23 +189,23 @@ def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None: outline = {"road_edge", "fill_outline", "cut_outline"} # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로) for kind in DEFAULT_KINDS: - if kind["id"] not in ("landuse", "watershed"): - assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] + assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] folder = _kinds_dir(root) folder.mkdir(parents=True) (folder / "old.json").write_text( json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8" ) - kind = next(k for k in list_kinds(root) if k["id"] == "old") # 용지도는 이관으로 더해짐 + kind = next(k for k in list_kinds(root) if k["id"] == "old") assert set(kind["pieces"]) == outline | {"station"} def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: root = tmp_path / "project" + route = add_kind(root, "노선배치도", ["centerline"])["id"] manifest = _read_manifest(root) manifest["drawings"] = { "plan_terrain": {"confirmed": True}, - "plan_route": {"confirmed": True}, + f"plan_{route}": {"confirmed": True}, } _write_manifest(root, manifest) update_kind(root, "terrain", "지형2", ["contour", "centerline"]) # 기본 종류 고침 @@ -254,7 +220,7 @@ def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로 drawings = _read_manifest(root)["drawings"] assert drawings["plan_terrain"]["confirmed"] is False - assert drawings["plan_route"]["confirmed"] is True + assert drawings[f"plan_{route}"]["confirmed"] is True with pytest.raises(ValueError): update_kind(root, "terrain", "노선배치도", ["contour"]) # 다른 종류와 같은 이름 with pytest.raises(ValueError): @@ -320,7 +286,6 @@ def test_ip_table_drawing_clips_lines_under_table(tmp_path: Path) -> None: assert ip["cells"][2][2]["text"] == "100.00000" # Y = 동(x) assert ip["cells"][3][1]["text"] == "90-00-00.00" assert {layer["id"] for layer in drawing["layers"]} >= {layer_id("ip_table")} - assert "ip_table" in next(k for k in DEFAULT_KINDS if k["id"] == "route")["pieces"] # DXF — 표 → 선 + 글(IP 묶음: 바깥 4 · 세로 10 · 가로 20 · 글 17) raw, skipped = drawing_to_dxf({"entities": [mine[1]], "layers": drawing["layers"]}) (tmp_path / "ip.dxf").write_bytes(raw) @@ -329,22 +294,6 @@ def test_ip_table_drawing_clips_lines_under_table(tmp_path: Path) -> None: assert {"90-00-00.00", "15.00000"} <= {t.dxf.text for t in space.query("TEXT")} -def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None: - root = tmp_path / "project" - folder = _kinds_dir(root) - folder.mkdir(parents=True) - for order, kind in enumerate(DEFAULT_KINDS[:4]): # 93장 앞에 내려 둔 기본 넷 - (folder / f"{kind['id']}.json").write_text( - json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8" - ) - kinds = list_kinds(root) - assert [k["id"] for k in kinds][-2:] == ["landuse", "watershed"] # 늘어난 기본 종류 둘 - assert kinds[-2]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"] - assert len(list_kinds(root)) == 6 # 두 번 읽어도 한 번 - delete_kind(root, "landuse") - assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남 - - def test_landuse_pieces_become_layers() -> None: pieces = { "parcel": {"lines": [[[0.0, 0.0], [10.0, 0.0]]]}, @@ -370,11 +319,11 @@ def test_landuse_pieces_become_layers() -> None: def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None: - """배수유역도 = 기본 종류 · 양식 watershed · 조각 없음 · 장이 여럿이어도 목록은 한 장.""" + """배수유역도 = 새 종류 · 양식 watershed · 조각 없음 · 장이 여럿이어도 목록은 한 장.""" root = tmp_path / "project" - watershed = next(k for k in list_kinds(root) if k["id"] == "watershed") - assert watershed == { - "id": "watershed", + added = add_kind(root, "배수유역도", [], "watershed") + assert next(k for k in list_kinds(root) if k["id"] == added["id"]) == { + "id": added["id"], "name": "배수유역도", "pieces": [], "layout": "watershed", @@ -387,13 +336,13 @@ def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None: ] items = plan_kind_items(root, {"stations": stations}) terrain = [item for item in items if item["plan_kind"] == "terrain"] - mine = [item for item in items if item["plan_kind"] == "watershed"] + mine = [item for item in items if item["plan_kind"] == added["id"]] assert len(terrain) > 1 assert mine == [ { - "id": "plan_watershed", + "id": f"plan_{added['id']}", "label": "배수유역도", - "plan_kind": "watershed", + "plan_kind": added["id"], "plan_kind_name": "배수유역도", } ] @@ -402,9 +351,10 @@ def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None: def test_watershed_kind_delete_and_add_again(tmp_path: Path) -> None: """× 로 지우면 안 살아남 · 「새 종류」 양식 유역도로 다시 만듦 · 업데이트는 양식 그대로.""" root = tmp_path / "project" - delete_kind(root, "watershed") - assert "watershed" not in [k["id"] for k in list_kinds(root)] - assert "watershed" not in [k["id"] for k in list_kinds(root)] # 다시 읽어도 안 살아남 + first = add_kind(root, "배수유역도", [], "watershed") + delete_kind(root, first["id"]) + assert first["id"] not in [k["id"] for k in list_kinds(root)] + assert first["id"] not in [k["id"] for k in list_kinds(root)] # 다시 읽어도 안 살아남 added = add_kind(root, "배수유역도", ["contour"], "watershed") # 조각은 버림 assert added["layout"] == "watershed" and added["pieces"] == [] again = next(k for k in list_kinds(root) if k["id"] == added["id"]) @@ -418,21 +368,6 @@ def test_watershed_kind_delete_and_add_again(tmp_path: Path) -> None: add_kind(root, "이상한 양식", ["contour"], "nope") -def test_watershed_kind_added_once_to_folder_with_landuse(tmp_path: Path) -> None: - """용지도 표시가 있는 옛 폴더 — 배수유역도만 한 번 더해짐 · 지우면 안 살아남.""" - root = tmp_path / "project" - folder = _kinds_dir(root) - folder.mkdir(parents=True) - for order, kind in enumerate(DEFAULT_KINDS[:5]): - (folder / f"{kind['id']}.json").write_text( - json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8" - ) - (folder / "_landuse_added").touch() - assert [k["id"] for k in list_kinds(root)][-1] == "watershed" - delete_kind(root, "watershed") - assert [k["id"] for k in list_kinds(root)][-1] == "landuse" - - def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None: """한 줄 셋까지 들어가는 폭 · 묶음 넷 — 첫 줄(1 · 2 · 3) 맨 위 왼 → 오 · 넷째는 아랫줄 왼쪽.""" blocks = [{"name": str(i), "ia": 1.0} for i in range(1, 5)] @@ -449,22 +384,23 @@ def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None: def test_reorder_kinds_order_and_release_renumbered(tmp_path: Path) -> None: root = tmp_path / "project" + second, third = (add_kind(root, name, ["centerline"])["id"] for name in ("둘째", "셋째")) manifest = _read_manifest(root) manifest["drawings"] = { "plan_terrain": {"confirmed": True}, - "plan_route": {"confirmed": True}, - "plan_layout": {"confirmed": True}, + f"plan_{second}": {"confirmed": True}, + f"plan_{third}": {"confirmed": True}, } _write_manifest(root, manifest) before = [k["id"] for k in list_kinds(root)] - assert before[:3] == ["terrain", "route", "layout"] - after = ["route", "terrain", *before[2:]] # 앞 둘만 바꿈 + assert before == ["terrain", second, third] + after = [second, "terrain", third] # 앞 둘만 바꿈 reorder_kinds(root, after) assert [k["id"] for k in list_kinds(root)] == after # 다시 읽어 같음 drawings = _read_manifest(root)["drawings"] assert drawings["plan_terrain"]["confirmed"] is False # 번호 바뀜 - assert drawings["plan_route"]["confirmed"] is False - assert drawings["plan_layout"]["confirmed"] is True # 앞선 종류 묶음 그대로 = 번호 그대로 + assert drawings[f"plan_{second}"]["confirmed"] is False + assert drawings[f"plan_{third}"]["confirmed"] is True # 앞선 종류 묶음 그대로 = 번호 그대로 with pytest.raises(ValueError): reorder_kinds(root, after[:-1]) # 빠진 종류 with pytest.raises(ValueError):