From 0ca0583995b82a3ea9f4cf1eb2fd11bbe55c12c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 16:16:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=EB=8F=84=20=EC=A2=85=EB=A5=98=ED=99=94=20=C2=B7=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=EB=8F=84=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EA=B3=B5=EC=9A=A9=20=EA=BC=B4=20=C2=B7=20IP=20?= =?UTF-8?q?=ED=91=9C=20=EC=B0=A8=EB=A1=80=20=C2=B7=20=EC=9A=A9=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EB=9D=BC=EB=B2=A8=202=EB=B0=B0=EC=99=80=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EB=B0=91=20=EC=84=A0=20=EC=9E=90=EB=A6=84?= =?UTF-8?q?(109-1~4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배수유역도 = 기본 종류(양식 watershed · 1/6,000 한 장 · 조각 없음) · 도면 id plan_watershed · × 지우기 · 「새 종류」 양식 고르기로 다시 · 옛 폴더에 한 번만 더함 - 평면도 칸 종류 · 양식 고르기 = 공용 createSelectField(compact) - IP 표 첫 줄 맨 위 · 왼 → 오 · 위 → 아래(묶음 왼쪽 아래 기준 그대로) - 용지도 필지 정보 글 3.6(상수 하나) · 상자 밑 등고선 · 계류 · 필지 · 읍면동 · 시군구 선 잘라 냄(clip_lines 일반화) - 시험: 기본 종류 · 유역 한 장 · 지우기 · 다시 만들기 · IP 표 두 줄 · 상자 안 선 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Api_Fetch.ts | 6 +- .../B07_DesignDetail_Engine_Cad_IpTable.py | 50 ++++--- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 8 +- B07_DesignDetail/B07_DesignDetail_Router.py | 8 -- .../B07_DesignDetail_Router_PlanKinds.py | 6 +- .../B07_DesignDetail_Router_Support.py | 47 ++----- .../B07_DesignDetail_Router_Support_Io.py | 1 - ...B07_DesignDetail_Router_Support_Landuse.py | 15 +- .../B07_DesignDetail_Router_Support_Plan.py | 131 ++++++++++++------ .../B07_DesignDetail_UI_Panels.ts | 4 +- .../B07_DesignDetail_UI_PlanKinds.ts | 54 ++++++-- .../B07_DesignDetail_UI_Style.css | 5 +- .../tester/test_b07_landuse_parcel_info.py | 45 ++++++ resources/tester/test_b07_plan_kinds.py | 108 ++++++++++++++- 14 files changed, 351 insertions(+), 137 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 103b6a67e..aa12da9dc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -180,7 +180,10 @@ export interface PlanKind { id: string; name: string; pieces: string[]; + /** 양식 — plan(평면도 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 없음). */ + layout: PlanKindLayout; } +export type PlanKindLayout = "plan" | "watershed"; export interface PlanMark { chainage_m: number; refuge: boolean; @@ -202,10 +205,11 @@ export function addPlanKind( projectId: string, name: string, pieces: string[], + layout: PlanKindLayout = "plan", ): Promise { return requestJson(`/projects/${projectId}/plan-kinds`, { method: "POST", - body: JSON.stringify({ name, pieces }), + body: JSON.stringify({ name, pieces, layout }), }); } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py index 8fea242bf..78d718bd3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py @@ -110,33 +110,31 @@ def ip_blocks( def _place(blocks: list[dict[str, Any]], width_mm: float) -> None: - """묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 아래부터 나란히 · 넘치면 위로 다음 줄.""" + """묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 → 오른쪽 · 위 → 아래(첫 줄 맨 위). + + 묶음 전체는 도곽 왼쪽 아래 기준 — 줄이 늘면 위로 쌓이고 마지막 줄이 맨 아래다. + """ block_w = sum(_COLUMNS) per_line = max(1, int((width_mm - 2 * _MARGIN) // block_w)) + lines = -(-len(blocks) // per_line) for order, block in enumerate(blocks): line, column = divmod(order, per_line) - top = _MARGIN + line * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H + top = _MARGIN + (lines - 1 - line) * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H block["origin"] = [_MARGIN + column * block_w, top] block["rows"] = _END_ROWS if "ia" not in block else _IP_ROWS -def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None: - """표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다.""" - min_x, min_y = box[0], box[1] - width = sum(_COLUMNS) / MM - area = unary_union( - [ - rect( - min_x + b["origin"][0] / MM, - min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM, - min_x + b["origin"][0] / MM + width, - min_y + b["origin"][1] / MM, - ) - for b in blocks - ] - ) +def clip_lines( + pieces: dict[str, dict[str, Any]], + rects: list[tuple[float, float, float, float]], + names: tuple[str, ...], +) -> None: + """상자들(사업지 m · 최소 x · y · 최대 x · y) 밑 선 조각 `names` 를 상자 밖으로 잘라 낸다.""" + if not rects: + return + area = unary_union([rect(*bounds) for bounds in rects]) inside = prep(area) - for piece in _CLIPPED: + for piece in names: lines = (pieces.get(piece) or {}).get("lines") if not lines: continue @@ -155,6 +153,22 @@ def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: pieces[piece]["lines"] = kept +def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None: + """표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다.""" + min_x, min_y = box[0], box[1] + width = sum(_COLUMNS) / MM + rects = [ + ( + min_x + b["origin"][0] / MM, + min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM, + min_x + b["origin"][0] / MM + width, + min_y + b["origin"][1] / MM, + ) + for b in blocks + ] + clip_lines(pieces, rects, _CLIPPED) + + def ip_table_piece( pieces: dict[str, dict[str, Any]], curves: list[dict[str, Any]], diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index a88402caa..072a4dc33 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -75,7 +75,7 @@ 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"]}, { @@ -100,6 +100,8 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( "name": "용지도", "pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"], }, + # 배수유역도 = 양식이 다른 종류(1/6,000 한 장 · 정보표) — 조각을 고르지 않는다(109장). + {"id": "watershed", "name": "배수유역도", "pieces": [], "layout": "watershed"}, ) _COLORS = { @@ -127,7 +129,7 @@ _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = { "emd": (2, [8, 4]), "sgg": (3, [14, 5, 3, 5]), } -_PARCEL_FONT = 1.8 +PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값 TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) _LABEL_FONT = 2.5 @@ -518,7 +520,7 @@ def _piece_entities( "rect", layer=layer, color=color, - font=_PARCEL_FONT, + font=PARCEL_FONT, ) ) return out diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 9c0e8e38c..776394703 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -39,14 +39,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( from B07_DesignDetail.B07_DesignDetail_Router_Support import ( CROSS_STANDARD_ID, MASS_HAUL_ID, - WATERSHED_ID, _cross_sheet_plan, _drawing_list, _invalidate_drawing, _read_drawing, _read_json, _store_confirmed_drawing, - watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source @@ -322,12 +320,6 @@ async def get_design_drawing( async with pool.acquire() as connection: section = await get_longitudinal_section(connection, project_id, route_id) source_design = ((section or {}).get("data") or {}).get("mass_haul") - elif drawing_id == WATERSHED_ID: - # 유역도는 B04 배수유역 산출물 + 도엽 배경을 사업지 CRS로 모아 넘긴다. - context, reason = await load_drainage_context(project_id) - if context is None: - return JSONResponse(status_code=404, content={"status": "error", "message": reason}) - source_design = await asyncio.to_thread(watershed_source, context) elif drawing_id == CROSS_STANDARD_ID: # 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값). pool = get_db_pool() diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index 051b9622e..10e823bf9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -34,6 +34,8 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) class PlanKindRequest(BaseModel): name: str pieces: list[str] = Field(default_factory=list) + # 양식 — plan(평면도 · 조각 고름) · watershed(유역도). 추가에서만 쓰고 업데이트는 그대로 둔다. + layout: str = "plan" class PlanMark(BaseModel): @@ -79,10 +81,10 @@ async def get_plan_kinds(project_id: UUID) -> Any: @router.post("/{project_id}/plan-kinds") async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: - """종류 추가 — 조각을 골라 이름을 붙인다.""" + """종류 추가 — 양식 · 조각을 골라 이름을 붙인다.""" try: root = await _project_root(project_id) - await asyncio.to_thread(add_kind, root, request.name, request.pieces) + await asyncio.to_thread(add_kind, root, request.name, request.pieces, request.layout) return await asyncio.to_thread(_state, root) except ValueError as exc: return _error(400, exc) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 31fe66b3b..55d141e11 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -76,6 +76,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( PLAN_KIND_ID, + WATERSHED_LAYOUT, plan_kind_items, ) from B07_DesignDetail.B07_DesignDetail_Schema import ( @@ -92,7 +93,6 @@ _CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") # 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다. MASS_HAUL_ID = "mass_haul" -WATERSHED_ID = "watershed" COVER_ID = "cover" # 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. CROSS_STANDARD_ID = "cross_standard" @@ -156,7 +156,6 @@ def _drawing_list( (COVER_ID, "cover", "표지"), (CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL), (MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"), - (WATERSHED_ID, "watershed", "유역도(배수 유역도)"), ): drawings.append( DesignDrawingItem( @@ -164,12 +163,6 @@ def _drawing_list( kind=kind, label=label, confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), - # 배수유역도는 평면도 묶음 안 항목 — 축척(1/6,000 한 장)이 달라 종류는 아니다. - **( - {"plan_kind": WATERSHED_ID, "plan_kind_name": "배수유역도"} - if drawing_id == WATERSHED_ID - else {} - ), ) ) # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④. @@ -405,7 +398,7 @@ def _read_drawing( # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. if saved.get("format") == DRAWING_FORMAT: - if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID): + if drawing_id in (COVER_ID, MASS_HAUL_ID, CROSS_STANDARD_ID): kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_KIND_ID.fullmatch(drawing_id): kind = "plan" @@ -451,36 +444,22 @@ def _read_drawing( if not isinstance(stored_design, dict): raise FileNotFoundError("계획평면도 자료가 없습니다.") label = str(stored_design.get("label") or drawing_id) - return ( - "plan", - label, - build_plan_kind_drawing( - drawing_id, label, stored_design["box"], stored_design.get("pieces") or {} - ), - False, - None, - ) - - if drawing_id == WATERSHED_ID: - # stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). - if not isinstance(stored_design, dict): - raise FileNotFoundError("배수 유역 산출물이 없습니다.") - longitudinal = _read_json(longitudinal_path) - interval = infer_station_interval(longitudinal.get("stations") or []) - return ( - "watershed", - "유역도(배수 유역도)", - build_watershed_drawing( + if stored_design.get("layout") == WATERSHED_LAYOUT: + # 유역도 양식 — watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). + longitudinal = _read_json(longitudinal_path) + drawing = build_watershed_drawing( drawing_id, stored_design.get("route_xy") or [], stored_design.get("basins") or [], stored_design.get("contours") or [], stored_design.get("streams") or [], - interval, - ), - False, - None, - ) + infer_station_interval(longitudinal.get("stations") or []), + ) + else: + drawing = build_plan_kind_drawing( + drawing_id, label, stored_design["box"], stored_design.get("pieces") or {} + ) + return "plan", label, drawing, False, None if _LONG_ID.fullmatch(drawing_id): source = _read_json(longitudinal_path) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py index 00303a402..6cb837830 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py @@ -20,7 +20,6 @@ _CROSS_ID = re.compile(r"^cross_(\d+)m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") # 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다. MASS_HAUL_ID = "mass_haul" -WATERSHED_ID = "watershed" COVER_ID = "cover" # ⚠ 빈-도각 목록이 여기 있었다 — 계획평면도·표준 횡단면도·용지도·표준도 일곱 줄. diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py index 36d81aca4..879af2b05 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py @@ -19,16 +19,18 @@ from shapely.validation import make_valid from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PARCEL_FONT from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_rings, _geojson_features LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg") +# 필지 정보 상자 밑에서 잘라 낼 선 조각 — 배경이 글을 가리지 않게(109-4). +LABEL_CLIPPED = ("contour", "stream", "parcel", "emd", "sgg") # B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84). PARCEL_FILE = "연속지적도_bounds.geojson" EMD_FILE = "행정구역_읍면동_bounds.geojson" SGG_FILE = "행정구역_시군구_bounds.geojson" -PARCEL_FONT = 1.8 # 필지 정보 글 크기(종이 mm) ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m) _GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m) _RINGS = 5 # 후보 자리 고리 수 @@ -187,6 +189,17 @@ def place_labels( return items +def label_rects(items: list[dict[str, Any]]) -> list[tuple[float, float, float, float]]: + """필지 정보 상자 자리(사업지 m) — 그리는 상자(`_balloon_entities`)와 같은 크기.""" + rects = [] + for item in items: + width_mm, height_mm = balloon_size(item["lines"], PARCEL_FONT) + half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0 + cx, cy = item["center"] + rects.append((cx - half_w, cy - half_h, cx + half_w, cy + half_h)) + return rects + + def landuse_pieces( project_root: Path, crs: str, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 5d4037c23..1cfe716d7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -1,12 +1,14 @@ """B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장). 저장 자리(프로젝트 저장소 `B07_DesignDetail/`): - plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order} — M02 양식 층처럼 파일 하나씩 + plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout} — 파일 하나씩 plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m) plan_marks.json 사용자가 정한 대피소 · 사토장 자리 -`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때 -기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다. +`plan_kinds/` 가 없으면 기본 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도)를 +쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 +없음). 처음 고칠 때 기본 종류를 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 종류도 +고치고 지울 수 있다. 조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경) 에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다. """ @@ -24,7 +26,7 @@ from B05_Profile.B05_Profile_Engine_Sections import ( 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, station_no_label -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_piece +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines, ip_table_piece from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _structure_label, plan_area_mm, @@ -47,6 +49,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _sheet_box, map_background, plan_stations, + watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _design_root, @@ -55,7 +58,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _write_manifest, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( + LABEL_CLIPPED, LANDUSE_PIECES, + label_rects, landuse_pieces, ) from common_util.common_util_json import atomic_write_json @@ -65,6 +70,8 @@ from config.config_system import DRAWING_SCALE_PLAN KIND_ID = re.compile(r"^[a-z][a-z0-9]{0,31}$") 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 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측) @@ -80,16 +87,24 @@ def _kinds_dir(project_root: Path) -> Path: return _design_root(project_root) / "plan_kinds" -# 이관 표시 — 있으면 용지도 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음). -_LANDUSE_MARK = "_landuse_added" +# 이관 표시 `_{id}_added` — 있으면 그 기본 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음). +_ADDED_ONCE = ("landuse", "watershed") -def _add_landuse_once(folder: Path) -> None: - """93장 앞에 종류 파일을 내린 프로젝트 — 기본 종류 「용지도」를 한 번 더한다.""" - mark = folder / _LANDUSE_MARK - if mark.exists(): +def _document(kind: dict[str, Any], order: int) -> dict[str, Any]: + return { + "name": kind["name"], + "pieces": list(kind["pieces"]), + "order": order, + "layout": kind.get("layout", "plan"), + } + + +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 - landuse = next(kind for kind in DEFAULT_KINDS if kind["id"] == "landuse") documents = [] for path in folder.glob("*.json"): try: @@ -97,21 +112,23 @@ def _add_landuse_once(folder: Path) -> None: except (OSError, ValueError): continue names = {str(document.get("name")) for document in documents} - if not (folder / "landuse.json").exists() and landuse["name"] not in names: - order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1 - atomic_write_json( - folder / "landuse.json", - {"name": landuse["name"], "pieces": list(landuse["pieces"]), "order": order}, - ) - mark.touch() + 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}] — 차례대로. 파일이 없으면 기본 종류.""" + """종류 목록 [{id, name, pieces, layout}] — 차례대로. 파일이 없으면 기본 종류.""" folder = _kinds_dir(project_root) if not folder.is_dir(): - return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS] - _add_landuse_once(folder) + return [ + {"layout": "plan", **kind, "pieces": list(kind["pieces"])} 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): @@ -123,67 +140,76 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: stored = set() for piece in document.get("pieces") or []: stored.update(LEGACY_PIECES.get(piece, (piece,))) - pieces = [piece for piece in PIECE_IDS if piece in stored] + layout = document.get("layout") if document.get("layout") in _LAYOUTS else "plan" + pieces = [] if layout == WATERSHED_LAYOUT else [p for p in PIECE_IDS if p in stored] rows.append( ( int(document.get("order") or 0), path.stem, - {"id": path.stem, "name": str(document.get("name") or path.stem), "pieces": pieces}, + { + "id": path.stem, + "name": str(document.get("name") or path.stem), + "pieces": pieces, + "layout": layout, + }, ) ) return [row for _order, _id, row in sorted(rows, key=lambda item: item[:2])] def _materialize(project_root: Path) -> list[dict[str, Any]]: - """기본 넷을 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번.""" + """기본 종류를 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번.""" kinds = list_kinds(project_root) folder = _kinds_dir(project_root) if not folder.is_dir(): for order, kind in enumerate(kinds): - atomic_write_json( - folder / f"{kind['id']}.json", - {"name": kind["name"], "pieces": kind["pieces"], "order": order}, - ) - (folder / _LANDUSE_MARK).touch() + 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 -def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]: +def _checked(name: str, pieces: list[str], layout: str = "plan") -> tuple[str, list[str]]: text = str(name or "").strip() if not text or len(text) > _NAME_MAX: raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.") + if layout not in _LAYOUTS: + raise ValueError("올바르지 않은 양식입니다.") + if layout == WATERSHED_LAYOUT: + return text, [] # 유역도 양식은 그림이 정해져 있다 — 조각을 고르지 않음 chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])] if not chosen: raise ValueError("얹을 정보를 하나 이상 고르세요.") return text, chosen -def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: - text, chosen = _checked(name, pieces) +def add_kind( + project_root: Path, name: str, pieces: list[str], layout: str = "plan" +) -> dict[str, Any]: + text, chosen = _checked(name, pieces, layout) kinds = _materialize(project_root) if any(kind["name"] == text for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") - kind_id = f"k{uuid.uuid4().hex[:8]}" - atomic_write_json( - _kinds_dir(project_root) / f"{kind_id}.json", - {"name": text, "pieces": chosen, "order": len(kinds)}, - ) - return {"id": kind_id, "name": text, "pieces": chosen} + kind = {"id": f"k{uuid.uuid4().hex[:8]}", "name": text, "pieces": chosen, "layout": layout} + atomic_write_json(_kinds_dir(project_root) / f"{kind['id']}.json", _document(kind, len(kinds))) + return kind def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]: - """종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다.""" + """종류 이름 · 조각을 고친다(차례 · 양식은 그대로). 그 종류의 확정본은 풀어 다시 그린다.""" if not KIND_ID.fullmatch(kind_id): raise ValueError("올바르지 않은 종류 id 입니다.") - text, chosen = _checked(name, pieces) kinds = _materialize(project_root) + current = next((kind for kind in kinds if kind["id"] == kind_id), None) + path = _kinds_dir(project_root) / f"{kind_id}.json" + if current is None or not path.is_file(): + raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") + text, chosen = _checked(name, pieces, current["layout"]) if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") - path = _kinds_dir(project_root) / f"{kind_id}.json" - if not path.is_file(): - raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") order = int(_read_json(path).get("order") or 0) - atomic_write_json(path, {"name": text, "pieces": chosen, "order": order}) + kind = {"id": kind_id, "name": text, "pieces": chosen, "layout": current["layout"]} + atomic_write_json(path, _document(kind, order)) manifest = _read_manifest(project_root) released = False for drawing_id, entry in manifest["drawings"].items(): @@ -193,7 +219,7 @@ def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) released = True if released: _write_manifest(project_root, manifest) - return {"id": kind_id, "name": text, "pieces": chosen} + return kind def delete_kind(project_root: Path, kind_id: str) -> None: @@ -449,6 +475,8 @@ def build_pieces( pieces.update( landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route]) ) + if "parcel_info" in wanted: + clip_lines(pieces, label_rects(pieces["parcel_info"]["items"]), LABEL_CLIPPED) if "ip_table" in wanted: pieces["ip_table"] = ip_table_piece( pieces, _load_design_curves(project_root), route, box, chunk, total @@ -477,6 +505,9 @@ def plan_kind_source( ) if kind is None: raise FileNotFoundError("계획평면도 종류를 찾을 수 없습니다.") + if kind["layout"] == WATERSHED_LAYOUT: + # 유역도 양식 — B04 배수유역 산출물 + 도엽 배경(사업지 CRS) 한 장. + return {"layout": WATERSHED_LAYOUT, "label": kind["name"], **watershed_source(context)} number = int(match.group(2)) if match.group(2) else 1 pieces, box, chunk, total = build_pieces( context, longitudinal, longitudinal_path, designs, number, kind["pieces"] @@ -494,6 +525,16 @@ def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[di sheets = plan_chunks(plan_stations(longitudinal)) items = [] for kind in list_kinds(project_root): + if kind["layout"] == WATERSHED_LAYOUT: # 1/6,000 한 장 — 장 나눔 없음 + items.append( + { + "id": f"plan_{kind['id']}", + "label": kind["name"], + "plan_kind": kind["id"], + "plan_kind_name": kind["name"], + } + ) + continue title = plan_title(kind["name"]) for chunk in sheets: single = len(sheets) <= 1 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 3f49b784d..fc2dc82dd 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -29,7 +29,7 @@ export const DRAWING_GROUPS: readonly { idPrefix?: string; }[] = [ { label: "표지", kind: "cover" }, - // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 사용자 추가) + 배수유역도를 한 묶음으로. + // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도 · 사용자 추가)를 한 묶음으로. { label: "평면도", kind: "plan" }, { label: "종단도", kind: "longitudinal" }, // 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5). @@ -89,7 +89,7 @@ export function buildDrawingSidePanel( for (const group of DRAWING_GROUPS) { if (group.kind === "plan") { // 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다. - const plans = drawings.filter((item) => item.kind === "plan" || item.kind === "watershed"); + const plans = drawings.filter((item) => item.kind === "plan"); panel.append(buildPlanSection(plans, drawingButton, plan)); continue; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index d1dd90109..2af96dcbe 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -4,11 +4,11 @@ * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장). * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. - * 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. + * 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. * ========================================================================== */ import { createCheckbox } from "@ui/ui_template_checkbox"; -import { showToast } from "@ui/ui_template_elements"; +import { createSelectField, showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, @@ -17,6 +17,7 @@ import { savePlanMarks, updatePlanKind, type DesignDrawingItem, + type PlanKindLayout, type PlanKindsState, type PlanMark, } from "./B07_DesignDetail_Api_Fetch"; @@ -51,15 +52,31 @@ async function run(action: () => Promise, controls: PlanKindsControls): } } -/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */ +/** 양식 — 평면도(조각 고름 · 1/1,200 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */ +const LAYOUTS: { value: PlanKindLayout; text: string }[] = [ + { value: "plan", text: "평면도" }, + { value: "watershed", text: "유역도" }, +]; + +/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "종류")); - const pick = el("select", "b07-plan-tools__kind"); - pick.append( - ...state.kinds.map((kind) => new Option(kind.name, kind.id)), - new Option("새 종류", ""), - ); + const pick = createSelectField({ + options: [ + ...state.kinds.map((kind) => ({ value: kind.id, text: kind.name })), + { value: "", text: "새 종류" }, + ], + compact: true, + ariaLabel: "종류", + onChange: () => show(), + }); + const layout = createSelectField({ + options: LAYOUTS, + compact: true, + ariaLabel: "양식", + onChange: () => showPieces(), + }); const name = el("input", "b07-plan-tools__name"); name.type = "text"; name.placeholder = "종류 이름"; @@ -69,27 +86,35 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme pieceList.append(...boxes.map((box) => box.root)); const action = el("button", "b07-plan-tools__button"); action.type = "button"; + // 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다. + const showPieces = (): void => { + pieceList.hidden = layout.select.value === "watershed"; + }; const show = (): void => { - const kind = state.kinds.find((item) => item.id === pick.value); + const kind = state.kinds.find((item) => item.id === pick.select.value); name.value = kind?.name ?? ""; boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false)); + // 양식은 새로 만들 때만 고른다 — 있는 종류는 그 양식을 보이기만. + layout.select.value = kind?.layout ?? "plan"; + layout.select.disabled = Boolean(kind); action.textContent = kind ? "업데이트" : "추가"; + showPieces(); }; - pick.addEventListener("change", show); show(); action.addEventListener("click", () => { const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value); - const kindId = pick.value; + const kindId = pick.select.value; const label = name.value.trim(); + const chosen = layout.select.value as PlanKindLayout; void run( () => kindId ? updatePlanKind(controls.projectId, kindId, label, pieces) - : addPlanKind(controls.projectId, label, pieces), + : addPlanKind(controls.projectId, label, pieces, chosen), controls, ); }); - form.append(pick, name, pieceList, action); + form.append(pick.root, layout.root, name, pieceList, action); return form; } @@ -173,8 +198,7 @@ export function buildPlanSection( makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name), ); head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]); - // × 는 종류만 — 배수유역도는 묶음 안 고정 항목(지우면 서버가 404). - if (controls && sheets[0].kind === "plan") { + if (controls) { const remove = el("button", "b07-plan-tools__remove", "×"); remove.type = "button"; remove.title = `「${name}」 종류 지우기`; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index fcc59ec17..a9257b90a 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -279,7 +279,10 @@ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); } -.b07-plan-tools__kind, +.b07-plan-tools__pieces[hidden] { + display: none; +} + .b07-plan-tools__name { min-width: 0; } diff --git a/resources/tester/test_b07_landuse_parcel_info.py b/resources/tester/test_b07_landuse_parcel_info.py index 2ed7bb7f0..600704445 100644 --- a/resources/tester/test_b07_landuse_parcel_info.py +++ b/resources/tester/test_b07_landuse_parcel_info.py @@ -3,6 +3,7 @@ ① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선 ② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번 ③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침 +④ 상자 밑 선(등고선 · 계류 · 필지 · 읍면동 · 시군구)은 잘려 상자 안 선 0 (109-4) """ import json @@ -11,6 +12,7 @@ from pathlib import Path from shapely.geometry import LineString, Point from shapely.geometry import box as box_polygon +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( @@ -18,9 +20,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( layer_id, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( + LABEL_CLIPPED, PARCEL_FONT, ROUTE_CLEAR_M, clip_parcels, + label_rects, place_labels, ) @@ -161,3 +165,44 @@ def _polyline(entity: dict) -> LineString: c["shapeData"]["endPoint"] for c in children ] return LineString([(p["x"], p["y"]) for p in points]) + + +def test_lines_under_label_boxes_are_cut(tmp_path: Path) -> None: + features = [ + ( + { + "pnu": f"P{i}{j}", + "jibun": f"{i}{j}-1", + "jimok": "임야", + "parea": 9999, + "owner_nm": "홍길동", + }, + _polygon(_square(i * 100.0, j * 100.0, 100.0)), + ) + for i in range(4) + for j in range(3) + ] + parcels = clip_parcels(_geojson(tmp_path, features), "EPSG:4326", BOX) + route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)] + items = place_labels(parcels, route, BOX) + rects = label_rects(items) + assert PARCEL_FONT == 3.6 and rects == [_rect(item) for item in items] # 그리는 상자와 같음 + # 도곽 전체를 촘촘히 가로지르는 배경 선 — 상자마다 반드시 지나감 + across = [[[0.0, y], [400.0, y]] for y in range(1, 300, 2)] + pieces = { + "contour": {"lines": [list(line) for line in across]}, + "stream": {"lines": [[[x, 0.0], [x, 300.0]] for x in range(1, 400, 2)]}, + "parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]}, + "emd": {"lines": [list(line) for line in across]}, + "sgg": {"lines": [list(line) for line in across]}, + "centerline": {"lines": [[list(p) for p in route]]}, + } + clip_lines(pieces, rects, LABEL_CLIPPED) + inner = [box_polygon(*rect).buffer(-1e-3) for rect in rects] + for piece in LABEL_CLIPPED: + for points in pieces[piece]["lines"]: + line = LineString(points) + assert all(line.intersection(area).length < 1e-6 for area in inner), piece + # 상자 밖은 남음 · 노선은 그대로 + assert sum(LineString(p).length for p in pieces["contour"]["lines"]) > 0.5 * 150 * 400 + assert pieces["centerline"]["lines"] == [[list(p) for p in route]] diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index 43355d261..3df0d6ff8 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -8,6 +8,8 @@ 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 ⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층 ⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층 +⑧ 배수유역도 종류(109장) — 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 · + 용지도 상자 안 선 0 """ import json @@ -19,6 +21,7 @@ import pytest from B06_Section.B06_Section_Engine_Design import compute_cross_design from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ( + _place, curve_values, dms, ip_blocks, @@ -38,6 +41,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( add_kind, delete_kind, list_kinds, + plan_kind_items, read_marks, slope_records, update_kind, @@ -139,6 +143,7 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None: "대피소", "라이다", "용지도", + "배수유역도", ] added = add_kind(root, "내 평면도", ["centerline", "station", "nope"]) assert added["pieces"] == ["centerline", "station"] @@ -148,14 +153,28 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None: add_kind(root, "빈 것", []) 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"} + assert {k["id"] for k in DEFAULT_KINDS} == { + "terrain", + "route", + "layout", + "lidar", + "landuse", + "watershed", + } def test_refuge_marks_same_spot(tmp_path: Path) -> None: @@ -202,7 +221,7 @@ 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"] != "landuse": + if kind["id"] not in ("landuse", "watershed"): assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] folder = _kinds_dir(root) folder.mkdir(parents=True) @@ -227,6 +246,7 @@ def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: "id": "terrain", "name": "지형2", "pieces": ["contour", "centerline"], + "layout": "plan", } assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로 drawings = _read_manifest(root)["drawings"] @@ -315,9 +335,9 @@ def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None: json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8" ) kinds = list_kinds(root) - assert [k["id"] for k in kinds][-1] == "landuse" - assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"] - assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번 + 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)] # 지운 것은 안 살아남 @@ -344,3 +364,79 @@ def test_landuse_pieces_become_layers() -> None: if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text" ] assert labels == ["1-1", "임야", "국"] + + +def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None: + """배수유역도 = 기본 종류 · 양식 watershed · 조각 없음 · 장이 여럿이어도 목록은 한 장.""" + root = tmp_path / "project" + watershed = next(k for k in list_kinds(root) if k["id"] == "watershed") + assert watershed == { + "id": "watershed", + "name": "배수유역도", + "pieces": [], + "layout": "watershed", + } + # 측점 0 ~ 2,000 m — 1/1,200 평면도는 여러 장 · 유역도는 한 장 + stations = [ + {"chainage_m": float(c), "center_x": 0.0, "center_y": float(c)} for c in range(0, 2001, 20) + ] + 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"] + assert len(terrain) > 1 + assert mine == [ + { + "id": "plan_watershed", + "label": "배수유역도", + "plan_kind": "watershed", + "plan_kind_name": "배수유역도", + } + ] + + +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)] # 다시 읽어도 안 살아남 + 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"]) + assert again == added + assert any( + item["id"] == f"plan_{added['id']}" for item in plan_kind_items(root, {"stations": []}) + ) + update_kind(root, added["id"], "유역도2", []) # 유역도 양식은 조각 없이 업데이트 + assert next(k for k in list_kinds(root) if k["id"] == added["id"])["layout"] == "watershed" + with pytest.raises(ValueError): + 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)] + block_w = 7.0 + 6.0 + 23.0 + _place(blocks, 2 * 5.0 + 3 * block_w + 1.0) + origins = [b["origin"] for b in blocks] + tops = [o[1] for o in origins] + assert tops[0] == tops[1] == tops[2] > tops[3] # 첫 줄이 위 + assert origins[0][0] < origins[1][0] < origins[2][0] # 왼 → 오 + assert origins[3][0] == origins[0][0] # 다음 줄은 다시 왼쪽 + assert tops[3] == 5.0 + 8 * 5.0 # 마지막 줄이 도곽 왼쪽 아래 기준(옛 한 줄 자리와 같음) + assert tops[0] - tops[3] == 8 * 5.0 + 3.0 # 줄 간격 그대로