Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_4
This commit is contained in:
@@ -10,7 +10,6 @@
|
||||
"""
|
||||
|
||||
import math
|
||||
from bisect import bisect_left
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
@@ -22,7 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
polyline_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities, balloon_size
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_COMPASS_MARGIN,
|
||||
_COMPASS_SIZE,
|
||||
@@ -36,6 +35,20 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
STREAM_COLOR,
|
||||
STRUCTURE_COLOR,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import (
|
||||
label_entities,
|
||||
place_labels,
|
||||
text_width,
|
||||
)
|
||||
|
||||
# 비탈 · 노선 따라가기 기하는 700줄을 넘어 떼어냄 — 호출부 import 경로는 그대로.
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: F401
|
||||
TICK_STEP_M,
|
||||
route_frame,
|
||||
side_is_cut,
|
||||
slope_end,
|
||||
slope_pieces,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
entities_bbox,
|
||||
@@ -130,7 +143,6 @@ _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = {
|
||||
"sgg": (3, [14, 5, 3, 5]),
|
||||
}
|
||||
PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값
|
||||
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
|
||||
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
|
||||
_LABEL_FONT = 2.5
|
||||
_MAJOR_FONT = 4.5
|
||||
@@ -142,178 +154,6 @@ def layer_id(piece: str) -> str:
|
||||
return f"b07-plan-{piece}"
|
||||
|
||||
|
||||
# ── 노선 따라가기 ─────────────────────────────────────
|
||||
|
||||
|
||||
def route_frame(
|
||||
route: list[tuple[float, float, float]], chainage: float
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny)."""
|
||||
chainages = [item[0] for item in route]
|
||||
index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1)
|
||||
(c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index]
|
||||
dx, dy = x1 - x0, y1 - y0
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0
|
||||
return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length)
|
||||
|
||||
|
||||
def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float:
|
||||
chainages = [record["chainage_m"] for record in records]
|
||||
index = bisect_left(chainages, chainage)
|
||||
if index <= 0:
|
||||
return records[0][key]
|
||||
if index >= len(records):
|
||||
return records[-1][key]
|
||||
before, after = records[index - 1], records[index]
|
||||
span = after["chainage_m"] - before["chainage_m"]
|
||||
ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0
|
||||
return before[key] + (after[key] - before[key]) * ratio
|
||||
|
||||
|
||||
def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
|
||||
return sorted(
|
||||
(float(item["offset_m"]), float(item["elevation_m"]))
|
||||
for item in samples
|
||||
if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float))
|
||||
)
|
||||
|
||||
|
||||
def _ground_at(ground: list[tuple[float, float]], offset: float) -> float:
|
||||
offsets = [x for x, _z in ground]
|
||||
index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1)
|
||||
(x0, z0), (x1, z1) = ground[index - 1], ground[index]
|
||||
return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0)
|
||||
|
||||
|
||||
def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool:
|
||||
"""노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토)."""
|
||||
ground = _ground(samples)
|
||||
return len(ground) >= 2 and _ground_at(ground, edge) > elevation
|
||||
|
||||
|
||||
def slope_end(
|
||||
design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str
|
||||
) -> float:
|
||||
"""노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝.
|
||||
|
||||
설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다.
|
||||
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
|
||||
"""
|
||||
ground = _ground(samples)
|
||||
points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line)
|
||||
outward = (
|
||||
[p for p in points if p[0] > edge]
|
||||
if side == "left"
|
||||
else [p for p in points[::-1] if p[0] < edge]
|
||||
)
|
||||
if not ground:
|
||||
return outward[-1][0] if outward else edge
|
||||
end, differs = edge, False
|
||||
for offset, elevation in outward:
|
||||
if abs(elevation - _ground_at(ground, offset)) <= 1e-3:
|
||||
return offset if differs else end
|
||||
differs, end = True, offset
|
||||
return end
|
||||
|
||||
|
||||
def _split_runs(
|
||||
points: list[list[float]], flags: list[bool]
|
||||
) -> tuple[list[list[list[float]]], list[list[list[float]]]]:
|
||||
"""한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다."""
|
||||
runs: dict[bool, list[list[list[float]]]] = {True: [], False: []}
|
||||
current: list[list[float]] = []
|
||||
previous: bool | None = None
|
||||
for point, flag in zip(points, flags):
|
||||
if flag != previous:
|
||||
if current:
|
||||
current.append(point)
|
||||
current = [point]
|
||||
runs[flag].append(current)
|
||||
previous = flag
|
||||
else:
|
||||
current.append(point)
|
||||
return (
|
||||
[run for run in runs[True] if len(run) >= 2],
|
||||
[run for run in runs[False] if len(run) >= 2],
|
||||
)
|
||||
|
||||
|
||||
def slope_pieces(
|
||||
route: list[tuple[float, float, float]],
|
||||
records: list[dict[str, Any]],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
"""측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다.
|
||||
|
||||
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
|
||||
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
|
||||
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을
|
||||
따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||||
"""
|
||||
records = sorted(
|
||||
(
|
||||
{
|
||||
**record,
|
||||
**{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")},
|
||||
}
|
||||
for record in records
|
||||
),
|
||||
key=lambda record: record["chainage_m"],
|
||||
)
|
||||
if len(route) < 2 or not records:
|
||||
return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []}
|
||||
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
|
||||
by_chainage = {round(record["chainage_m"], 3): record for record in records}
|
||||
samples = {
|
||||
round(record["chainage_m"], 3)
|
||||
for record in records
|
||||
if start_m <= record["chainage_m"] <= end_m
|
||||
}
|
||||
step = start_m
|
||||
while step <= end_m:
|
||||
samples.add(round(step, 3))
|
||||
step += TICK_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]]] = []
|
||||
for index, chainage in enumerate(sorted(samples)):
|
||||
record = by_chainage.get(chainage)
|
||||
if record and all(k in record for k in ("x", "y", "nx", "ny")):
|
||||
x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"]
|
||||
else:
|
||||
x, y, nx, ny = route_frame(route, chainage)
|
||||
offsets = {key: _interp(records, chainage, key) for key in keys}
|
||||
points = {
|
||||
key: [round(x + nx * value, 4), round(y + ny * value, 4)]
|
||||
for key, value in offsets.items()
|
||||
}
|
||||
for key in keys:
|
||||
lines[key].append(points[key])
|
||||
for side in cut:
|
||||
cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5)
|
||||
# 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례).
|
||||
for side in ("left", "right"):
|
||||
edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"]
|
||||
if abs(end - edge) < 0.05:
|
||||
continue
|
||||
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
|
||||
ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]])
|
||||
cut_lines: list[list[list[float]]] = []
|
||||
fill_lines: list[list[list[float]]] = []
|
||||
for side in cut:
|
||||
cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side])
|
||||
cut_lines += cut_runs
|
||||
fill_lines += fill_runs
|
||||
return (
|
||||
{"lines": [lines["left_edge_m"], lines["right_edge_m"]]},
|
||||
{"lines": fill_lines},
|
||||
{"lines": cut_lines},
|
||||
{"ticks": ticks},
|
||||
)
|
||||
|
||||
|
||||
def curve_pieces(
|
||||
curves: list[dict[str, Any]],
|
||||
route: list[tuple[float, float, float]],
|
||||
@@ -338,7 +178,7 @@ def curve_pieces(
|
||||
"end": list(end[:2]),
|
||||
}
|
||||
)
|
||||
# R 라벨은 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽)으로 조금 띄운다.
|
||||
# R 라벨 후보는 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽) · 바깥으로(종이 mm · 141-1).
|
||||
middle = min(
|
||||
((x, y) for _c, x, y in route),
|
||||
key=lambda point: math.hypot(point[0] - apex[0], point[1] - apex[1]),
|
||||
@@ -349,7 +189,8 @@ def curve_pieces(
|
||||
radii.append(
|
||||
{
|
||||
"label": f"R={round(float(radius), 1):g}",
|
||||
"at": [middle[0] + dx / length * 4.0, middle[1] + dy / length * 4.0],
|
||||
"middle": list(middle),
|
||||
"dir": [dx / length, dy / length],
|
||||
}
|
||||
)
|
||||
return {"items": ips}, {"items": radii}
|
||||
@@ -371,11 +212,30 @@ def _reading(nx: float, ny: float) -> tuple[float, float]:
|
||||
return (tx, ty) if tx > 0 or (abs(tx) < 1e-9 and ty > 0) else (-tx, -ty)
|
||||
|
||||
|
||||
def _side_spots(cx: float, cy: float, size: float) -> list[dict[str, Any]]:
|
||||
"""기호 곁 글 후보 — 오른쪽(옛 자리) → 왼 · 위 · 아래 → 멀리 오른 · 왼(지시선)."""
|
||||
return [
|
||||
{"x": cx + size + 1.0, "y": cy, "align": "left"},
|
||||
{"x": cx - size - 1.0, "y": cy, "align": "right"},
|
||||
{"x": cx, "y": cy + size + 2.5},
|
||||
{"x": cx, "y": cy - size - 2.5},
|
||||
{"x": cx + size + 8.0, "y": cy + 4.0, "align": "left", "far": True},
|
||||
{"x": cx - size - 8.0, "y": cy - 4.0, "align": "right", "far": True},
|
||||
]
|
||||
|
||||
|
||||
def _piece_entities(
|
||||
drawing_id: str, piece: str, data: dict[str, Any], paper: Any, mm: float = MM
|
||||
drawing_id: str,
|
||||
piece: str,
|
||||
data: dict[str, Any],
|
||||
paper: Any,
|
||||
mm: float = MM,
|
||||
labels: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""조각의 선 · 기호 엔티티. 글은 후보 자리째 `labels` 에 모음(자리는 `place_labels` · 141-1)."""
|
||||
layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR)
|
||||
out: list[dict[str, Any]] = []
|
||||
labels = labels if labels is not None else []
|
||||
|
||||
def line(points: Any, suffix: str, width: int = 1, dash: list[int] | None = None) -> None:
|
||||
entity = polyline_entity(
|
||||
@@ -390,11 +250,24 @@ def _piece_entities(
|
||||
if entity:
|
||||
out.append(entity)
|
||||
|
||||
def text(seed: str, label: str, at: tuple[float, float], size: float, **kw: Any) -> None:
|
||||
out.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:{piece}:{seed}", label, at[0], at[1], layer, size, color, **kw
|
||||
)
|
||||
def text(
|
||||
seed: str,
|
||||
label: str,
|
||||
size: float,
|
||||
spots: list[dict[str, Any]],
|
||||
anchor: tuple[float, float] | None = None,
|
||||
) -> None:
|
||||
labels.append(
|
||||
{
|
||||
"piece": piece,
|
||||
"seed": f"{drawing_id}:{piece}:{seed}",
|
||||
"label": label,
|
||||
"size": size,
|
||||
"layer": layer,
|
||||
"color": color,
|
||||
"anchor": anchor,
|
||||
"candidates": spots,
|
||||
}
|
||||
)
|
||||
|
||||
if piece == "lidar":
|
||||
@@ -434,10 +307,39 @@ def _piece_entities(
|
||||
)
|
||||
if out_circle:
|
||||
out.append(out_circle)
|
||||
text(f"{index}", item["name"], (apex[0], apex[1] - _CIRCLE_MM - 3.0), _LABEL_FONT)
|
||||
ax, ay, reach = apex[0], apex[1], _CIRCLE_MM + 3.0
|
||||
text(
|
||||
f"{index}",
|
||||
item["name"],
|
||||
_LABEL_FONT,
|
||||
[
|
||||
{"x": ax, "y": ay - reach},
|
||||
{"x": ax, "y": ay + reach},
|
||||
{"x": ax - _CIRCLE_MM - 1.0, "y": ay, "align": "right"},
|
||||
{"x": ax + _CIRCLE_MM + 1.0, "y": ay, "align": "left"},
|
||||
{"x": ax, "y": ay - reach - 5.0, "far": True},
|
||||
{"x": ax, "y": ay + reach + 5.0, "far": True},
|
||||
],
|
||||
apex,
|
||||
)
|
||||
elif piece == "radius":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT)
|
||||
if "middle" not in item: # 옛 조각 파일 — 자리 하나
|
||||
at = paper(item["at"])
|
||||
text(f"{index}", item["label"], _LABEL_FONT, [{"x": at[0], "y": at[1]}])
|
||||
continue
|
||||
mx, my = paper(item["middle"])
|
||||
dx, dy = item["dir"]
|
||||
text(
|
||||
f"{index}",
|
||||
item["label"],
|
||||
_LABEL_FONT,
|
||||
[
|
||||
{"x": mx + dx * d, "y": my + dy * d, "far": abs(d) > 6.0}
|
||||
for d in (3.0, 6.0, -3.0, -6.0, 10.0, -10.0)
|
||||
],
|
||||
(mx, my),
|
||||
)
|
||||
elif piece == "ip_table":
|
||||
out.extend(ip_table_entities(drawing_id, data))
|
||||
elif piece == "station":
|
||||
@@ -457,13 +359,25 @@ def _piece_entities(
|
||||
)
|
||||
if circle:
|
||||
out.append(circle)
|
||||
gap = _MAJOR_FONT * 1.8
|
||||
gap, (tx, ty) = _MAJOR_FONT * 1.8, _reading(nx, ny)
|
||||
shift = text_width(item["label"], _MAJOR_FONT) / 2.0 + 2.0
|
||||
text(
|
||||
f"{index}",
|
||||
item["label"],
|
||||
(cx + nx * gap, cy + ny * gap),
|
||||
_MAJOR_FONT,
|
||||
direction=_reading(nx, ny),
|
||||
[
|
||||
{
|
||||
"x": cx + nx * g + tx * t,
|
||||
"y": cy + ny * g + ty * t,
|
||||
"direction": (tx, ty),
|
||||
}
|
||||
for g, t in ((gap, 0.0), (-gap, 0.0), (gap, shift), (gap, -shift))
|
||||
]
|
||||
+ [
|
||||
{"x": cx + nx * g, "y": cy + ny * g, "direction": (tx, ty), "far": True}
|
||||
for g in (gap * 1.8, -gap * 1.8)
|
||||
],
|
||||
(cx, cy),
|
||||
)
|
||||
elif piece == "endpoints":
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
@@ -478,7 +392,16 @@ def _piece_entities(
|
||||
width=2,
|
||||
)
|
||||
cx, cy = paper((item["x"], item["y"]))
|
||||
text(f"{index}", item["label"], (cx + nx * 9.0, cy + ny * 9.0), _LABEL_FONT + 0.5)
|
||||
text(
|
||||
f"{index}",
|
||||
item["label"],
|
||||
_LABEL_FONT + 0.5,
|
||||
[
|
||||
{"x": cx + nx * g, "y": cy + ny * g, "far": abs(g) > 9.0}
|
||||
for g in (9.0, -9.0, 15.0, -15.0)
|
||||
],
|
||||
(cx, cy),
|
||||
)
|
||||
elif piece == "refuge":
|
||||
size = 2.0 # 네모 기호 반크기(종이 mm)
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
@@ -486,7 +409,7 @@ def _piece_entities(
|
||||
corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)]
|
||||
line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}")
|
||||
cx, cy = paper((x, y))
|
||||
text(f"{index}", item["label"], (cx + size + 1.0, cy), _LABEL_FONT, align="left")
|
||||
text(f"{index}", item["label"], _LABEL_FONT, _side_spots(cx, cy, size), (cx, cy))
|
||||
elif piece == "drainage":
|
||||
size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm)
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
@@ -502,7 +425,7 @@ def _piece_entities(
|
||||
if diamond:
|
||||
out.append(diamond)
|
||||
text(
|
||||
f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left"
|
||||
f"{index}", item["label"], _STRUCTURE_FONT_SIZE, _side_spots(cx, cy, size), (cx, cy)
|
||||
)
|
||||
elif piece in _BOUNDARY_STYLE:
|
||||
width, dash = _BOUNDARY_STYLE[piece]
|
||||
@@ -526,6 +449,12 @@ def _piece_entities(
|
||||
return out
|
||||
|
||||
|
||||
def _table_rect(entity: dict[str, Any]) -> tuple[float, float, float, float]:
|
||||
shape = entity["shapeData"]
|
||||
x, y = shape["origin"]["x"], shape["origin"]["y"]
|
||||
return (x, y - sum(shape["rowHeights"]), x + sum(shape["columnWidths"]), y)
|
||||
|
||||
|
||||
def build_plan_kind_drawing(
|
||||
drawing_id: str,
|
||||
label: str,
|
||||
@@ -541,9 +470,23 @@ def build_plan_kind_drawing(
|
||||
return ((point[0] - min_x) * mm, (point[1] - min_y) * mm)
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
labels: list[dict[str, Any]] = []
|
||||
obstacles: list[tuple[float, float, float, float]] = []
|
||||
for piece in PIECE_IDS:
|
||||
if piece in pieces:
|
||||
entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper, mm))
|
||||
made = _piece_entities(drawing_id, piece, pieces[piece], paper, mm, labels)
|
||||
entities.extend(made)
|
||||
# 글이 비켜 갈 자리 — IP 표 · 필지 상자(141-1). 제목은 내용 위에 따로 서서 안 겹침.
|
||||
if piece == "ip_table":
|
||||
obstacles += [_table_rect(e) for e in made if e.get("type") == "Table"]
|
||||
elif piece == "parcel_info":
|
||||
for item in pieces[piece].get("items") or []:
|
||||
width, height = balloon_size(item["lines"], PARCEL_FONT)
|
||||
cx, cy = paper(item["center"])
|
||||
obstacles.append(
|
||||
(cx - width / 2, cy - height / 2, cx + width / 2, cy + height / 2)
|
||||
)
|
||||
entities.extend(label_entities(drawing_id, place_labels(labels, obstacles)))
|
||||
# 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다.
|
||||
frame_box = (0.0, 0.0, (box[2] - min_x) * mm, (box[3] - min_y) * mm)
|
||||
entities.extend(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""B07 평면도 글 자리 잡기(141-1) — 글끼리 · 장애물(IP 표 · 필지 상자)과 안 겹치게.
|
||||
|
||||
조각(`_piece_entities`)은 글을 바로 그리지 않고 후보 자리째 모은다. 여기서 우선순위 차례로
|
||||
후보 중 첫 빈 자리를 고르고(못 찾으면 첫 후보) 멀리 놓인 글엔 지시선을 단다.
|
||||
우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소. 서버 단독 계산(CLAUDE.md 5장 ③).
|
||||
|
||||
좌표 = 종이 mm. 글 사각 = 글 크기 × (한글 1.0 · 그 밖 0.6) 폭 · 글 크기 높이(세로 가운데 기준 —
|
||||
CAD 가 textBaseline middle 로 그림) · 방향이 있으면 돌린 사각의 바깥 상자.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _text_entity, polyline_entity
|
||||
|
||||
PRIORITY = ("endpoints", "station", "ip", "radius", "drainage", "refuge")
|
||||
_GAP_MM = 0.6 # 글 사이 틈(종이 mm)
|
||||
_PUSH = (2.0, 3.0) # 먼 고리 — 후보 거리의 배수
|
||||
|
||||
Rect = tuple[float, float, float, float]
|
||||
|
||||
|
||||
def text_width(label: str, size: float) -> float:
|
||||
return sum(1.0 if ord(char) > 0x2E7F else 0.6 for char in label) * size
|
||||
|
||||
|
||||
def text_rect(
|
||||
label: str,
|
||||
x: float,
|
||||
y: float,
|
||||
size: float,
|
||||
align: str = "center",
|
||||
direction: tuple[float, float] = (1.0, 0.0),
|
||||
) -> Rect:
|
||||
width = text_width(label, size)
|
||||
left = {"left": 0.0, "right": -width}.get(align, -width / 2.0)
|
||||
dx, dy = direction
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
dx, dy = dx / length, dy / length
|
||||
corners = [
|
||||
(x + cx * dx - cy * dy, y + cx * dy + cy * dx)
|
||||
for cx in (left, left + width)
|
||||
for cy in (-size / 2.0, size / 2.0)
|
||||
]
|
||||
xs, ys = [p[0] for p in corners], [p[1] for p in corners]
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def overlaps(a: Rect, b: Rect, gap: float = 0.0) -> bool:
|
||||
return a[0] < b[2] + gap and b[0] < a[2] + gap and a[1] < b[3] + gap and b[1] < a[3] + gap
|
||||
|
||||
|
||||
def _rect(item: dict[str, Any], spot: dict[str, Any]) -> Rect:
|
||||
return text_rect(
|
||||
item["label"],
|
||||
spot["x"],
|
||||
spot["y"],
|
||||
item["size"],
|
||||
spot.get("align", "center"),
|
||||
spot.get("direction", (1.0, 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def place_labels(labels: list[dict[str, Any]], obstacles: list[Rect]) -> list[dict[str, Any]]:
|
||||
"""우선순위 차례로 후보 중 첫 빈 자리 — 글마다 고른 자리(`spot`) · 사각(`rect`)을 붙여 냄.
|
||||
|
||||
labels = [{piece, seed, label, size, layer, color, anchor?, candidates: [{x, y, align?,
|
||||
direction?, far?}]}] — 첫 후보 = 옛 자리.
|
||||
"""
|
||||
taken = list(obstacles)
|
||||
placed = []
|
||||
rank = {piece: index for index, piece in enumerate(PRIORITY)}
|
||||
for item in sorted(labels, key=lambda label: rank.get(label["piece"], len(PRIORITY))):
|
||||
spots = [*item["candidates"], *_pushed(item)]
|
||||
rects = [_rect(item, spot) for spot in spots]
|
||||
# ponytail: 먼 고리까지 모두 막히면 첫 후보 그대로(겹침 남음 · 작은 축척에 글이 몰린 장)
|
||||
index = next(
|
||||
(
|
||||
i
|
||||
for i, rect in enumerate(rects)
|
||||
if not any(overlaps(rect, t, _GAP_MM) for t in taken)
|
||||
),
|
||||
0,
|
||||
)
|
||||
taken.append(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]))
|
||||
|
||||
|
||||
def label_entities(drawing_id: str, placed: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""자리 잡은 글 + 멀리 놓인 글(후보 far)의 지시선."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in placed:
|
||||
spot = item["spot"]
|
||||
out.append(
|
||||
_text_entity(
|
||||
item["seed"],
|
||||
item["label"],
|
||||
spot["x"],
|
||||
spot["y"],
|
||||
item["layer"],
|
||||
item["size"],
|
||||
item["color"],
|
||||
align=spot.get("align", "center"),
|
||||
direction=spot.get("direction", (1.0, 0.0)),
|
||||
)
|
||||
)
|
||||
anchor = item.get("anchor")
|
||||
if spot.get("far") and anchor:
|
||||
leader = polyline_entity(
|
||||
drawing_id,
|
||||
[anchor, _nearest_on_rect(anchor, item["rect"])],
|
||||
item["layer"],
|
||||
item["color"],
|
||||
suffix=f":leader:{item['seed']}",
|
||||
)
|
||||
if leader:
|
||||
out.append(leader)
|
||||
return out
|
||||
@@ -0,0 +1,192 @@
|
||||
"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장).
|
||||
|
||||
`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m.
|
||||
"""
|
||||
|
||||
import math
|
||||
from bisect import bisect_left
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── 노선 따라가기 ─────────────────────────────────────
|
||||
|
||||
|
||||
def route_frame(
|
||||
route: list[tuple[float, float, float]], chainage: float
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny)."""
|
||||
chainages = [item[0] for item in route]
|
||||
index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1)
|
||||
(c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index]
|
||||
dx, dy = x1 - x0, y1 - y0
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0
|
||||
return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length)
|
||||
|
||||
|
||||
def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float:
|
||||
chainages = [record["chainage_m"] for record in records]
|
||||
index = bisect_left(chainages, chainage)
|
||||
if index <= 0:
|
||||
return records[0][key]
|
||||
if index >= len(records):
|
||||
return records[-1][key]
|
||||
before, after = records[index - 1], records[index]
|
||||
span = after["chainage_m"] - before["chainage_m"]
|
||||
ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0
|
||||
return before[key] + (after[key] - before[key]) * ratio
|
||||
|
||||
|
||||
def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
|
||||
return sorted(
|
||||
(float(item["offset_m"]), float(item["elevation_m"]))
|
||||
for item in samples
|
||||
if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float))
|
||||
)
|
||||
|
||||
|
||||
def _ground_at(ground: list[tuple[float, float]], offset: float) -> float:
|
||||
offsets = [x for x, _z in ground]
|
||||
index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1)
|
||||
(x0, z0), (x1, z1) = ground[index - 1], ground[index]
|
||||
return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0)
|
||||
|
||||
|
||||
def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool:
|
||||
"""노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토)."""
|
||||
ground = _ground(samples)
|
||||
return len(ground) >= 2 and _ground_at(ground, edge) > elevation
|
||||
|
||||
|
||||
def slope_end(
|
||||
design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str
|
||||
) -> float:
|
||||
"""노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝.
|
||||
|
||||
설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다.
|
||||
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
|
||||
"""
|
||||
ground = _ground(samples)
|
||||
points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line)
|
||||
outward = (
|
||||
[p for p in points if p[0] > edge]
|
||||
if side == "left"
|
||||
else [p for p in points[::-1] if p[0] < edge]
|
||||
)
|
||||
if not ground:
|
||||
return outward[-1][0] if outward else edge
|
||||
end, differs = edge, False
|
||||
for offset, elevation in outward:
|
||||
if abs(elevation - _ground_at(ground, offset)) <= 1e-3:
|
||||
return offset if differs else end
|
||||
differs, end = True, offset
|
||||
return end
|
||||
|
||||
|
||||
def _split_runs(
|
||||
points: list[list[float]], flags: list[bool]
|
||||
) -> tuple[list[list[list[float]]], list[list[list[float]]]]:
|
||||
"""한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다."""
|
||||
runs: dict[bool, list[list[list[float]]]] = {True: [], False: []}
|
||||
current: list[list[float]] = []
|
||||
previous: bool | None = None
|
||||
for point, flag in zip(points, flags):
|
||||
if flag != previous:
|
||||
if current:
|
||||
current.append(point)
|
||||
current = [point]
|
||||
runs[flag].append(current)
|
||||
previous = flag
|
||||
else:
|
||||
current.append(point)
|
||||
return (
|
||||
[run for run in runs[True] if len(run) >= 2],
|
||||
[run for run in runs[False] if len(run) >= 2],
|
||||
)
|
||||
|
||||
|
||||
def slope_pieces(
|
||||
route: list[tuple[float, float, float]],
|
||||
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]]:
|
||||
"""측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다.
|
||||
|
||||
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
|
||||
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
|
||||
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을
|
||||
따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||||
"""
|
||||
records = sorted(
|
||||
(
|
||||
{
|
||||
**record,
|
||||
**{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")},
|
||||
}
|
||||
for record in records
|
||||
),
|
||||
key=lambda record: record["chainage_m"],
|
||||
)
|
||||
if len(route) < 2 or not records:
|
||||
return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []}
|
||||
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
|
||||
by_chainage = {round(record["chainage_m"], 3): record for record in records}
|
||||
samples = {
|
||||
round(record["chainage_m"], 3)
|
||||
for record in records
|
||||
if start_m <= record["chainage_m"] <= end_m
|
||||
}
|
||||
step = start_m
|
||||
while step <= end_m:
|
||||
samples.add(round(step, 3))
|
||||
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]]] = []
|
||||
for index, chainage in enumerate(sorted(samples)):
|
||||
record = by_chainage.get(chainage)
|
||||
if record and all(k in record for k in ("x", "y", "nx", "ny")):
|
||||
x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"]
|
||||
else:
|
||||
x, y, nx, ny = route_frame(route, chainage)
|
||||
offsets = {key: _interp(records, chainage, key) for key in keys}
|
||||
points = {
|
||||
key: [round(x + nx * value, 4), round(y + ny * value, 4)]
|
||||
for key, value in offsets.items()
|
||||
}
|
||||
for key in keys:
|
||||
lines[key].append(points[key])
|
||||
for side in cut:
|
||||
cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5)
|
||||
# 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례).
|
||||
for side in ("left", "right"):
|
||||
edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"]
|
||||
if abs(end - edge) < 0.05:
|
||||
continue
|
||||
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
|
||||
ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]])
|
||||
cut_lines: list[list[list[float]]] = []
|
||||
fill_lines: list[list[list[float]]] = []
|
||||
for side in cut:
|
||||
cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side])
|
||||
cut_lines += cut_runs
|
||||
fill_lines += fill_runs
|
||||
return (
|
||||
{"lines": [lines["left_edge_m"], lines["right_edge_m"]]},
|
||||
{"lines": fill_lines},
|
||||
{"lines": cut_lines},
|
||||
{"ticks": ticks},
|
||||
)
|
||||
@@ -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 — 검은 띠 없음
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""141-1 B07 평면도 글자 겹침 피하기.
|
||||
|
||||
못박는 것
|
||||
- 우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소 — 앞 글이 첫 후보(옛 자리)를 가짐.
|
||||
- 후보 중 첫 빈 자리 · 모두 막히면 첫 후보 그대로(글은 남음).
|
||||
- 장애물(IP 표 · 필지 상자) 비켜 감.
|
||||
- 가까운 IP · R · 배수 둘 · NO.0 + 시점 한 장 → 글 사각 겹침 0 · 글 모두 남음.
|
||||
- 멀리 놓인 글(후보 far)엔 지시선.
|
||||
"""
|
||||
|
||||
from itertools import combinations
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import (
|
||||
overlaps,
|
||||
place_labels,
|
||||
text_rect,
|
||||
)
|
||||
|
||||
|
||||
def _label(piece: str, label: str, spots: list[dict], anchor=None) -> dict:
|
||||
return {
|
||||
"piece": piece,
|
||||
"seed": f"s:{piece}:{label}",
|
||||
"label": label,
|
||||
"size": 2.5,
|
||||
"layer": "l",
|
||||
"color": "#fff",
|
||||
"anchor": anchor,
|
||||
"candidates": spots,
|
||||
}
|
||||
|
||||
|
||||
def test_priority_keeps_first_spot_and_next_moves() -> None:
|
||||
spot = [{"x": 0.0, "y": 0.0}, {"x": 0.0, "y": 10.0}]
|
||||
placed = place_labels([_label("refuge", "대피소", spot), _label("endpoints", "시점", spot)], [])
|
||||
by = {item["piece"]: item["spot"] for item in placed}
|
||||
assert by["endpoints"] == spot[0] # 시종점이 먼저 — 옛 자리
|
||||
assert by["refuge"] == spot[1]
|
||||
|
||||
|
||||
def test_all_blocked_falls_back_to_first() -> None:
|
||||
spot = [{"x": 0.0, "y": 0.0}]
|
||||
placed = place_labels([_label("ip", "IP1", spot), _label("radius", "R=12", spot)], [])
|
||||
assert [item["spot"] for item in placed] == [spot[0], spot[0]] # 글은 남음
|
||||
|
||||
|
||||
def test_obstacle_is_avoided() -> None:
|
||||
spots = [{"x": 0.0, "y": 0.0}, {"x": 30.0, "y": 0.0}]
|
||||
placed = place_labels([_label("ip", "IP1", spots)], [(-5.0, -5.0, 5.0, 5.0)])
|
||||
assert placed[0]["spot"] == spots[1]
|
||||
|
||||
|
||||
def _texts(drawing: dict) -> list[dict]:
|
||||
return [
|
||||
e
|
||||
for e in drawing["entities"]
|
||||
if e["type"] == "Text"
|
||||
and e["layerId"].startswith("b07-plan-")
|
||||
and e["layerId"] != "b07-plan-title"
|
||||
]
|
||||
|
||||
|
||||
def _rect(entity: dict) -> tuple:
|
||||
shape = entity["shapeData"]
|
||||
opts = shape["options"]
|
||||
direction = (opts["textDirection"]["x"], opts["textDirection"]["y"])
|
||||
return text_rect(
|
||||
shape["label"],
|
||||
shape["basePoint"]["x"],
|
||||
shape["basePoint"]["y"],
|
||||
opts["fontSize"],
|
||||
opts["textAlign"],
|
||||
direction,
|
||||
)
|
||||
|
||||
|
||||
def test_crowded_sheet_has_no_overlapping_labels() -> None:
|
||||
box = (0.0, 0.0, 300.0, 200.0)
|
||||
pieces = {
|
||||
"ip": {
|
||||
"items": [
|
||||
{
|
||||
"name": "IP1",
|
||||
"apex": [100.0, 100.0],
|
||||
"start": [90.0, 100.0],
|
||||
"end": [100.0, 110.0],
|
||||
},
|
||||
{
|
||||
"name": "IP2",
|
||||
"apex": [103.0, 101.0],
|
||||
"start": [95.0, 101.0],
|
||||
"end": [103.0, 111.0],
|
||||
},
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"items": [
|
||||
{"label": "R=12", "middle": [100.0, 98.0], "dir": [0.0, -1.0]},
|
||||
{"label": "R=15", "middle": [103.0, 99.0], "dir": [0.0, -1.0]},
|
||||
]
|
||||
},
|
||||
"drainage": {
|
||||
"items": [
|
||||
{"x": 101.0, "y": 96.0, "label": "D800 L=8.0"},
|
||||
{"x": 102.0, "y": 95.0, "label": "D600 L=6.0"},
|
||||
]
|
||||
},
|
||||
"station": {
|
||||
"items": [
|
||||
{"x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0, "label": "NO.0", "major": True},
|
||||
]
|
||||
},
|
||||
"endpoints": {
|
||||
"items": [{"label": "시점 NO.0", "x": 50.0, "y": 50.0, "nx": 0.0, "ny": 1.0}]
|
||||
},
|
||||
}
|
||||
drawing = build_plan_kind_drawing("plan_x", "시험", box, pieces)
|
||||
texts = _texts(drawing)
|
||||
labels = sorted(e["shapeData"]["label"] for e in texts)
|
||||
assert labels == sorted(
|
||||
["IP1", "IP2", "R=12", "R=15", "D800 L=8.0", "D600 L=6.0", "NO.0", "시점 NO.0"]
|
||||
)
|
||||
pairs = [
|
||||
(a["shapeData"]["label"], b["shapeData"]["label"])
|
||||
for a, b in combinations(texts, 2)
|
||||
if overlaps(_rect(a), _rect(b))
|
||||
]
|
||||
assert pairs == []
|
||||
|
||||
|
||||
def test_far_spot_draws_leader() -> None:
|
||||
near = [{"x": 0.0, "y": 0.0}, {"x": 40.0, "y": 0.0, "far": True}]
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities
|
||||
|
||||
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