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:
@@ -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"] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user