From 719455ae2b36bb1e43a9f9bbb8df50635a7c99eb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 17:55:32 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(B07):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=EB=8F=84=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EB=B6=80?= =?UTF-8?q?=EC=A1=B1=20=E2=80=94=20=EB=B0=B0=EA=B2=BD=EC=84=A0=20=EC=86=8E?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=B0=B0=EC=97=B4=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=C2=B7=20=ED=95=B4=EC=B9=AD=20=EC=84=A0=EB=B6=84=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등고선·세류선: 종이 0.1 mm(펜 굵기) 아래 굴곡 점 제거 — 294,339점 → 약 95,600점 - 배경선 캐시: 파이썬 튜플 → numpy 배열 + 선마다 범위 · 도곽 밖 선은 자르기 전에 거름 · 투영 한 줄씩 묶어 처리 - HatchEntity: 경계·무늬가 그대로면 줌·이동 때 패턴 선분 다시 계산 안 함 - 검증_sub4 유역도: 응답 75 MB → 24 MB · 서버 최고 1,304 MB → 595 MB · 끝 489 MB → 288 MB - 브라우저: 전 = 힙 289 MB 넘다 페이지 죽음 · 후 = 최고 203 MB 열림 · 줌 20번 275~281 MB 고정 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B07_DesignDetail_Engine_Cad_Basin.py | 16 +++++- .../B07_DesignDetail_Router_Support_Basin.py | 49 +++++++++++++------ .../openwebcad/src/entities/HatchEntity.ts | 26 ++++++---- resources/tester/test_b07_basin_memory.py | 37 ++++++++++++++ 4 files changed, 102 insertions(+), 26 deletions(-) create mode 100644 resources/tester/test_b07_basin_memory.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index 271f75cdd..a9999693d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -18,6 +18,8 @@ import math from typing import Any from uuid import uuid5 +from shapely.geometry import LineString + from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, DRAWING_FORMAT, @@ -94,6 +96,9 @@ _COMPASS_SIZE = 26.0 # 방위표 긴 변(mm) _COMPASS_GAP = 6.0 # 방위표와 첫 정보표 사이(mm) # 노선은 지형·유역 위에 얹혀야 보인다 — 굵기도 배경선보다 굵게 한다. _ROUTE_WIDTH = 3 +# 배경선(등고선·세류선)은 종이에서 이보다 작게 꺾이는 점을 뺀다 — 펜 굵기보다 가늘어 +# 안 보이는 점이다. 그대로 두면 수십만 선분이 서버 응답·브라우저 메모리를 다 먹는다. +_BACKGROUND_SIMPLIFY_MM = 0.1 def map_area_mm() -> tuple[float, float]: @@ -109,6 +114,13 @@ def map_area_mm() -> tuple[float, float]: ) +def _thinned(points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """종이 mm 점열에서 `_BACKGROUND_SIMPLIFY_MM` 아래 굴곡을 뺀다(양 끝점은 남는다).""" + if len(points) < 3: + return points + return list(LineString(points).simplify(_BACKGROUND_SIMPLIFY_MM).coords) + + def _number(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback @@ -298,7 +310,7 @@ def build_watershed_drawing( for index, line in enumerate(contours): contour = polyline_entity( drawing_id, - [paper(point) for point in line], + _thinned([paper(point) for point in line]), CONTOUR_LAYER_ID, CONTOUR_COLOR, suffix=f":contour:{index}", @@ -308,7 +320,7 @@ def build_watershed_drawing( for index, line in enumerate(streams): stream = polyline_entity( drawing_id, - [paper(point) for point in line], + _thinned([paper(point) for point in line]), STREAM_LAYER_ID, STREAM_COLOR, suffix=f":stream:{index}", diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 7858a53f6..41c0b64e8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -11,6 +11,7 @@ from functools import lru_cache from pathlib import Path from typing import Any +import numpy as np from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE @@ -186,32 +187,50 @@ def _too_far_from_route( @lru_cache(maxsize=8) def _metric_lines_cached( path_str: str, mtime_ns: int, crs: str -) -> tuple[tuple[tuple[float, float], ...], ...]: +) -> tuple[tuple[np.ndarray, ...], np.ndarray]: """도엽 GeoJSON 한 벌을 사업지 좌표계(m) 선 목록으로 돌려 **캐시**한다. 같은 배경을 유역도와 계획평면도가 나눠 쓴다 — 도면마다 다시 읽고 다시 투영하면 한 장 여는 데 수 초가 걸린다(등고선 수만 점). 파일이 바뀌면 mtime 이 달라져 캐시가 저절로 갈린다(`_read_template` 와 같은 방식). + + 선은 (n, 2) 실수 배열로 쥔다 — 파이썬 튜플이면 등고선 한 벌이 수백 MB 를 붙든다. + 둘째 값은 선마다 (min_x, min_y, max_x, max_y) — 도곽 밖 선을 자르기 전에 거른다. """ to_metric = Transformer.from_crs("EPSG:4326", crs, always_xy=True) - lines: list[tuple[tuple[float, float], ...]] = [] + lines: list[np.ndarray] = [] for feature in _geojson_features(Path(path_str)): for part in _geometry_lines(feature.get("geometry")): - converted = tuple( - (float(x), float(y)) - for x, y in (to_metric.transform(point[0], point[1]) for point in part) - ) - if len(converted) >= 2: - lines.append(converted) - return tuple(lines) + if len(part) < 2: + continue + lonlat = np.array([(point[0], point[1]) for point in part], dtype=float) + x, y = to_metric.transform(lonlat[:, 0], lonlat[:, 1]) + lines.append(np.column_stack((x, y))) + bounds = np.array( + [(*line.min(axis=0), *line.max(axis=0)) for line in lines], dtype=float + ).reshape(-1, 4) + return tuple(lines), bounds -def _metric_lines(path: Path, crs: str) -> list[list[tuple[float, float]]]: - """캐시된 배경 선을 쓰기 좋은 형태로 낸다. 파일이 없으면 빈 목록.""" +def _metric_lines( + path: Path, crs: str, box: tuple[float, float, float, float] | None = None +) -> list[list[tuple[float, float]]]: + """캐시된 배경 선 중 box 에 걸치는 것만 낸다. 파일이 없으면 빈 목록.""" if not path.is_file(): return [] - cached = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs) - return [list(line) for line in cached] + lines, bounds = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs) + if box is None: + picked = range(len(lines)) + else: + min_x, min_y, max_x, max_y = box + hit = ( + (bounds[:, 2] >= min_x) + & (bounds[:, 0] <= max_x) + & (bounds[:, 3] >= min_y) + & (bounds[:, 1] <= max_y) + ) + picked = np.flatnonzero(hit) + return [list(map(tuple, lines[index].tolist())) for index in picked] def map_background( @@ -248,7 +267,7 @@ def map_background( background: dict[str, list[list[tuple[float, float]]]] = {} for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)): lines: list[list[tuple[float, float]]] = [] - for line in _metric_lines(sheet_dir / filename, crs): + for line in _metric_lines(sheet_dir / filename, crs, box): lines.extend(clip_line_to_box(line, box)) background[key] = lines return background @@ -360,7 +379,7 @@ def _clip_rings( ) -> list[list[tuple[float, float]]]: """행정구역 경계를 사업지 좌표계로 돌려 도곽 범위로 절취한다(속성은 안 씀).""" rings: list[list[tuple[float, float]]] = [] - for ring in _metric_lines(path, crs): + for ring in _metric_lines(path, crs, box): rings.extend(clip_line_to_box(ring, box)) return rings diff --git a/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts b/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts index a9b58a275..6b3effa5f 100644 --- a/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts @@ -6,7 +6,7 @@ import { Box, Point, Polygon, Segment } from '@flatten-js/core'; import type { Shape, SnapPoint } from '../App.types'; import { SnapPointType } from '../App.types'; import type { DrawController } from '../drawControllers/DrawController'; -import { hatchSpans } from '../helpers/geometry/hatch-lines'; +import { type Span, hatchSpans } from '../helpers/geometry/hatch-lines'; import { getExportColor } from '../helpers/get-export-color'; import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis'; import { scalePoint } from '../helpers/scale-point'; @@ -50,6 +50,8 @@ export class HatchEntity implements Entity { private points: Point[]; public options: HatchOptions; + /** 패턴 선분 — 줌·이동마다 다시 그리므로 경계·무늬가 그대로면 지난 계산을 쓴다. */ + private spanCache?: { key: string; points: Point[]; spans: Span[] }; constructor(layerId: string, points: Point[], options?: Partial) { this.layerId = layerId; @@ -77,14 +79,8 @@ export class HatchEntity implements Entity { this.drawGradient(drawController); } else { drawController.setLineStyles(highlighted, selected, this.options.color, this.lineWidth); - const angles = - this.options.style === 'cross' - ? [this.options.angle, this.options.angle + Math.PI / 2] - : [this.options.angle]; - for (const angle of angles) { - for (const [start, end] of hatchSpans(this.points, angle, this.options.spacing)) { - drawController.drawLine(start, end); - } + for (const [start, end] of this.patternSpans()) { + drawController.drawLine(start, end); } } @@ -101,6 +97,18 @@ export class HatchEntity implements Entity { } } + private patternSpans(): Span[] { + const { style, angle, spacing } = this.options; + const key = `${style}:${angle}:${spacing}`; + if (this.spanCache?.points === this.points && this.spanCache.key === key) { + return this.spanCache.spans; + } + const angles = style === 'cross' ? [angle, angle + Math.PI / 2] : [angle]; + const spans = angles.flatMap((value) => hatchSpans(this.points, value, spacing)); + this.spanCache = { key, points: this.points, spans }; + return spans; + } + /** 그라데이션 — 촘촘한 스캔선의 색을 조금씩 바꿔 표현한다 */ private drawGradient(drawController: DrawController): void { const box = this.getBoundingBox(); diff --git a/resources/tester/test_b07_basin_memory.py b/resources/tester/test_b07_basin_memory.py new file mode 100644 index 000000000..7ef3a5610 --- /dev/null +++ b/resources/tester/test_b07_basin_memory.py @@ -0,0 +1,37 @@ +"""B07 유역도 메모리 — 배경선 배열 캐시 · 도곽 밖 선 거름 · 종이 0.1 mm 솎기 (59-4).""" + +import json + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _thinned +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _metric_lines + + +def test_metric_lines_filters_by_box(tmp_path) -> None: + path = tmp_path / "lines.geojson" + inside = [[0.0, 0.0], [1.0, 1.0], [2.0, 0.5]] + outside = [[50.0, 50.0], [51.0, 51.0]] + path.write_text( + json.dumps( + { + "type": "FeatureCollection", + "features": [ + {"geometry": {"type": "LineString", "coordinates": inside}}, + {"geometry": {"type": "LineString", "coordinates": outside}}, + ], + } + ), + encoding="utf-8", + ) + every = _metric_lines(path, "EPSG:4326") + assert every == [[tuple(p) for p in inside], [tuple(p) for p in outside]] + assert _metric_lines(path, "EPSG:4326", (-1.0, -1.0, 5.0, 5.0)) == every[:1] + assert _metric_lines(tmp_path / "none.geojson", "EPSG:4326") == [] + + +def test_thinned_drops_sub_pen_wiggles() -> None: + points = [(x * 0.5, 0.02 if x % 2 else 0.0) for x in range(21)] + thinned = _thinned(points) + assert thinned[0] == points[0] and thinned[-1] == points[-1] + assert len(thinned) == 2 + bend = [(0.0, 0.0), (5.0, 1.0), (10.0, 0.0)] + assert _thinned(bend) == bend From 97940baa8e202a9258b280b0f1eefbe1ef39c28a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 17:59:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(B07):=20=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=EB=8F=84=20=ED=92=8D=EC=84=A0=20=EC=88=AB=EC=9E=90=20?= =?UTF-8?q?=ED=9D=B0=C2=B7=EA=B2=80=EC=9D=80=20=EB=B0=94=ED=83=95=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=20=EC=9D=BD=ED=9E=88=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원판 #ffffff(CAD 자동색 — 흰 바탕에서 검정으로 뒤집혀 #111111 숫자 묻힘) → #909090 회색(테마가 안 바꾸는 밝기) - 화면 픽셀 실측: 밝은 테마 대비 약 1.1:1 → 5.9:1 · 어두운 테마 5.9:1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B07_DesignDetail_Engine_Cad_Basin.py | 6 ++-- resources/tester/test_b07_basin_badge.py | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_b07_basin_badge.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index a9999693d..3ce69d183 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -76,7 +76,9 @@ _FONT_SIZE = 2.2 # 유역 번호는 해칭 위에 얹히므로 흰 원판을 깔고 그 안에 적는다(2026-08-30 사용자 지시). _NUMBER_FONT_SIZE = 4.5 _BADGE_RADIUS_MM = 3.6 -_BADGE_FILL = "#ffffff" +# 흰색(#ffffff)은 CAD 자동색이라 흰 바탕에서 검정으로 바뀌어 숫자가 묻혔다. 원판은 CAD 가 +# 밝기를 손대지 않는 회색(단순 휘도 0.58 이하)으로 둔다 — 흰·검은 바탕 모두 숫자 대비 5.9:1. +_BADGE_FILL = "#909090" _BADGE_TEXT_COLOR = "#111111" _BADGE_SEGMENTS = 24 _TITLE_FONT_SIZE = 7.0 @@ -181,7 +183,7 @@ def _hatch_entity( def _number_badge_entities( drawing_id: str, number: int, center: tuple[float, float], color: str ) -> list[dict[str, Any]]: - """유역 번호를 동그라미 배지로 그린다 — 흰 원판 + 유역색 테두리 + 검은 숫자. + """유역 번호를 동그라미 배지로 그린다 — 회색 원판 + 유역색 테두리 + 검은 숫자. 해칭 위에 그냥 문자만 올리면 무늬에 묻혀 읽히지 않는다. 원판은 solid 해치라 화면(어두운 배경)과 인쇄(흰 배경) 어느 쪽에서도 숫자가 남는다. diff --git a/resources/tester/test_b07_basin_badge.py b/resources/tester/test_b07_basin_badge.py new file mode 100644 index 000000000..23e7deac3 --- /dev/null +++ b/resources/tester/test_b07_basin_badge.py @@ -0,0 +1,31 @@ +"""B07 유역도 풍선 — 흰·검은 바탕 어느 쪽에서도 숫자가 읽히는 색 (59-5). + +CAD 그리기 규칙(openwebcad `theme.ts` paintColor): 자동색(#000000·#ffffff·#f5f7fa)은 +바탕 따라 흑백이 뒤집히고, 흰 바탕에서는 단순 휘도 0.58 초과 색을 어둡게 누른다. +""" + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _BADGE_FILL, _BADGE_TEXT_COLOR + +_AUTO = {"#000000", "#ffffff", "#f5f7fa"} + + +def _rgb(color: str) -> tuple[int, int, int]: + return tuple(int(color[index : index + 2], 16) for index in (1, 3, 5)) + + +def _wcag(color: str) -> float: + def channel(value: int) -> float: + value /= 255 + return value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4 + + r, g, b = (channel(v) for v in _rgb(color)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def test_badge_colors_survive_both_themes() -> None: + for color in (_BADGE_FILL, _BADGE_TEXT_COLOR): + assert color.lower() not in _AUTO + r, g, b = _rgb(color) + assert (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 <= 0.58 + fill, text = _wcag(_BADGE_FILL), _wcag(_BADGE_TEXT_COLOR) + assert (max(fill, text) + 0.05) / (min(fill, text) + 0.05) >= 4.5 From d4cbe6bdf3825824d1961f5e2e19a414a0ec90d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 18:06:29 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(B07):=20=EC=9A=A9=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=ED=95=84=EC=A7=80=EB=A7=88=EB=8B=A4=20=EC=A7=80=EB=B2=88=C2=B7?= =?UTF-8?q?=EC=A7=80=EB=AA=A9=C2=B7=EB=A9=B4=EC=A0=81=C2=B7=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EC=9E=90=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 연속지적도 속성(jibun·jimok·parea·owner_nm) 네 줄 · 빈 값은 그 줄만 비움 - 도곽에 잘려 조각이 여럿인 필지도 정보는 한 번만(가장 큰 조각 가운데) · 작은 필지 솎기 없앰 - 검증_sub4 용지도: 구역 안 필지 1 = 라벨 묶음 1(산77-15임 / 임야 / 10,640,598㎡ / 국유지) - 같은 지적도 전체(176필지 · 조각 198)를 한 구역으로: 필지 176 = 라벨 묶음 176 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B07_DesignDetail_Engine_Cad_Landuse.py | 93 +++++++++++-------- .../tester/test_b07_landuse_parcel_info.py | 37 ++++++++ 2 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 resources/tester/test_b07_landuse_parcel_info.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py index 1949a66fc..16c5c57fc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py @@ -5,8 +5,8 @@ 넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」. 지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`· -`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 이번 판은 **지번만** 적는다 — -지목·면적·소유 구분은 용지 조서(표)에서 쓸 값이라 도면에는 넣지 않는다. +`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 필지마다 **지번·지목·면적·소유자** +네 줄을 적는다(사용자: 「연속지적도에 해당 정보를 띄우고 판단」) — 빈 값은 그 줄만 비운다. 축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다. @@ -65,10 +65,8 @@ _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 -# 지번을 적을 최소 필지 크기(종이 mm) — 이보다 작으면 글자가 겹쳐 읽히지 않는다. -_JIBUN_MIN_W_MM = 6.0 -_JIBUN_MIN_H_MM = 3.0 _LEGEND_ROW_H = 6.0 _LEGEND_SAMPLE_W = 12.0 @@ -92,6 +90,25 @@ def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]: 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 _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]: """범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다.""" entities: list[dict[str, Any]] = [] @@ -197,48 +214,48 @@ def build_landuse_drawing( drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour" ) ) - # 필지 경계 + 지번. - jibun: list[dict[str, Any]] = [] + # 필지 경계 + 필지 정보. 도곽에 잘려 조각이 여럿인 필지도 정보는 한 번만 — + # 가장 큰 조각 가운데에 적는다(구역 안 필지 수 = 정보 묶음 수). + spots: dict[str, tuple[float, tuple[float, float], dict[str, Any]]] = {} 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(point) for point in ring], - PARCEL_LAYER_ID, - PARCEL_COLOR, - suffix=f":parcel:{index}", + drawing_id, paper_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: + size, center = width * height, _ring_center(paper_ring) + elif label_at is not None: + # 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 정보만 적는다. + size, center = 0.0, paper(tuple(label_at)) + else: continue - jibun.append( - _text_entity( - f"{drawing_id}:jibun:{index}", - text, - center[0], - center[1], - JIBUN_LAYER_ID, - _JIBUN_FONT_SIZE, - JIBUN_COLOR, - ) - ) + key = str(props.get("pnu") or f"#{index}") + if key not in spots or size > spots[key][0]: + spots[key] = (size, center, props) + jibun: list[dict[str, Any]] = [] + for key, (_size, center, props) in spots.items(): + lines = _parcel_info_lines(props) + 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( @@ -267,7 +284,7 @@ def build_landuse_drawing( ) if route: entities.append(route) - entities.extend(jibun) # 지번은 가장 위 — 선에 가리면 못 읽는다. + entities.extend(jibun) # 필지 정보는 가장 위 — 선에 가리면 못 읽는다. # 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리). if map_bbox: @@ -323,7 +340,7 @@ def build_landuse_drawing( "layers": [ _layer(CONTOUR_LAYER_ID, "등고선", locked=True), _layer(PARCEL_LAYER_ID, "필지 경계"), - _layer(JIBUN_LAYER_ID, "지번"), + _layer(JIBUN_LAYER_ID, "필지 정보(지번·지목·면적·소유자)"), _layer(EMD_LAYER_ID, "읍면동·리 경계"), _layer(SGG_LAYER_ID, "시군구 경계"), _layer(ROUTE_LAYER_ID, "계획노선"), diff --git a/resources/tester/test_b07_landuse_parcel_info.py b/resources/tester/test_b07_landuse_parcel_info.py new file mode 100644 index 000000000..55c81ec90 --- /dev/null +++ b/resources/tester/test_b07_landuse_parcel_info.py @@ -0,0 +1,37 @@ +"""B07 용지도 — 필지마다 지번·지목·면적·소유자 한 묶음 · 빈 값은 빈 줄 (59-6).""" + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( + JIBUN_LAYER_ID, + MM, + build_landuse_drawing, +) + + +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)] + + +def test_one_label_group_per_parcel() -> 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임" + } + assert split_x.pop() == pytest.approx(215.0 * MM)