feat(B07): 평면도 축척 여덟 · 실거리 상수 축척화 · 막힌 글 먼 자리(141-2)

- 축척 = 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-09 15:10:02 +09:00
co-authored by Claude Opus 5.5
parent d26b991fb3
commit f05d77d642
7 changed files with 94 additions and 19 deletions
@@ -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]))
@@ -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]]] = []
@@ -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]
@@ -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"] = {
+4 -2
View File
@@ -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
+22 -2
View File
@@ -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 — 검은 띠 없음
@@ -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"]