diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 2a29b156..38a360c9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -12,6 +12,7 @@ export interface DesignDrawingItem { | "mass_haul" | "watershed" | "plan" + | "landuse" | "blank"; label: string; chainage_m: number | null; @@ -97,6 +98,7 @@ export interface DesignDrawingResponse { | "mass_haul" | "watershed" | "plan" + | "landuse" | "blank"; label: string; drawing: CadDrawing; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py new file mode 100644 index 00000000..1949a66f --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py @@ -0,0 +1,339 @@ +"""B07 용지도 CAD 조립 — 수치등고선 배경 위에 연속지적도·행정구역을 얹는다. + +사용자 지시(2026-09-04) — 「용지도는 계획평면도와 같이 수치등고선을 배경으로 하고 +연속지적도·시군구·읍면동을 얹을 것. 색상은 변경하고, 배수유역도의 표 자리에 범례를 +넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」. + +지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`· +`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 이번 판은 **지번만** 적는다 — +지목·면적·소유 구분은 용지 조서(표)에서 쓸 값이라 도면에는 넣지 않는다. + +축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다. + +좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm). +""" + +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + _COMPASS_MARGIN, + _COMPASS_SIZE, + _FONT_SIZE, + _TITLE_FONT_SIZE, + MM, + plan_area_mm, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + entities_bbox, + frame_entities, + scale_fields, +) +from config.config_system import DRAWING_SCALE_PLAN + +LANDUSE_KIND = "landuse" +LANDUSE_LABEL = "용지도" + +CONTOUR_LAYER_ID = "b07-landuse-contour" +PARCEL_LAYER_ID = "b07-landuse-parcel" +JIBUN_LAYER_ID = "b07-landuse-jibun" +EMD_LAYER_ID = "b07-landuse-emd" +SGG_LAYER_ID = "b07-landuse-sgg" +ROUTE_LAYER_ID = "b07-landuse-route" +LEGEND_LAYER_ID = "b07-landuse-legend" +TITLE_LAYER_ID = "b07-landuse-title" + +# 도면용 색 — 화면용(유역도)보다 **가라앉힌** 색을 쓴다. 지적 경계가 주제이므로 배경 +# 등고선은 가장 옅게, 행정 경계는 굵고 진하게 가른다(2026-09-04 사용자 「색상은 변경」). +CONTOUR_COLOR = "#9aa3ad" +PARCEL_COLOR = "#8c6b4f" +JIBUN_COLOR = "#5c4632" +EMD_COLOR = "#2f7d4f" +SGG_COLOR = "#a63d3d" +ROUTE_COLOR = "#ffe066" +LEGEND_COLOR = TABLE_LABEL_COLOR + +_ROUTE_WIDTH = 3 +_SGG_WIDTH = 3 +_EMD_WIDTH = 2 +_JIBUN_FONT_SIZE = 1.8 +_LEGEND_FONT_SIZE = 2.4 +# 지번을 적을 최소 필지 크기(종이 mm) — 이보다 작으면 글자가 겹쳐 읽히지 않는다. +_JIBUN_MIN_W_MM = 6.0 +_JIBUN_MIN_H_MM = 3.0 + +_LEGEND_ROW_H = 6.0 +_LEGEND_SAMPLE_W = 12.0 +_LEGEND_GAP = 3.0 +_LEGEND_TOP_GAP = 8.0 + +# 범례 항목 (표기 이름, 색, 선굵기, 파선). +_LEGEND_ROWS: tuple[tuple[str, str, int, list[int] | None], ...] = ( + ("계획노선", ROUTE_COLOR, _ROUTE_WIDTH, None), + ("필지 경계", PARCEL_COLOR, 1, None), + ("읍면동·리 경계", EMD_COLOR, _EMD_WIDTH, [8, 4]), + ("시군구 경계", SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5]), + ("등고선", CONTOUR_COLOR, 1, None), +) + + +def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]: + """고리의 bbox 중심 — 오목한 필지에서도 글자가 도면 밖으로 튀지 않는다.""" + xs = [x for x, _y in ring] + ys = [y for _x, y in ring] + return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0) + + +def _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]: + """범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다.""" + entities: list[dict[str, Any]] = [] + x, y = origin + entities.append( + _text_entity( + f"{drawing_id}:legend:title", + "범 례", + x + _LEGEND_SAMPLE_W / 2.0 + 6.0, + y, + LEGEND_LAYER_ID, + _LEGEND_FONT_SIZE + 0.6, + LEGEND_COLOR, + ) + ) + for index, (label, color, width, dash) in enumerate(_LEGEND_ROWS): + row_y = y - _LEGEND_TOP_GAP - index * _LEGEND_ROW_H + sample = polyline_entity( + drawing_id, + [(x, row_y), (x + _LEGEND_SAMPLE_W, row_y)], + LEGEND_LAYER_ID, + color, + suffix=f":legend:{index}", + dash=dash, + width=width, + ) + if sample: + entities.append(sample) + entities.append( + _text_entity( + f"{drawing_id}:legend:label:{index}", + label, + x + _LEGEND_SAMPLE_W + _LEGEND_GAP, + row_y, + LEGEND_LAYER_ID, + _LEGEND_FONT_SIZE, + LEGEND_COLOR, + align="left", + ) + ) + return entities + + +def _boundary_entities( + drawing_id: str, + rings: list[list[tuple[float, float]]], + layer_id: str, + color: str, + width: int, + dash: list[int] | None, + paper: Any, + tag: str, +) -> list[dict[str, Any]]: + entities: list[dict[str, Any]] = [] + for index, ring in enumerate(rings): + line = polyline_entity( + drawing_id, + [paper(point) for point in ring], + layer_id, + color, + suffix=f":{tag}:{index}", + dash=dash, + width=width, + ) + if line: + entities.append(line) + return entities + + +def build_landuse_drawing( + drawing_id: str, + label: str, + route_xy: list[tuple[float, float]], + contours: list[list[tuple[float, float]]], + parcels: list[dict[str, Any]], + emd_rings: list[list[tuple[float, float]]], + sgg_rings: list[list[tuple[float, float]]], +) -> dict[str, Any]: + """용지도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다. + + `parcels`는 {"ring": [(x, y)...], "props": {지적 속성}} 목록이다. 도곽에 걸친 필지는 + 라우터가 잘라 넘기므로 고리가 아니라 **열린 선**일 수 있다. + """ + everything = [ + *route_xy, + *(point for line in contours for point in line), + *(point for parcel in parcels for point in parcel.get("ring") or []), + ] + if not everything: + raise FileNotFoundError( + "용지도에 그릴 좌표가 없습니다. B04 전처리에서 연속지적도·수치지형도를 먼저 받으세요." + ) + min_x = min(x for x, _y in everything) + min_y = min(y for _x, y in everything) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + + entities: list[dict[str, Any]] = [] + # 배경 등고선이 가장 아래 — 지적 경계가 주제라 옅게 깐다. + entities.extend( + _boundary_entities( + drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour" + ) + ) + # 필지 경계 + 지번. + jibun: list[dict[str, Any]] = [] + for index, parcel in enumerate(parcels): + ring = parcel.get("ring") or [] + label_at = parcel.get("label_at") + if len(ring) >= 2: + outline = polyline_entity( + drawing_id, + [paper(point) for point in ring], + PARCEL_LAYER_ID, + PARCEL_COLOR, + suffix=f":parcel:{index}", + ) + if outline: + entities.append(outline) + elif label_at is None: + continue + if label_at is not None: + # 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 지번만 적는다. + center = paper(tuple(label_at)) + else: + paper_ring = [paper(point) for point in ring] + width = max(x for x, _y in paper_ring) - min(x for x, _y in paper_ring) + height = max(y for _x, y in paper_ring) - min(y for _x, y in paper_ring) + # 작은 필지는 지번을 솎는다 — 글자가 겹치면 큰 필지 것까지 못 읽는다. + if width < _JIBUN_MIN_W_MM or height < _JIBUN_MIN_H_MM: + continue + center = _ring_center(paper_ring) + text = (parcel.get("props") or {}).get("jibun") + if not isinstance(text, str) or not text: + continue + jibun.append( + _text_entity( + f"{drawing_id}:jibun:{index}", + text, + center[0], + center[1], + JIBUN_LAYER_ID, + _JIBUN_FONT_SIZE, + JIBUN_COLOR, + ) + ) + # 행정 경계는 필지 위에, 노선은 그 위에 — 아래에 깔리면 필지 선에 묻힌다. + entities.extend( + _boundary_entities( + drawing_id, emd_rings, EMD_LAYER_ID, EMD_COLOR, _EMD_WIDTH, [8, 4], paper, "emd" + ) + ) + entities.extend( + _boundary_entities( + drawing_id, + sgg_rings, + SGG_LAYER_ID, + SGG_COLOR, + _SGG_WIDTH, + [14, 5, 3, 5], + paper, + "sgg", + ) + ) + map_bbox = entities_bbox(entities) + route = polyline_entity( + drawing_id, + [paper(point) for point in route_xy], + ROUTE_LAYER_ID, + ROUTE_COLOR, + width=_ROUTE_WIDTH, + ) + if route: + entities.append(route) + entities.extend(jibun) # 지번은 가장 위 — 선에 가리면 못 읽는다. + + # 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리). + if map_bbox: + column_x = map_bbox[2] + _COMPASS_MARGIN + column_top = map_bbox[3] + entities.extend( + compass_entities( + drawing_id, + (column_x + _COMPASS_SIZE / 2.0, column_top - _COMPASS_SIZE / 2.0), + _COMPASS_SIZE, + ) + ) + entities.extend(_legend_entities(drawing_id, (column_x, column_top - _COMPASS_SIZE - 10.0))) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_PLAN:,}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(CONTOUR_LAYER_ID, "등고선", locked=True), + _layer(PARCEL_LAYER_ID, "필지 경계"), + _layer(JIBUN_LAYER_ID, "지번"), + _layer(EMD_LAYER_ID, "읍면동·리 경계"), + _layer(SGG_LAYER_ID, "시군구 경계"), + _layer(ROUTE_LAYER_ID, "계획노선"), + _layer(LEGEND_LAYER_ID, "범례"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } + + +def landuse_area_mm() -> tuple[float, float]: + """지적 배경이 차지할 수 있는 크기(mm) — 계획평면도와 같다(같은 축척·같은 도곽).""" + return plan_area_mm() diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index f4777153..aa8b3b5c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -34,6 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( use_title_fields, ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + LANDUSE_ID, MASS_HAUL_ID, PLAN_ID, WATERSHED_ID, @@ -44,6 +45,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _read_json, _recompute_confirmed_design, _store_confirmed_drawing, + landuse_source, plan_source, watershed_source, ) @@ -303,6 +305,15 @@ async def get_design_drawing( if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) source_design = await asyncio.to_thread(watershed_source, context) + elif LANDUSE_ID.fullmatch(drawing_id): + # 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다. + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + source_design = await asyncio.to_thread( + landuse_source, context, longitudinal, drawing_id + ) elif PLAN_ID.fullmatch(drawing_id): # 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다. context, reason = await load_drainage_context(project_id) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index ef362b2a..9fb6f583 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -21,6 +21,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import ( build_blank_drawing, build_cover_drawing, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( + LANDUSE_LABEL, + build_landuse_drawing, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, @@ -52,6 +56,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( CONTOUR_FILE as CONTOUR_FILE, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + LANDUSE_ID as LANDUSE_ID, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( PLAN_ID as PLAN_ID, ) @@ -79,6 +86,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( clip_line_to_box as clip_line_to_box, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( + landuse_source as landuse_source, +) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( plan_source as plan_source, ) @@ -119,7 +129,6 @@ BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( ("blank_plan_lidar", "계획평면도(라이다)"), ("blank_cross_standard", "표준 횡단면도"), ("blank_standard", "표준도"), - ("blank_landuse", "용지도"), ) BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) @@ -177,6 +186,19 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) + # 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다. + for chunk in plan_sheets: + drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}" + drawings.append( + DesignDrawingItem( + id=drawing_id, + kind="landuse", + label=LANDUSE_LABEL + if len(plan_sheets) <= 1 + else f"{LANDUSE_LABEL} {chunk['number']}장", + confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + ) + ) # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). for drawing_id, kind, label in ( (COVER_ID, "cover", "표지"), @@ -409,6 +431,8 @@ def _read_drawing( kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_ID.fullmatch(drawing_id): kind = "plan" + elif LANDUSE_ID.fullmatch(drawing_id): + kind = "landuse" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -437,6 +461,27 @@ def _read_drawing( None, ) + if LANDUSE_ID.fullmatch(drawing_id): + # stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS). + if not isinstance(stored_design, dict): + raise FileNotFoundError("용지도 자료가 없습니다.") + label = str(stored_design.get("label") or LANDUSE_LABEL) + return ( + "landuse", + label, + build_landuse_drawing( + drawing_id, + label, + stored_design.get("route_xy") or [], + stored_design.get("contours") or [], + stored_design.get("parcels") or [], + stored_design.get("emd_rings") or [], + stored_design.get("sgg_rings") or [], + ), + False, + None, + ) + if PLAN_ID.fullmatch(drawing_id): # stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS). if not isinstance(stored_design, dict): diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 73c52af0..3c104371 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -15,6 +15,7 @@ from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import LANDUSE_LABEL from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( plan_area_mm, plan_chunks, @@ -76,7 +77,7 @@ def _basins_crs(context: Any, payload: dict[str, Any]) -> str: def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: - """LineString·MultiLineString·Polygon을 점열 목록으로 편다.""" + """LineString·MultiLineString·Polygon·MultiPolygon을 점열 목록으로 편다.""" if not isinstance(geometry, dict): return [] kind = geometry.get("type") @@ -91,6 +92,15 @@ def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: for part in coordinates if isinstance(part, list) ] + # 연속지적도·행정구역은 MultiPolygon 이다 — 폴리곤마다 고리를 모두 편다(2026-09-04). + if kind == "MultiPolygon": + return [ + [(float(p[0]), float(p[1])) for p in ring if isinstance(p, list)] + for polygon in coordinates + if isinstance(polygon, list) + for ring in polygon + if isinstance(ring, list) + ] return [] @@ -336,6 +346,130 @@ def _plan_structures(context: Any) -> list[dict[str, Any]]: return [point for point in points if isinstance(point, dict)] +LANDUSE_ID = re.compile(r"^landuse(?:_(\d+))?$") + +# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84). +PARCEL_FILE = "연속지적도_bounds.geojson" +EMD_FILE = "행정구역_읍면동_bounds.geojson" +SGG_FILE = "행정구역_시군구_bounds.geojson" + + +def _clip_rings( + path: Path, crs: str, box: tuple[float, float, float, float] +) -> list[list[tuple[float, float]]]: + """행정구역 경계를 사업지 좌표계로 돌려 도곽 범위로 절취한다(속성은 안 씀).""" + rings: list[list[tuple[float, float]]] = [] + for ring in _metric_lines(path, crs): + rings.extend(clip_line_to_box(ring, box)) + return rings + + +def _contains(ring: list[tuple[float, float]], point: tuple[float, float]) -> bool: + """점이 고리 안에 드는지 (반직선 교차 판정).""" + x, y = point + inside = False + for index in range(len(ring)): + x1, y1 = ring[index - 1] + x2, y2 = ring[index] + if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1: + inside = not inside + return inside + + +def _clip_parcels( + path: Path, crs: str, box: tuple[float, float, float, float] +) -> list[dict[str, Any]]: + """연속지적도를 사업지 좌표계로 돌려 도곽 안 필지만 남긴다 (지번 표기용 속성 포함). + + 필지는 지번을 적어야 하므로 경계선만 자르는 `_metric_lines` 캐시를 쓰지 못한다 — + 피처와 속성을 짝지어 읽는다. 도곽 밖 필지는 여기서 버려 도면이 무거워지지 않게 한다. + + 두 가지를 함께 낸다. + - `ring` : 도곽으로 자른 경계선(밖으로 나가는 부분은 버린다). 자르지 않으면 + 산지 대필지 하나가 도면을 10 km 로 벌린다(2026-09-04 실측: 콘텐츠 8,368 mm). + - `label_at` : 도곽을 **통째로 감싸는** 필지의 지번 자리. 임야 대필지 안에 노선이 + 들어앉으면 경계선이 도곽 안에 하나도 없어 지번이 사라진다(2026-09-04 실측: + 용화_LAS 노선이 「산77-1임 일월면 용화리」 한 필지 안에 통째로 들어감). + """ + if not path.is_file(): + return [] + transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + min_x, min_y, max_x, max_y = box + center = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + parcels: list[dict[str, Any]] = [] + for feature in _geojson_features(path): + properties = feature.get("properties") or {} + for ring in _geometry_lines(feature.get("geometry")): + converted = [ + (float(x), float(y)) + for x, y in (transformer.transform(point[0], point[1]) for point in ring) + ] + if len(converted) < 3: + continue + if max(x for x, _y in converted) < min_x or min(x for x, _y in converted) > max_x: + continue + if max(y for _x, y in converted) < min_y or min(y for _x, y in converted) > max_y: + continue + parts = [part for part in clip_line_to_box(converted, box) if len(part) >= 2] + for part in parts: + parcels.append({"ring": part, "props": properties}) + if not parts and _contains(converted, center): + parcels.append({"ring": [], "props": properties, "label_at": center}) + return parcels + + +def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]: + """용지도 한 장의 입력(노선·등고선·연속지적도·행정구역)을 사업지 CRS(m)로 모은다. + + 축척·도곽·장 나눔은 계획평면도와 같다 — 배경도 같은 창구(`map_background`)를 쓴다. + """ + match = LANDUSE_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 용지도 ID입니다.") + chunks = plan_chunks(plan_stations(longitudinal)) + number = int(match.group(1)) if match.group(1) else 1 + chunk = next((item for item in chunks if item["number"] == number), None) + if chunk is None: + raise FileNotFoundError("요청한 용지도 장을 찾을 수 없습니다.") + total = len(chunks) + start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) + + route_xy = [ + (vertex.x, vertex.y) + for vertex in context.vertices + if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m + ] + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_PLAN, + plan_area_mm(), + route_xy, + ) + # 지적·행정 경계는 등고선과 **같은 범위**로 자른다 — 배경보다 넓으면 도면이 A1을 넘는다. + area_w_mm, area_h_mm = plan_area_mm() + half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0 + center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0 + box = ( + min(center_x - half_w, min(x for x, _y in route_xy)), + min(center_y - half_h, min(y for _x, y in route_xy)), + max(center_x + half_w, max(x for x, _y in route_xy)), + max(center_y + half_h, max(y for _x, y in route_xy)), + ) + sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed" + label = LANDUSE_LABEL if total <= 1 else f"{LANDUSE_LABEL} {number}장" + return { + "label": label, + "route_xy": route_xy, + "contours": background["contours"], + "parcels": _clip_parcels(sheet_dir / PARCEL_FILE, context.crs, box), + "emd_rings": _clip_rings(sheet_dir / EMD_FILE, context.crs, box), + "sgg_rings": _clip_rings(sheet_dir / SGG_FILE, context.crs, box), + } + + def watershed_source(context: Any) -> dict[str, Any]: """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 1fc0ce6d..d4b7fddf 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -10,7 +10,16 @@ class DesignDrawingItem(BaseModel): id: str # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"] + kind: Literal[ + "cover", + "longitudinal", + "cross", + "mass_haul", + "watershed", + "plan", + "landuse", + "blank", + ] label: str chainage_m: float | None = None confirmed: bool = False @@ -33,7 +42,16 @@ class DesignDrawingResponse(BaseModel): route_id: int id: str # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "plan", "blank"] + kind: Literal[ + "cover", + "longitudinal", + "cross", + "mass_haul", + "watershed", + "plan", + "landuse", + "blank", + ] label: str drawing: dict[str, Any] confirmed: bool = False diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 9e8ef8ad..74d19953 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -41,7 +41,7 @@ export const DRAWING_GROUPS: readonly { { label: "토적도(유토곡선)", kind: "mass_haul" }, { label: "유역도(배수 유역도)", kind: "watershed" }, { label: "표준도", blankId: "blank_standard" }, - { label: "용지도", blankId: "blank_landuse" }, + { label: "용지도", idPrefix: "landuse" }, ]; /** B06 확정 산출물 기반 도면 목록 패널. */