From f05d77d642b78972d5e9bfe257ba7a30e4750d5f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:10:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84=20?= =?UTF-8?q?=EC=B6=95=EC=B2=99=20=EC=97=AC=EB=8D=9F=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=83=81=EC=88=98=20=EC=B6=95=EC=B2=99?= =?UTF-8?q?=ED=99=94=20=C2=B7=20=EB=A7=89=ED=9E=8C=20=EA=B8=80=20=EB=A8=BC?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC(141-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 축척 = 1/500 · 600 · 1,000 · 1,200(기본) · 2,500 · 3,000 · 5,000 · 6,000(지식DB 근거) - 비탈 눈금 간격 · 대피소 띄움 · 필지 버퍼 · 틈 = 1/1,200 실거리 × 축척 비(종이 거리 같게 · 1/6,000 눈금 10 m) - 글 후보가 다 막히면 기호에서 2 · 3 배 먼 자리 + 지시선 — 검증_sub8 1/6,000 겹친 쌍 55 → 4 - 시험 test_119 · test_141 늘림 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B07_DesignDetail_Engine_Cad_PlanLabels.py | 21 +++++++++++++--- .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 14 +++++++++-- ...B07_DesignDetail_Router_Support_Landuse.py | 11 ++++++--- .../B07_DesignDetail_Router_Support_Plan.py | 22 +++++++++++------ config/config_system.py | 6 +++-- resources/tester/test_119_b07_plan_scale.py | 24 +++++++++++++++++-- resources/tester/test_141_b07_plan_labels.py | 15 ++++++++++++ 7 files changed, 94 insertions(+), 19 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py index 2caf7bb57..53b368cf5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanLabels.py @@ -15,6 +15,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _text_entity, polyline_ PRIORITY = ("endpoints", "station", "ip", "radius", "drainage", "refuge") _GAP_MM = 0.6 # 글 사이 틈(종이 mm) +_PUSH = (2.0, 3.0) # 먼 고리 — 후보 거리의 배수 Rect = tuple[float, float, float, float] @@ -70,8 +71,9 @@ def place_labels(labels: list[dict[str, Any]], obstacles: list[Rect]) -> list[di placed = [] rank = {piece: index for index, piece in enumerate(PRIORITY)} for item in sorted(labels, key=lambda label: rank.get(label["piece"], len(PRIORITY))): - rects = [_rect(item, spot) for spot in item["candidates"]] - # ponytail: 후보가 모두 막히면 첫 후보 그대로(겹침 남음) — 더 필요하면 후보 고리를 늘림 + spots = [*item["candidates"], *_pushed(item)] + rects = [_rect(item, spot) for spot in spots] + # ponytail: 먼 고리까지 모두 막히면 첫 후보 그대로(겹침 남음 · 작은 축척에 글이 몰린 장) index = next( ( i @@ -81,10 +83,23 @@ def place_labels(labels: list[dict[str, Any]], obstacles: list[Rect]) -> list[di 0, ) taken.append(rects[index]) - placed.append({**item, "spot": item["candidates"][index], "rect": rects[index]}) + placed.append({**item, "spot": spots[index], "rect": rects[index]}) return placed +def _pushed(item: dict[str, Any]) -> list[dict[str, Any]]: + """후보가 다 막혔을 때 — 기호에서 후보 쪽으로 2 · 3 배 먼 자리(지시선 · 141-2 작은 축척).""" + anchor = item.get("anchor") + if not anchor: + return [] + ax, ay = anchor + return [ + {**spot, "x": ax + (spot["x"] - ax) * k, "y": ay + (spot["y"] - ay) * k, "far": True} + for k in _PUSH + for spot in item["candidates"] + ] + + def _nearest_on_rect(point: tuple[float, float], rect: Rect) -> tuple[float, float]: return (min(max(point[0], rect[0]), rect[2]), min(max(point[1], rect[1]), rect[3])) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index 279372493..3ef15e745 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -7,7 +7,16 @@ import math from bisect import bisect_left from typing import Any -TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 +from config.config_system import DRAWING_SCALE_PLAN + +#: 비탈 눈금 간격(1/1,200 실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 · 다른 축척은 종이 간격이 +#: 같게(1/6,000 = 10 m · 141-2) +TICK_STEP_M = 2.0 + + +def tick_step_m(scale: int = DRAWING_SCALE_PLAN) -> float: + """비탈 눈금 간격(실거리 m) — 종이 간격(1/1,200 의 2 m) × 축척.""" + return TICK_STEP_M * scale / DRAWING_SCALE_PLAN # ── 노선 따라가기 ───────────────────────────────────── @@ -112,6 +121,7 @@ def slope_pieces( records: list[dict[str, Any]], start_m: float, end_m: float, + step_m: float = TICK_STEP_M, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. @@ -142,7 +152,7 @@ def slope_pieces( step = start_m while step <= end_m: samples.add(round(step, 3)) - step += TICK_STEP_M + step += step_m lines: dict[str, list[list[float]]] = {key: [] for key in keys} cut: dict[str, list[bool]] = {"left": [], "right": []} ticks: list[list[list[float]]] = [] diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py index 16463c373..0811853e7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py @@ -31,6 +31,7 @@ PARCEL_FILE = "연속지적도_bounds.geojson" EMD_FILE = "행정구역_읍면동_bounds.geojson" SGG_FILE = "행정구역_시군구_bounds.geojson" +# 실거리는 1/1,200 값 — 다른 축척은 종이 거리가 같게 늘이고 줄임(`mm` · 141-2) ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m) _GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m) _RINGS = 5 # 후보 자리 고리 수 @@ -131,7 +132,11 @@ def place_labels( mm: float = MM, ) -> list[dict[str, Any]]: """필지마다 라벨 [{lines, anchor, center}] — 큰 필지부터 자리를 잡는다.""" - clear = LineString(route_xy).buffer(ROUTE_CLEAR_M) if len(route_xy) >= 2 else Polygon() + stretch = MM / mm # 1/1,200 = 1 + gap = _GAP_M * stretch + clear = ( + LineString(route_xy).buffer(ROUTE_CLEAR_M * stretch) 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]] = [] @@ -149,8 +154,8 @@ def place_labels( 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 + cx = ax + sx * (half_w + gap) * ring + cy = ay + sy * (half_h + gap) * ring rect = (cx - half_w, cy - half_h, cx + half_w, cy + half_h) inside = ( rect[0] >= box[0] diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 97410d0a3..ef3be073d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -42,6 +42,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( slope_end, slope_pieces, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import tick_step_m from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _chunk_route, _hillshade_for_box, @@ -77,7 +78,7 @@ _NAME_MAX = 40 WATERSHED_LAYOUT = "watershed" _LAYOUTS = ("plan", WATERSHED_LAYOUT) _REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"} -_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측) +_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 기호를 중심선에서 띄우는 거리(좌측 · 1/1,200 실거리) def plan_title(name: str) -> str: @@ -391,9 +392,16 @@ def slope_records( def _refuge_items( - project_root: Path, route: list[tuple[float, float, float]], start_m: float, end_m: float + project_root: Path, + route: list[tuple[float, float, float]], + start_m: float, + end_m: float, + scale: int = DRAWING_SCALE_PLAN, ) -> dict[str, Any]: - """대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능.""" + """대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능. + + 띄움은 종이 거리가 같게 축척 따라(141-2).""" + offset = _REFUGE_OFFSET_M * scale / DRAWING_SCALE_PLAN spots: dict[float, set[str]] = {} _revision, structures = load_structures(str(project_root)) for structure in structures: @@ -420,8 +428,8 @@ def _refuge_items( { "chainage_m": chainage, "label": " · ".join(name for name in ("대피소", "사토장") if name in labels), - "x": x + nx * _REFUGE_OFFSET_M, - "y": y + ny * _REFUGE_OFFSET_M, + "x": x + nx * offset, + "y": y + ny * offset, } ) return {"items": items} @@ -464,7 +472,7 @@ def build_pieces( pieces["fill_outline"], pieces["cut_outline"], pieces["slope_ticks"], - ) = slope_pieces(route, records, start_m, end_m) + ) = slope_pieces(route, records, start_m, end_m, tick_step_m(scale)) if {"ip", "radius"} & set(wanted): pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box) if "station" in wanted: @@ -502,7 +510,7 @@ def build_pieces( items.append({"label": label, "x": x, "y": y, "nx": nx, "ny": ny}) pieces["endpoints"] = {"items": items} if "refuge" in wanted: - pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m) + pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m, scale) if "drainage" in wanted: min_x, min_y, max_x, max_y = box pieces["drainage"] = { diff --git a/config/config_system.py b/config/config_system.py index 24de90716..ed3852578 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -185,8 +185,10 @@ DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/ # 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200. # 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**. DRAWING_SCALE_PLAN = 1200 -# 평면도 종류마다 고를 수 있는 축척 분모 — 1/1,200 기본(법정) · 1/1,000 · 1/600(용지도). -DRAWING_SCALE_PLAN_CHOICES = (600, 1000, 1200) +# 평면도 종류마다 고를 수 있는 축척 분모 — 1/1,200 기본(법정) · 지식DB 근거 있는 것만(141-2): +# 1/500 · 1/2,500(측량 품셈) · 1/600(용지도) · 1/1,000(임도 설계) · 1/3,000 · 1/6,000(측량원도) · +# 1/5,000(지형도) +DRAWING_SCALE_PLAN_CHOICES = (500, 600, 1000, 1200, 2500, 3000, 5000, 6000) # 표준 횡단면도 — 상세도라 지식DB에 지정 축척이 없다. 본 그림 1/50, 측구 부분확대도 # 1/10 (2026-09-04). 노폭 4 m 기준 본 그림이 A1 작도영역에 여유 있게 든다. DRAWING_SCALE_CROSS_STANDARD = 50 diff --git a/resources/tester/test_119_b07_plan_scale.py b/resources/tester/test_119_b07_plan_scale.py index 9e448c718..c8a6fd096 100644 --- a/resources/tester/test_119_b07_plan_scale.py +++ b/resources/tester/test_119_b07_plan_scale.py @@ -38,7 +38,9 @@ _STATIONS = [ def test_scale_choices_add_update_and_old_files(tmp_path: Path) -> None: root = tmp_path / "project" state = _state(root) - assert state["scales"] == [600, 1000, 1200] and state["default_scale"] == 1200 + # 141-2 — 지식DB 근거 있는 여덟 + assert state["scales"] == [500, 600, 1000, 1200, 2500, 3000, 5000, 6000] + assert state["default_scale"] == 1200 assert all(k["scale"] == (6000 if k["layout"] == "watershed" else 1200) for k in state["kinds"]) added = add_kind(root, "용지 600", ["contour"], "plan", 600) assert added["scale"] == 600 @@ -49,7 +51,7 @@ def test_scale_choices_add_update_and_old_files(tmp_path: Path) -> None: update_kind(root, added["id"], "용지 1000", ["contour"]) # 축척 없이 = 지금 값 assert next(k for k in list_kinds(root) if k["id"] == added["id"])["scale"] == 1000 with pytest.raises(ValueError): - add_kind(root, "이상한 축척", ["contour"], "plan", 500) + add_kind(root, "이상한 축척", ["contour"], "plan", 700) with pytest.raises(ValueError): update_kind(root, added["id"], "용지 1000", ["contour"], 777) watershed = add_kind(root, "유역 둘", [], "watershed", 600) # 유역도 양식은 고정 @@ -171,3 +173,21 @@ def test_ip_table_clip_and_parcel_label_follow_scale() -> None: (half,) = label_rects(items, 1000.0 / 600) assert half[2] - half[0] == pytest.approx((big[2] - big[0]) / 2) assert half[3] - half[1] == pytest.approx((big[3] - big[1]) / 2) + + +def test_실거리_상수는_종이_거리가_같게_141_2() -> None: + """비탈 눈금 · 대피소 띄움 · 필지 버퍼 = 1/1,200 실거리 × 축척 비 — 1/6,000 눈금 간격 10 m.""" + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import slope_pieces, tick_step_m + + assert tick_step_m(1200) == 2.0 and tick_step_m(6000) == 10.0 and tick_step_m(500) < 1.0 + route = [(0.0, 0.0, 0.0), (200.0, 200.0, 0.0)] + records = [ + {"chainage_m": c, "left_edge_m": 2.0, "left_end_m": 6.0, "right_edge_m": -2.0, + "right_end_m": -6.0} + for c in (0.0, 200.0) + ] # fmt: skip + for scale in (1200, 6000): + ticks = slope_pieces(route, records, 0.0, 200.0, tick_step_m(scale))[3]["ticks"] + left = sorted(t[0][0] for t in ticks if t[0][1] > 0) + gaps_mm = [(b - a) * 1000.0 / scale for a, b in zip(left, left[1:])] + assert min(gaps_mm) == pytest.approx(2000.0 / 1200.0) # 1.67 mm — 검은 띠 없음 diff --git a/resources/tester/test_141_b07_plan_labels.py b/resources/tester/test_141_b07_plan_labels.py index 47c52df3b..6e9c426f1 100644 --- a/resources/tester/test_141_b07_plan_labels.py +++ b/resources/tester/test_141_b07_plan_labels.py @@ -137,3 +137,18 @@ def test_far_spot_draws_leader() -> None: placed = place_labels([_label("ip", "IP1", near, (0.0, 0.0))], [(-3.0, -3.0, 3.0, 3.0)]) entities = label_entities("plan_x", placed) assert [e["type"] for e in entities] == ["Text", "PolyLine"] + + +def test_blocked_spots_push_farther_with_leader() -> None: + """141-2 — 후보가 다 막히면 기호에서 2 · 3 배 먼 자리(지시선) · 작은 축척에 몰린 장.""" + anchor = (0.0, 0.0) + spot = [{"x": 0.0, "y": -4.0}] + placed = place_labels( + [_label("ip", "IP1", spot, anchor), _label("radius", "R=12", spot, anchor)], [] + ) + assert placed[0]["spot"] == spot[0] + assert placed[1]["spot"] == {"x": 0.0, "y": -8.0, "far": True} + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities + + entities = label_entities("d", placed) + assert [e["type"] for e in entities] == ["Text", "Text", "PolyLine"]