From 3048dd96ddbdd0fbed4b24ab4d26b790b5bf838a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:54:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B07):=20=EC=9A=A9=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=3D=20=ED=8F=89=EB=A9=B4=EB=8F=84=20=EC=A2=85=EB=A5=98=20=C2=B7?= =?UTF-8?q?=20=ED=95=84=EC=A7=80=20=EC=83=81=EC=9E=90=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EB=B9=84=ED=82=B4=20=C2=B7=20=EB=B0=B0?= =?UTF-8?q?=EC=88=98=EC=9C=A0=EC=97=AD=EB=8F=84=20=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=EB=8F=84(93-1~93-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조각 넷(필지 경계 · 필지 정보 · 읍면동 경계 · 시군구 경계) · 기본 종류 「용지도」 · 옛 종류 폴더에 한 번 더함 - 필지 라벨 = (필지 ∩ 도곽) − 노선 버퍼 대표점 + 상자 · 지시선 · 노선 · 상자끼리 안 겹침(새 Router_Support_Landuse) - 옛 따로 용지도 길(landuse_source · build_landuse_drawing) 지움 - 배수유역도 목록 항목에 plan_kind 실음 · 종류 제목 「평면도(…)」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Engine_Cad_Landuse.py | 372 ------------------ .../B07_DesignDetail_Engine_Cad_MassHaul.py | 38 +- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 42 ++ B07_DesignDetail/B07_DesignDetail_Router.py | 11 - .../B07_DesignDetail_Router_Support.py | 50 +-- .../B07_DesignDetail_Router_Support_Basin.py | 115 ------ ...B07_DesignDetail_Router_Support_Landuse.py | 204 ++++++++++ .../B07_DesignDetail_Router_Support_Plan.py | 41 +- .../tester/test_b07_landuse_parcel_info.py | 198 +++++++--- resources/tester/test_b07_plan_kinds.py | 63 ++- 10 files changed, 512 insertions(+), 622 deletions(-) delete mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py deleted file mode 100644 index 806d3c1b3..000000000 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py +++ /dev/null @@ -1,372 +0,0 @@ -"""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 -_INFO_LINE_GAP = _JIBUN_FONT_SIZE * 1.3 # 필지 정보 줄 간격(mm) -_LEGEND_FONT_SIZE = 2.4 - -_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 _parcel_info_lines(props: dict[str, Any]) -> list[str]: - """지번·지목·면적·소유자 네 줄 — 값이 없으면 그 줄은 빈 문자열.""" - - def text(value: Any) -> str: - return value.strip() if isinstance(value, str) else "" - - area = props.get("parea") - try: - area_text = f"{float(area):,.0f}㎡" if area not in (None, "") else "" - except (TypeError, ValueError): - area_text = text(area) - return [ - text(props.get("jibun")), - text(props.get("jimok")), - area_text, - text(props.get("owner_nm")), - ] - - -def _enclosing_note(props: dict[str, Any]) -> str: - """구역 전체가 한 필지 안일 때 정보 묶음 맨 위 줄 — 소재지(시군구 읍면동 리)를 붙인다.""" - place = " ".join( - str(props[key]).strip() - for key in ("sgg_nm", "emd_nm", "ri_nm") - if isinstance(props.get(key), str) and props[key].strip() - ) - return f"구역 전체가 이 필지 안 ({place})" if place else "구역 전체가 이 필지 안" - - -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" - ) - ) - # 필지 경계 + 필지 정보. 도곽에 잘려 조각이 여럿인 필지도 정보는 한 번만 — - # 가장 큰 조각 가운데에 적는다(구역 안 필지 수 = 정보 묶음 수). - spots: dict[str, tuple[float, tuple[float, float], dict[str, Any]]] = {} - enclosing: set[str] = set() # 도곽을 통째로 감싼 필지 - for index, parcel in enumerate(parcels): - ring = parcel.get("ring") or [] - props = parcel.get("props") or {} - label_at = parcel.get("label_at") - if len(ring) >= 2: - paper_ring = [paper(point) for point in ring] - outline = polyline_entity( - drawing_id, paper_ring, PARCEL_LAYER_ID, PARCEL_COLOR, suffix=f":parcel:{index}" - ) - if outline: - entities.append(outline) - 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) - size, center = width * height, _ring_center(paper_ring) - elif label_at is not None: - # 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 정보만 적는다. - size, center = 0.0, paper(tuple(label_at)) - enclosing.add(str(props.get("pnu") or f"#{index}")) - else: - continue - key = str(props.get("pnu") or f"#{index}") - if key not in spots or size > spots[key][0]: - spots[key] = (size, center, props) - # 필지 경계가 하나도 안 보이면(구역이 한 필지 안) 어느 필지인지 글로 밝힌다(68-2). - no_outline = not any(len(parcel.get("ring") or []) >= 2 for parcel in parcels) - jibun: list[dict[str, Any]] = [] - for key, (_size, center, props) in spots.items(): - lines = _parcel_info_lines(props) - if no_outline and key in enclosing: - lines = [_enclosing_note(props), *lines] - top = center[1] + (len(lines) - 1) / 2.0 * _INFO_LINE_GAP - for row, text in enumerate(lines): - if text: - jibun.append( - _text_entity( - f"{drawing_id}:parcel-info:{key}:{row}", - text, - center[0], - top - row * _INFO_LINE_GAP, - 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_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index ec9f25f23..c749dac21 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -179,6 +179,14 @@ def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> li return entities +def balloon_size(lines: list[str], font: float = _FONT_SIZE) -> tuple[float, float]: + """balloon (폭, 높이) mm — 글 크기에 비례 · 한글 등 넓은 글자는 1.65 칸.""" + scale = font / _FONT_SIZE + chars = max((sum(1.65 if ord(c) > 0x2E7F else 1.0 for c in line) for line in lines), default=0) + width = (chars * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X) * scale + return width, (len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y) * scale + + def _balloon_entities( drawing_id: str, seed: str, @@ -186,10 +194,14 @@ def _balloon_entities( anchor: tuple[float, float], center: tuple[float, float], shape: str, + layer: str = BAND_LAYER_ID, + color: str = BAND_COLOR, + font: float = _FONT_SIZE, ) -> list[dict[str, Any]]: - """balloon 도형 + 지시선 + 6줄 문자. shape = hexagon | ellipse | rect.""" - width = max(len(line) for line in lines) * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X - height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y + """balloon 도형 + 지시선 + 여러 줄 문자(빈 줄은 자리만). shape = hexagon | ellipse | rect.""" + scale = font / _FONT_SIZE + width, height = balloon_size(lines, font) + line_h, pad_y = _BALLOON_LINE_H * scale, _BALLOON_PAD_Y * scale cx, cy = center half_w, half_h = width / 2.0, height / 2.0 if shape == "hexagon": @@ -222,9 +234,7 @@ def _balloon_entities( ] entities: list[dict[str, Any]] = [] - shape_entity = polyline_entity( - drawing_id, outline, BAND_LAYER_ID, BAND_COLOR, suffix=f":balloon:{seed}" - ) + shape_entity = polyline_entity(drawing_id, outline, layer, color, suffix=f":balloon:{seed}") if shape_entity: entities.append(shape_entity) # 지시선은 **계단형**이고 balloon **모서리**에서 나간다(2026-09-03 사용자 확정 — @@ -234,23 +244,25 @@ def _balloon_entities( leader = polyline_entity( drawing_id, [(corner_x, corner_y), (anchor[0], corner_y), anchor], - BAND_LAYER_ID, - BAND_COLOR, + layer, + color, suffix=f":balloon:leader:{seed}", ) if leader: entities.append(leader) - first_y = cy + half_h - _BALLOON_PAD_Y - _BALLOON_LINE_H * 0.75 + first_y = cy + half_h - pad_y - line_h * 0.75 for index, line in enumerate(lines): + if not line: + continue entities.append( _text_entity( f"{drawing_id}:balloon:text:{seed}:{index}", line, cx, - first_y - index * _BALLOON_LINE_H, - BAND_LAYER_ID, - _FONT_SIZE, - BAND_COLOR, + first_y - index * line_h, + layer, + font, + color, ) ) return entities diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index cd51607fb..0e992e71e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -21,6 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, polyline_entity, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_MARGIN, _COMPASS_SIZE, @@ -58,6 +59,11 @@ PIECES: tuple[tuple[str, str, bool], ...] = ( ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), ("drainage", "배수시설 기호", False), + # 용지도(93장) — 연속지적도 · 행정구역 + ("parcel", "필지 경계", False), + ("parcel_info", "필지 정보", False), + ("emd", "읍면동 경계", False), + ("sgg", "시군구 경계", False), ) PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES) # 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다. @@ -86,6 +92,12 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( "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"], + }, ) _COLORS = { @@ -102,7 +114,18 @@ _COLORS = { "endpoints": "#ff9d4d", "refuge": "#7ddc7d", "drainage": STRUCTURE_COLOR, + "parcel": "#8c6b4f", + "parcel_info": "#5c4632", + "emd": "#2f7d4f", + "sgg": "#a63d3d", } +# 행정 · 지적 경계 (선굵기, 파선) +_BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = { + "parcel": (1, None), + "emd": (2, [8, 4]), + "sgg": (3, [14, 5, 3, 5]), +} +_PARCEL_FONT = 1.8 TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) _LABEL_FONT = 2.5 @@ -475,6 +498,25 @@ def _piece_entities( text( f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left" ) + elif piece in _BOUNDARY_STYLE: + width, dash = _BOUNDARY_STYLE[piece] + for index, points in enumerate(data.get("lines") or []): + line(points, f":{index}", width=width, dash=dash) + elif piece == "parcel_info": + for index, item in enumerate(data.get("items") or []): + out.extend( + _balloon_entities( + f"{drawing_id}:{piece}", + str(index), + item["lines"], + paper(item["anchor"]), + paper(item["center"]), + "rect", + layer=layer, + color=color, + font=_PARCEL_FONT, + ) + ) return out diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 790e55a86..9c0e8e38c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -38,7 +38,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( CROSS_STANDARD_ID, - LANDUSE_ID, MASS_HAUL_ID, WATERSHED_ID, _cross_sheet_plan, @@ -47,7 +46,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _read_drawing, _read_json, _store_confirmed_drawing, - landuse_source, watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents @@ -355,15 +353,6 @@ async def get_design_drawing( source_design = await asyncio.to_thread( plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id ) - 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 - ) kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( _read_drawing, project_root, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 3810e114c..31fe66b3b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -24,16 +24,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import 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, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, @@ -56,7 +51,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F401 _BASIN_MAX_DISTANCE_M, CONTOUR_FILE, - LANDUSE_ID, LIDAR_ID, PLAN_ID, STREAM_FILE, @@ -67,7 +61,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F4 _geometry_lines, _too_far_from_route, clip_line_to_box, - landuse_source, lidar_source, plan_source, plan_stations, @@ -158,20 +151,6 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(entry["id"], {}).get("confirmed")), ) ) - plan_sheets = plan_chunks(plan_stations(longitudinal)) - # 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다. - 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", "표지"), @@ -185,6 +164,12 @@ 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장 ④. @@ -424,8 +409,6 @@ def _read_drawing( kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_KIND_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) @@ -478,27 +461,6 @@ 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 drawing_id == WATERSHED_ID: # stored_design = watershed_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 41c0b64e8..f55535be1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -16,7 +16,6 @@ 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_Lidar import LIDAR_LABEL, hillshade_png from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( plan_area_mm, @@ -366,14 +365,6 @@ 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]]]: @@ -384,112 +375,6 @@ def _clip_rings( 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), - } - - LIDAR_ID = re.compile(r"^plan_lidar(?:_(\d+))?$") diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py new file mode 100644 index 000000000..36d81aca4 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py @@ -0,0 +1,204 @@ +"""B07 용지도 조각 — 연속지적도 · 행정구역을 평면도 장 범위로 자르고 필지 라벨 자리를 잡는다(93장). + +조각 넷 = parcel(필지 경계) · parcel_info(필지 정보 상자) · emd(읍면동 경계) · sgg(시군구 경계). +필지마다 지번 · 지목 · 면적 · 소유자 네 줄을 상자 + 지시선으로 적는다(빈 값은 그 줄만 비움). +지시선 끝 = (필지 ∩ 도곽) − 노선 버퍼 의 대표점 · 상자 = 후보 자리 중 노선 버퍼 · 이미 놓은 +상자 · 도곽 밖과 안 겹치는 첫 자리. 좌표는 사업지 m — 그리기는 `Engine_Cad_PlanKinds`. +""" + +from pathlib import Path +from typing import Any + +import numpy as np +import shapely +from pyproj import Transformer +from shapely.errors import GEOSException +from shapely.geometry import LineString, Polygon, shape +from shapely.geometry import box as box_polygon +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_Router_Support_Basin import _clip_rings, _geojson_features + +LANDUSE_PIECES = ("parcel", "parcel_info", "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 # 후보 자리 고리 수 +_DIRECTIONS = ((1, 1), (-1, 1), (1, -1), (-1, -1), (1, 0), (-1, 0), (0, 1), (0, -1)) + + +def parcel_info_lines(props: dict[str, Any]) -> list[str]: + """지번·지목·면적·소유자 네 줄 — 값이 없으면 그 줄은 빈 문자열.""" + + def text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + area = props.get("parea") + try: + area_text = f"{float(area):,.0f}㎡" if area not in (None, "") else "" + except (TypeError, ValueError): + area_text = text(area) + return [ + text(props.get("jibun")), + text(props.get("jimok")), + area_text, + text(props.get("owner_nm")), + ] + + +def _enclosing_note(props: dict[str, Any]) -> str: + """구역 전체가 한 필지 안일 때 정보 맨 위 줄 — 소재지(시군구 읍면동 리)를 붙인다.""" + place = " ".join( + str(props[key]).strip() + for key in ("sgg_nm", "emd_nm", "ri_nm") + if isinstance(props.get(key), str) and props[key].strip() + ) + return f"구역 전체가 이 필지 안 ({place})" if place else "구역 전체가 이 필지 안" + + +def _lines_of(geometry: Any) -> list[list[list[float]]]: + return [ + [[round(x, 4), round(y, 4)] for x, y in part.coords] + for part in shapely.get_parts(geometry) + if isinstance(part, LineString) and len(part.coords) >= 2 + ] + + +def clip_parcels( + path: Path, crs: str, box: tuple[float, float, float, float] +) -> list[dict[str, Any]]: + """도곽에 걸친 필지 [{key, props, area(필지 ∩ 도곽), whole(도곽을 통째로 감쌈), lines}]. + + `lines` = 도곽 안 경계선(밖은 버림 — 산지 대필지 하나가 도면을 10 km 로 벌리지 않게). + 같은 지번(pnu)이 여러 피처면 하나로 합친다. + """ + if not path.is_file(): + return [] + to_site = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + + def project(coords: np.ndarray) -> np.ndarray: + xs, ys = to_site.transform(coords[:, 0], coords[:, 1]) + return np.column_stack([xs, ys]) + + frame = box_polygon(*box) + groups: dict[str, dict[str, Any]] = {} + for index, feature in enumerate(_geojson_features(path)): + try: + geometry = make_valid(shapely.transform(shape(feature.get("geometry")), project)) + except (AttributeError, TypeError, ValueError, GEOSException): + continue + if geometry.is_empty or not geometry.intersects(frame): + continue + area = geometry.intersection(frame) + if area.area <= 0: + continue + props = feature.get("properties") or {} + key = str(props.get("pnu") or f"#{index}") + group = groups.setdefault( + key, {"key": key, "props": props, "parts": [], "whole": False, "lines": []} + ) + group["parts"].append(area) + group["whole"] = group["whole"] or geometry.contains(frame) + group["lines"].extend(_lines_of(geometry.boundary.intersection(frame))) + for group in groups.values(): + group["area"] = shapely.union_all(group.pop("parts")) + return list(groups.values()) + + +def _anchor(area: Any, clear: Any) -> tuple[float, float]: + """지시선 끝 — 노선 버퍼를 뺀 필지 조각 중 가장 큰 것의 대표점(다 덮이면 필지 대표점).""" + free = area.difference(clear) + polygons = [part for part in shapely.get_parts(free) if isinstance(part, Polygon)] + target = max(polygons, key=lambda part: part.area) if polygons else area + point = target.representative_point() + return (point.x, point.y) + + +def place_labels( + parcels: list[dict[str, Any]], + route_xy: list[tuple[float, float]], + box: tuple[float, float, float, float], +) -> list[dict[str, Any]]: + """필지마다 라벨 [{lines, anchor, center}] — 큰 필지부터 자리를 잡는다.""" + clear = LineString(route_xy).buffer(ROUTE_CLEAR_M) if len(route_xy) >= 2 else Polygon() + shapely.prepare(clear) + no_outline = not any(parcel["lines"] for parcel in parcels) + placed: list[tuple[float, float, float, float]] = [] + items: list[dict[str, Any]] = [] + for parcel in sorted(parcels, key=lambda item: -item["area"].area): + lines = parcel_info_lines(parcel["props"]) + if no_outline and parcel["whole"]: + lines = [_enclosing_note(parcel["props"]), *lines] + if not any(lines): + continue + width_mm, height_mm = balloon_size(lines, PARCEL_FONT) + half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0 + ax, ay = _anchor(parcel["area"], clear) + free_of_route: tuple[float, float, float, float] | None = None + chosen = None + for ring in range(1, _RINGS + 1): + for sx, sy in _DIRECTIONS: + cx = ax + sx * (half_w + _GAP_M) * ring + cy = ay + sy * (half_h + _GAP_M) * ring + rect = (cx - half_w, cy - half_h, cx + half_w, cy + half_h) + inside = ( + rect[0] >= box[0] + and rect[1] >= box[1] + and rect[2] <= box[2] + and rect[3] <= box[3] + ) + if not inside or clear.intersects(box_polygon(*rect)): + continue + free_of_route = free_of_route or rect + if not any( + rect[0] < other[2] + and other[0] < rect[2] + and rect[1] < other[3] + and other[1] < rect[3] + for other in placed + ): + chosen = rect + break + if chosen: + break + # ponytail: 빈자리가 없으면 상자끼리 겹침은 받아들임 · 노선과는 끝내 안 겹침(못 놓으면 뺌). + rect = chosen or free_of_route + if rect is None: + continue + placed.append(rect) + items.append( + { + "lines": lines, + "anchor": [round(ax, 4), round(ay, 4)], + "center": [ + round((rect[0] + rect[2]) / 2.0, 4), + round((rect[1] + rect[3]) / 2.0, 4), + ], + } + ) + return items + + +def landuse_pieces( + project_root: Path, + crs: str, + box: tuple[float, float, float, float], + route_xy: list[tuple[float, float]], +) -> dict[str, dict[str, Any]]: + """그 장의 용지 조각 넷 — 노선은 라벨이 비켜 갈 전체 중심선(사업지 m).""" + folder = project_root / "B04_PreProcess" / "processed" + parcels = clip_parcels(folder / PARCEL_FILE, crs, box) + return { + "parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]}, + "parcel_info": {"items": place_labels(parcels, route_xy, box)}, + "emd": {"lines": _clip_rings(folder / EMD_FILE, crs, box)}, + "sgg": {"lines": _clip_rings(folder / SGG_FILE, crs, box)}, + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 1274d78e1..861121402 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -53,6 +53,10 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _read_manifest, _write_manifest, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( + LANDUSE_PIECES, + landuse_pieces, +) from common_util.common_util_json import atomic_write_json from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_PLAN @@ -65,7 +69,7 @@ _REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄 def plan_title(name: str) -> str: - return f"계획평면도({name})" + return f"평면도({name})" # ── 종류 ───────────────────────────────────────────── @@ -75,11 +79,38 @@ def _kinds_dir(project_root: Path) -> Path: return _design_root(project_root) / "plan_kinds" +# 이관 표시 — 있으면 용지도 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음). +_LANDUSE_MARK = "_landuse_added" + + +def _add_landuse_once(folder: Path) -> None: + """93장 앞에 종류 파일을 내린 프로젝트 — 기본 종류 「용지도」를 한 번 더한다.""" + mark = folder / _LANDUSE_MARK + if mark.exists(): + return + landuse = next(kind for kind in DEFAULT_KINDS if kind["id"] == "landuse") + 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} + 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() + + def list_kinds(project_root: Path) -> list[dict[str, Any]]: - """종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 넷.""" + """종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 종류.""" 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) rows: list[tuple[int, str, dict[str, Any]]] = [] for path in folder.glob("*.json"): if not KIND_ID.fullmatch(path.stem): @@ -112,6 +143,7 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]: folder / f"{kind['id']}.json", {"name": kind["name"], "pieces": kind["pieces"], "order": order}, ) + (folder / _LANDUSE_MARK).touch() return kinds @@ -411,6 +443,11 @@ def build_pieces( pieces["lidar"] = {"image": image, "box": list(image_box)} except (FileNotFoundError, ValueError, OSError): pieces["lidar"] = {"image": None, "box": None} + if set(LANDUSE_PIECES) & set(wanted): + # 라벨은 이 장 구간이 아니라 노선 전체를 비켜 간다 — 도곽 안에 다른 구간이 들 수 있다. + pieces.update( + landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route]) + ) folder = _design_root(project_root) / "plan_pieces" / str(number) for piece, data in pieces.items(): diff --git a/resources/tester/test_b07_landuse_parcel_info.py b/resources/tester/test_b07_landuse_parcel_info.py index f44543a80..2ed7bb7f0 100644 --- a/resources/tester/test_b07_landuse_parcel_info.py +++ b/resources/tester/test_b07_landuse_parcel_info.py @@ -1,48 +1,89 @@ -"""B07 용지도 — 필지마다 지번·지목·면적·소유자 한 묶음 · 빈 값은 빈 줄 (59-6).""" +"""B07 용지도 — 필지마다 지번·지목·면적·소유자 상자 + 지시선 (59-6 · 68-2 · 93-3). -import pytest +① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선 +② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번 +③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침 +""" -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( - JIBUN_LAYER_ID, - MM, - build_landuse_drawing, +import json +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_MassHaul import balloon_size +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( + build_plan_kind_drawing, + layer_id, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( + PARCEL_FONT, + ROUTE_CLEAR_M, + clip_parcels, + place_labels, ) - -def _square(x0: float, size: float) -> list[tuple[float, float]]: - return [(x0, 0.0), (x0 + size, 0.0), (x0 + size, size), (x0, size), (x0, 0.0)] +BOX = (0.0, 0.0, 400.0, 300.0) -def test_one_label_group_per_parcel() -> None: +def _square(x0: float, y0: float, size: float) -> list[list[float]]: + return [[x0, y0], [x0 + size, y0], [x0 + size, y0 + size], [x0, y0 + size], [x0, y0]] + + +def _geojson(tmp_path: Path, features: list[tuple[dict, dict]]) -> Path: + path = tmp_path / "parcels.geojson" + payload = { + "type": "FeatureCollection", + "features": [ + {"type": "Feature", "properties": props, "geometry": geometry} + for props, geometry in features + ], + } + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + return path + + +def _polygon(ring: list[list[float]]) -> dict: + return {"type": "Polygon", "coordinates": [ring]} + + +def _rect(item: dict) -> tuple[float, float, float, float]: + width, height = balloon_size(item["lines"], PARCEL_FONT) + cx, cy = item["center"] + return (cx - width / MM / 2, cy - height / MM / 2, cx + width / MM / 2, cy + height / MM / 2) + + +def test_one_box_per_parcel(tmp_path: Path) -> None: full = {"pnu": "A", "jibun": "95전", "jimok": "전", "parea": 4916, "owner_nm": "개인"} split = {"pnu": "B", "jibun": "산1임", "jimok": "임야", "parea": 12.4, "owner_nm": ""} - parcels = [ - {"ring": _square(0.0, 40.0), "props": full}, - # 도곽에 잘려 두 조각이 된 필지 — 정보는 큰 조각에 한 번만 - {"ring": _square(100.0, 10.0), "props": split}, - {"ring": _square(200.0, 30.0), "props": split}, - # 도곽을 통째로 감싼 필지 — 경계 없이 정보만 - {"ring": [], "props": {"pnu": "C", "jibun": "산77"}, "label_at": (500.0, 500.0)}, - ] - drawing = build_landuse_drawing("landuse", "용지도", [], [], parcels, [], []) - texts = [e for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID] - labels = [e["shapeData"]["label"] for e in texts] - assert labels == ["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡", "산77"] - groups = {round(e["shapeData"]["basePoint"]["x"], 6) for e in texts} - assert len(groups) == 3 - # 쪼개진 필지는 큰 조각(200~230 m) 가운데에 - split_x = { - e["shapeData"]["basePoint"]["x"] for e in texts if e["shapeData"]["label"] == "산1임" + two_parts = { + "type": "MultiPolygon", + "coordinates": [[_square(250.0, 200.0, 20.0)], [_square(300.0, 150.0, 80.0)]], } - assert split_x.pop() == pytest.approx(215.0 * MM) + path = _geojson(tmp_path, [(full, _polygon(_square(20.0, 20.0, 120.0))), (split, two_parts)]) + parcels = clip_parcels(path, "EPSG:4326", BOX) + items = place_labels(parcels, [], BOX) + assert sorted(item["lines"] for item in items) == [ + ["95전", "전", "4,916㎡", "개인"], + ["산1임", "임야", "12㎡", ""], + ] + # 쪼개진 필지는 큰 조각(300~380 · 150~230)에 지시선 끝 + (b,) = [item for item in items if item["lines"][0] == "산1임"] + assert 300.0 <= b["anchor"][0] <= 380.0 and 150.0 <= b["anchor"][1] <= 230.0 + # 빈 줄은 글 없이 자리만 — 도면 글은 일곱 + drawing = build_plan_kind_drawing( + "plan_landuse", "평면도(용지도)", BOX, {"parcel_info": {"items": items}} + ) + texts = [ + e["shapeData"]["label"] + for e in drawing["entities"] + if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text" + ] + assert sorted(texts) == sorted(["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡"]) -def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None: - """작은 구역이 큰 필지 하나 안에 통째로 — 경계선이 없어도 어느 필지인지 한 번 적는다 (68-2).""" - import json - - from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_parcels - +def test_zone_inside_one_parcel_is_named_once(tmp_path: Path) -> None: props = { "pnu": "P1", "sgg_nm": "봉화군", @@ -53,27 +94,70 @@ def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None: "parea": 1234567, "owner_nm": "국", } - big = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]] - geojson = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": props, - "geometry": {"type": "Polygon", "coordinates": [big]}, - } - ], - } - path = tmp_path / "parcels.geojson" - path.write_text(json.dumps(geojson, ensure_ascii=False), encoding="utf-8") - parcels = _clip_parcels(path, "EPSG:4326", (4.0, 4.0, 6.0, 6.0)) - assert len(parcels) == 1 and parcels[0]["ring"] == [] - - route = [(4.5, 4.5), (5.5, 5.5)] - drawing = build_landuse_drawing("landuse", "용지도", route, [], parcels, [], []) - labels = [ - e["shapeData"]["label"] for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID + path = _geojson(tmp_path, [(props, _polygon(_square(-500.0, -500.0, 2000.0)))]) + parcels = clip_parcels(path, "EPSG:4326", BOX) + assert len(parcels) == 1 and parcels[0]["lines"] == [] and parcels[0]["whole"] + (item,) = place_labels(parcels, [(150.0, 100.0), (250.0, 200.0)], BOX) + assert item["lines"] == [ + "구역 전체가 이 필지 안 (봉화군 재산면 갈산리)", + "산77-1임", + "임야", + "1,234,567㎡", + "국", ] - notes = [label for label in labels if label.startswith("구역 전체가 이 필지 안")] - assert notes == ["구역 전체가 이 필지 안 (봉화군 재산면 갈산리)"] - assert labels[1:] == ["산77-1임", "임야", "1,234,567㎡", "국"] + + +def test_boxes_clear_route_and_each_other(tmp_path: Path) -> None: + # 노선이 가운데를 가로지르는 필지 열둘(4 × 3) + 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) + ] + path = _geojson(tmp_path, features) + route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)] + items = place_labels(clip_parcels(path, "EPSG:4326", BOX), route, BOX) + assert len(items) == 12 + clear = LineString(route).buffer(ROUTE_CLEAR_M) + rects = [_rect(item) for item in items] + for item, rect in zip(items, rects): + assert not box_polygon(*rect).intersects(clear) # 상자 · 노선 겹침 0 + assert not clear.contains(Point(item["anchor"])) # 지시선 끝도 노선 밖 + assert BOX[0] <= rect[0] and rect[2] <= BOX[2] and BOX[1] <= rect[1] and rect[3] <= BOX[3] + for index, rect in enumerate(rects): + for other in rects[index + 1 :]: + assert not box_polygon(*rect).intersects(box_polygon(*other)) + # 도면(종이 mm)에서도 상자 테두리가 중심선과 안 닿음 + pieces = { + "centerline": {"lines": [[list(p) for p in route]]}, + "parcel_info": {"items": items}, + } + drawing = build_plan_kind_drawing("plan_landuse", "평면도(용지도)", BOX, pieces) + (center,) = [e for e in drawing["entities"] if e.get("layerId") == layer_id("centerline")] + center_line = _polyline(center) + # 상자 = 변 넷 · 지시선 = 계단 둘 + outlines = [ + e + for e in drawing["entities"] + if e.get("layerId") == layer_id("parcel_info") and len(e.get("children") or []) == 4 + ] + assert len(outlines) == 12 + for outline in outlines: + assert _polyline(outline).distance(center_line) > 0 + + +def _polyline(entity: dict) -> LineString: + children = entity["children"] + points = [children[0]["shapeData"]["startPoint"]] + [ + c["shapeData"]["endPoint"] for c in children + ] + return LineString([(p["x"], p["y"]) for p in points]) diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index a3cc127a6..f6837ff2f 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -6,6 +6,7 @@ ④ 고른 조각만 도면 층으로 얹힘 ⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 +⑥ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층 """ import json @@ -123,7 +124,13 @@ 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)] == [ + "지형", + "노선배치도", + "대피소", + "라이다", + "용지도", + ] added = add_kind(root, "내 평면도", ["centerline", "station", "nope"]) assert added["pieces"] == ["centerline", "station"] with pytest.raises(ValueError): @@ -132,14 +139,14 @@ 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"} + assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"} def test_refuge_marks_same_spot(tmp_path: Path) -> None: @@ -184,16 +191,16 @@ def test_split_runs_switch_cut_fill_and_stay_joined() -> None: def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None: root = tmp_path / "project" outline = {"road_edge", "fill_outline", "cut_outline"} - for ( - kind - ) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음 - assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] + # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로) + for kind in DEFAULT_KINDS: + if kind["id"] != "landuse": + 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,) = list_kinds(root) + kind = next(k for k in list_kinds(root) if k["id"] == "old") # 용지도는 이관으로 더해짐 assert set(kind["pieces"]) == outline | {"station"} @@ -230,3 +237,43 @@ def test_drainage_symbol_piece() -> None: mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")] assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2 assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine) + + +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][-1] == "landuse" + assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"] + assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번 + 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]]]}, + "emd": {"lines": [[[0.0, 5.0], [10.0, 5.0]]]}, + "sgg": {"lines": [[[0.0, 9.0], [10.0, 9.0]]]}, + "parcel_info": { + "items": [ + {"lines": ["1-1", "임야", "", "국"], "anchor": [5.0, 2.0], "center": [20.0, 20.0]} + ] + }, + } + drawing = build_plan_kind_drawing( + "plan_landuse", "평면도(용지도)", (-50.0, -10.0, 50.0, 50.0), pieces + ) + names = {layer["id"] for layer in drawing["layers"]} + assert {layer_id(p) for p in ("parcel", "parcel_info", "emd", "sgg")} <= names + labels = [ + e["shapeData"]["label"] + for e in drawing["entities"] + if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text" + ] + assert labels == ["1-1", "임야", "국"] From 5b4560493a35a24f4b3afc10b133840a6595299b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:54:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B07):=20=EC=A2=8C=EC=B8=A1=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=8F=89=EB=A9=B4=EB=8F=84=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=EB=8F=84=20=C2=B7=20=ED=9A=A1=EB=8B=A8=EB=8F=84=20?= =?UTF-8?q?=C2=B7=20=EC=9C=A0=EC=97=AD=EB=8F=84=20=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=20=EC=95=88=20=C2=B7=20=ED=91=9C=EC=A4=80=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=ED=8E=BC=EC=B9=A8(93-1=20=C2=B7=2093-4?= =?UTF-8?q?~93-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배수유역도 = 평면도 묶음 안 항목(× 없음) · 용지도 따로 항목 뺌 - 표준 횡단면도 = 횡단도 묶음 맨 위 전체 폭 한 줄 · 개수는 횡단 장만 · 한 장이어도 묶음 - 「종류 추가 · 업데이트 · 대피소 자리」 기본 펼침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_UI_Panels.ts | 24 +++++++++++-------- .../B07_DesignDetail_UI_PlanKinds.ts | 19 +++++++++------ .../B07_DesignDetail_UI_Style.css | 5 ++-- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 59bfc6f72..3f49b784d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -29,14 +29,12 @@ export const DRAWING_GROUPS: readonly { idPrefix?: string; }[] = [ { label: "표지", kind: "cover" }, - // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)를 한 묶음으로 — PLAN 60장. - { label: "계획평면도", kind: "plan" }, - { label: "종단면도", kind: "longitudinal" }, - { label: "표준 횡단면도", kind: "cross_standard" }, - { label: "횡단면도", kind: "cross" }, + // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 사용자 추가) + 배수유역도를 한 묶음으로. + { label: "평면도", kind: "plan" }, + { label: "종단도", kind: "longitudinal" }, + // 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5). + { label: "횡단도", kind: "cross" }, { label: "토적도(유토곡선)", kind: "mass_haul" }, - { label: "유역도(배수 유역도)", kind: "watershed" }, - { label: "용지도", idPrefix: "landuse" }, ]; /** B06 확정 산출물 기반 도면 목록 패널. */ @@ -91,7 +89,7 @@ export function buildDrawingSidePanel( for (const group of DRAWING_GROUPS) { if (group.kind === "plan") { // 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다. - const plans = drawings.filter((item) => item.kind === "plan"); + const plans = drawings.filter((item) => item.kind === "plan" || item.kind === "watershed"); panel.append(buildPlanSection(plans, drawingButton, plan)); continue; } @@ -102,8 +100,9 @@ export function buildDrawingSidePanel( (item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`), ) : drawings.filter((item) => item.id === group.blankId); - // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. - if (items.length <= 1) { + const cross = group.kind === "cross"; + // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다 — 횡단도는 늘 묶음. + if (items.length <= 1 && !cross) { const [drawing] = items; // 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다. if (!drawing) { @@ -135,6 +134,11 @@ export function buildDrawingSidePanel( sectionTitle.className = "ui-collapsible__title"; sectionTitle.textContent = `${group.label} ${items.length}`; section.append(sectionTitle); + if (cross) { + // 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만. + for (const standard of drawings.filter((item) => item.kind === "cross_standard")) + section.append(drawingButton(standard, standard.label)); + } for (const drawing of items) { // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. section.append(drawingButton(drawing, drawing.label)); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 7ec37a5f6..d1dd90109 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts - * 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · + * 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장). * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. @@ -47,7 +47,7 @@ async function run(action: () => Promise, controls: PlanKindsControls): await action(); controls.onChanged(); } catch (error) { - showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error"); + showToast(error instanceof Error ? error.message : "평면도를 고치지 못했습니다.", "error"); } } @@ -148,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem return form; } -/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ +/** 「평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, @@ -156,7 +156,7 @@ export function buildPlanSection( ): HTMLElement { const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); section.dataset.kind = "plan"; - section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`)); + section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`)); const kinds = new Map(); for (const item of items) { @@ -173,7 +173,8 @@ 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]); - if (controls) { + // × 는 종류만 — 배수유역도는 묶음 안 고정 항목(지우면 서버가 404). + if (controls && sheets[0].kind === "plan") { const remove = el("button", "b07-plan-tools__remove", "×"); remove.type = "button"; remove.title = `「${name}」 종류 지우기`; @@ -192,7 +193,7 @@ export function buildPlanSection( // 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. const tools = el("details", "b07-plan-tools"); tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리")); - tools.addEventListener("toggle", () => { + const load = (): void => { if (!tools.open || tools.dataset.loaded) return; tools.dataset.loaded = "true"; void fetchPlanKinds(controls.projectId) @@ -204,7 +205,11 @@ export function buildPlanSection( "error", ); }); - }); + }; + tools.addEventListener("toggle", load); + // 기본 펼침 — 종류마다 얹을 정보(사용자 지정)가 바로 보이게(93-6). + tools.open = true; + load(); section.append(tools); } return section; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 20dd06b03..fcc59ec17 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -80,8 +80,9 @@ cursor: not-allowed; } -/* 그리드 제목은 두 열을 가로지른다 */ -.b07-drawing-group[data-kind="cross"] h3 { +/* 그리드 제목 · 표준 횡단면도는 두 열을 가로지른다 */ +.b07-drawing-group[data-kind="cross"] h3, +.b07-drawing-group[data-kind="cross"] [data-drawing-id="cross_standard"] { grid-column: 1 / -1; }