Merge remote-tracking branch 'origin/sub_laptop_8' into sub_desktop_1

This commit is contained in:
2026-10-09 15:22:18 +09:00
15 changed files with 1121 additions and 238 deletions
@@ -159,7 +159,11 @@ def _diameter_label(props: dict[str, Any]) -> str:
def _hatch_entity(
drawing_id: str, number: int, ring: list[tuple[float, float]], color: str
drawing_id: str,
number: int,
ring: list[tuple[float, float]],
color: str,
layer: str = BASIN_LAYER_ID,
) -> dict[str, Any]:
"""유역 안쪽 격자 해칭. openwebcad Hatch 엔티티라 CAD에서 무늬·간격을 그대로 편집한다."""
return {
@@ -167,7 +171,7 @@ def _hatch_entity(
"type": "Hatch",
"lineColor": color,
"lineWidth": 1,
"layerId": BASIN_LAYER_ID,
"layerId": layer,
"shapeData": {
"points": [{"x": x, "y": y} for x, y in ring],
"options": {
@@ -181,7 +185,11 @@ def _hatch_entity(
def _number_badge_entities(
drawing_id: str, number: int, center: tuple[float, float], color: str
drawing_id: str,
number: int,
center: tuple[float, float],
color: str,
layer: str = BASIN_LAYER_ID,
) -> list[dict[str, Any]]:
"""유역 번호를 동그라미 배지로 그린다 — 회색 원판 + 유역색 테두리 + 검은 숫자.
@@ -202,7 +210,7 @@ def _number_badge_entities(
"type": "Hatch",
"lineColor": _BADGE_FILL,
"lineWidth": 1,
"layerId": BASIN_LAYER_ID,
"layerId": layer,
"shapeData": {
"points": [{"x": x, "y": y} for x, y in disc],
"options": {"style": "solid", "color": _BADGE_FILL, "spacing": 1, "angle": 0},
@@ -213,7 +221,7 @@ def _number_badge_entities(
"type": "Circle",
"lineColor": color,
"lineWidth": 1,
"layerId": BASIN_LAYER_ID,
"layerId": layer,
"shapeData": {"center": {"x": cx, "y": cy}, "radius": _BADGE_RADIUS_MM},
},
_text_entity(
@@ -221,7 +229,7 @@ def _number_badge_entities(
str(number),
cx,
cy,
BASIN_LAYER_ID,
layer,
_NUMBER_FONT_SIZE,
_BADGE_TEXT_COLOR,
),
@@ -18,6 +18,9 @@
import math
from typing import Any
from shapely.geometry import LineString
from shapely.geometry import box as shapely_box
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
FRAME_LAYER_ID,
@@ -25,8 +28,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_layer,
_text_entity,
polyline_entity,
station_no_label,
station_plus_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import route_frame
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
compass_entities,
entities_bbox,
@@ -114,8 +119,8 @@ def plan_chunks(
def fits(part: list[tuple[float, float, float]]) -> bool:
width = max(x for _c, x, _y in part) - min(x for _c, x, _y in part)
height = max(y for _c, _x, y in part) - min(y for _c, _x, y in part)
# 가로로 길든 세로로 길든 도곽에만 들어가면 된다 — 두 방향 다 본다.
return (width <= span_w and height <= span_h) or (width <= span_h and height <= span_w)
# 그림을 돌리지 않으므로 도곽 방향 그대로만 본다(141-4 — 돌린 맞춤은 그림칸을 넘쳤음)
return width <= span_w and height <= span_h
chunks: list[dict[str, Any]] = []
start = 0
@@ -134,6 +139,50 @@ def plan_chunks(
return chunks
MATCH_HALF_MM = 30.0 # 맞춤선 반길이(종이 mm · 노선 직각)
def sheet_links(
route: list[tuple[float, float, float]],
chunk: dict[str, Any],
total: int,
box: tuple[float, float, float, float],
interval_m: float,
mm: float,
neighbors: bool,
) -> dict[str, Any]:
"""여러 장 이음(141-4) — 장 경계 맞춤선 + 「MATCH LINE NO.x · n장 이어짐」 · 이웃 구간 노선.
`route` = (누가거리, x, y) 노선 전체 · 맞춤선 = 경계 측점에서 노선 직각(좌측 법선) ·
이웃 구간 = 이 장 구간 밖 노선을 도곽으로 자른 것(흐리게 그림 · `neighbors` 일 때만).
"""
start_m, end_m, number = float(chunk["start_m"]), float(chunk["end_m"]), int(chunk["number"])
half = MATCH_HALF_MM / mm
lines = []
for at, other in ((start_m, number - 1), (end_m, number + 1)):
if not 1 <= other <= total or len(route) < 2:
continue
x, y, nx, ny = route_frame(route, at)
no = station_no_label(at, interval_m).replace("No.", "NO.")
lines.append(
{
"points": [[x - nx * half, y - ny * half], [x + nx * half, y + ny * half]],
"label": f"MATCH LINE {no} · {other}장 이어짐",
}
)
out: list[list[list[float]]] = []
if neighbors:
frame = shapely_box(*box)
for part in ([p for p in route if p[0] <= start_m], [p for p in route if p[0] >= end_m]):
if len(part) < 2:
continue
clipped = LineString([(x, y) for _c, x, y in part]).intersection(frame)
for piece in getattr(clipped, "geoms", [clipped]):
if isinstance(piece, LineString) and len(piece.coords) >= 2:
out.append([[round(x, 4), round(y, 4)] for x, y in piece.coords])
return {"lines": lines, "neighbors": out}
def plan_drawing_id(kind: str, chunk: dict[str, Any], total: int) -> str:
"""장이 하나면 접두어 그대로, 여럿이면 `plan_route_2` 처럼 번호를 붙인다."""
return kind if total <= 1 else f"{kind}_{chunk['number']}"
@@ -10,7 +10,6 @@
"""
import math
from bisect import bisect_left
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
@@ -21,8 +20,14 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_text_entity,
polyline_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import (
_BADGE_RADIUS_MM,
BASIN_COLORS,
_hatch_entity,
_number_badge_entities,
)
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 +41,21 @@ 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_rect,
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,
@@ -49,6 +69,9 @@ PIECES: tuple[tuple[str, str, bool], ...] = (
("lidar", "라이다 음영", True),
("contour", "등고선", True),
("stream", "계류", True),
# 배수유역(141-3) — 유역도 해치 · 번호 배지를 어느 평면도에나(장 범위로 자름)
("basin", "배수유역 경계", False),
("basin_no", "유역 번호", False),
("slope_ticks", "비탈 눈금", False),
("road_edge", "노견선", False),
("fill_outline", "성토 외곽선", False),
@@ -130,7 +153,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 +164,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 +188,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 +199,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 +222,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 +260,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 +317,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 +369,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 +402,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 +419,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 +435,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]
@@ -523,9 +456,83 @@ def _piece_entities(
font=PARCEL_FONT,
)
)
elif piece == "basin":
for index, item in enumerate(data.get("items") or []):
ring = [paper(point) for point in item["ring"]]
tint = BASIN_COLORS[(int(item["number"]) - 1) % len(BASIN_COLORS)]
seed = f"{drawing_id}:plan:{index}"
out.append(_hatch_entity(seed, int(item["number"]), ring, tint, layer))
outline = polyline_entity(drawing_id, ring, layer, tint, suffix=f":{index}")
if outline:
out.append(outline)
elif piece == "basin_no":
for index, item in enumerate(data.get("items") or []):
number = int(item["number"])
tint = BASIN_COLORS[(number - 1) % len(BASIN_COLORS)]
seed = f"{drawing_id}:plan:{index}"
out.extend(_number_badge_entities(seed, number, paper(item["center"]), tint, layer))
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)
SHEET_LAYER = "b07-plan-sheet" # 여러 장 이음 — 맞춤선 · 이웃 구간 노선(141-4)
_SHEET_COLOR = "#ff9d4d"
_NEIGHBOR_COLOR = "#7d8590" # 이웃 구간 노선 — 흐리게
def _sheet_link_entities(
drawing_id: str, links: dict[str, Any], paper: Any
) -> tuple[list[dict[str, Any]], list[tuple[float, float, float, float]]]:
"""맞춤선(노선 직각 쇄선 + 「MATCH LINE …」) · 이웃 구간 노선(흐린 파선) · 글 사각 = 장애물."""
out: list[dict[str, Any]] = []
rects: list[tuple[float, float, float, float]] = []
for index, points in enumerate(links.get("neighbors") or []):
line = polyline_entity(
drawing_id,
[paper(p) for p in points],
SHEET_LAYER,
_NEIGHBOR_COLOR,
suffix=f":n:{index}",
dash=[3, 3],
)
if line:
out.append(line)
size = _LABEL_FONT + 0.5
for index, item in enumerate(links.get("lines") or []):
a, b = (paper(point) for point in item["points"])
line = polyline_entity(
drawing_id, [a, b], SHEET_LAYER, _SHEET_COLOR, suffix=f":m:{index}", width=2,
dash=[8, 3, 2, 3],
) # fmt: skip
if line:
out.append(line)
length = math.hypot(b[0] - a[0], b[1] - a[1]) or 1.0
ux, uy = (b[0] - a[0]) / length, (b[1] - a[1]) / length
# 글은 맞춤선 끝 바깥으로 선 방향 — 거꾸로 서지 않게 오른쪽(위)쪽 끝에서
end, (dx, dy) = (b, (ux, uy)) if ux > 1e-9 or (ux > -1e-9 and uy > 0) else (a, (-ux, -uy))
x, y = end[0] + dx * 2.0, end[1] + dy * 2.0
out.append(
_text_entity(
f"{drawing_id}:sheet:{index}",
item["label"],
x,
y,
SHEET_LAYER,
size,
_SHEET_COLOR,
align="left",
direction=(dx, dy),
)
)
rects.append(text_rect(item["label"], x, y, size, "left", (dx, dy)))
return out, rects
def build_plan_kind_drawing(
drawing_id: str,
label: str,
@@ -541,9 +548,33 @@ 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)
)
elif piece == "basin_no": # 유역 번호 배지(141-3)
radius = _BADGE_RADIUS_MM
for item in pieces[piece].get("items") or []:
cx, cy = paper(item["center"])
obstacles.append((cx - radius, cy - radius, cx + radius, cy + radius))
links = pieces.get("sheet_links")
if links:
made, rects = _sheet_link_entities(drawing_id, links, paper)
entities.extend(made)
obstacles += rects
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(
@@ -599,6 +630,7 @@ def build_plan_kind_drawing(
"entities": entities,
"layers": [
*layers,
*([_layer(SHEET_LAYER, "맞춤선 · 이웃 구간")] if links else []),
_layer("b07-plan-title", "표제"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
@@ -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},
)
@@ -13,6 +13,8 @@ from typing import Any
import numpy as np
from pyproj import Transformer
from shapely.geometry import Polygon
from shapely.geometry import box as shapely_box
from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm
@@ -499,6 +501,30 @@ def watershed_source(context: Any) -> dict[str, Any]:
좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 그 라벨로 되돌려야 원래
미터 좌표가 나온다(2026-09-01).
"""
route_xy = [(vertex.x, vertex.y) for vertex in context.vertices]
basins = metric_basins(context)
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
# 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다.
background = map_background(
Path(context.project_root),
context.crs,
DRAWING_SCALE_BASIN,
map_area_mm(),
[*route_xy, *(point for basin in basins for point in basin["ring"])],
)
return {
"route_xy": route_xy,
"basins": basins,
"contours": background["contours"],
"streams": background["streams"],
}
def metric_basins(context: Any) -> list[dict[str, Any]]:
"""세부유역 [{ring, props}] — 사업지 m(유역도 · 평면도 유역 조각 141-3 공용)."""
basins_payload = _geojson_payload(detail_basins_path(context.stored_path))
to_basin_metric = Transformer.from_crs(
"EPSG:4326", _basins_crs(context, basins_payload), always_xy=True
@@ -533,21 +559,36 @@ def watershed_source(context: Any) -> dict[str, Any]:
_BASIN_MAX_DISTANCE_M / 1000.0,
dropped,
)
return basins
# 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다
# (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다).
# 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다.
background = map_background(
Path(context.project_root),
context.crs,
DRAWING_SCALE_BASIN,
map_area_mm(),
[*route_xy, *(point for basin in basins for point in basin["ring"])],
)
return {
"route_xy": route_xy,
"basins": basins,
"contours": background["contours"],
"streams": background["streams"],
}
def plan_basin_pieces(
context: Any, box: tuple[float, float, float, float]
) -> dict[str, dict[str, Any]]:
"""평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 자름(141-3).
번호 = 유역도와 같은 번호(`index` · 없으면 차례) · 자리 = 잘린 조각 안 대표점.
유역 저장본이 없으면 빈 조각(배수 분석 전).
"""
try:
basins = metric_basins(context)
except (FileNotFoundError, OSError, ValueError):
basins = []
frame = shapely_box(*box)
rings: list[dict[str, Any]] = []
numbers: list[dict[str, Any]] = []
for order, basin in enumerate(basins):
number = int((basin.get("props") or {}).get("index") or order + 1)
try:
part = Polygon(basin["ring"]).buffer(0).intersection(frame)
except (ValueError, TypeError):
continue
if part.is_empty or part.area <= 0:
continue
for polygon in getattr(part, "geoms", [part]):
if isinstance(polygon, Polygon) and polygon.area > 0:
ring = [[round(x, 4), round(y, 4)] for x, y in polygon.exterior.coords]
rings.append({"number": number, "ring": ring})
point = part.representative_point()
numbers.append({"number": number, "center": [round(point.x, 4), round(point.y, 4)]})
return {"basin": {"items": rings}, "basin_no": {"items": numbers}}
@@ -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]
@@ -31,6 +31,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_structure_label,
plan_area_mm,
plan_chunks,
sheet_links,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
DEFAULT_KINDS,
@@ -42,12 +43,14 @@ 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,
_plan_structures,
_sheet_box,
map_background,
plan_basin_pieces,
plan_stations,
watershed_source,
)
@@ -77,7 +80,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 +394,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 +430,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 +474,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 +512,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"] = {
@@ -515,6 +525,8 @@ def build_pieces(
and min_y <= item["y"] <= max_y
]
}
if {"basin", "basin_no"} & set(wanted):
pieces.update(plan_basin_pieces(context, box)) # 141-3
if "lidar" in wanted:
try:
image, image_box = _hillshade_for_box(context, box)
@@ -533,10 +545,15 @@ def build_pieces(
pieces, _load_design_curves(project_root), route, box, chunk, total, mm
)
chosen = {piece: pieces[piece] for piece in wanted if piece in pieces}
if total > 1: # 여러 장 이음 — 조각 고름과 무관하게 늘(이웃 노선은 중심선 고를 때만 · 141-4)
chosen["sheet_links"] = pieces["sheet_links"] = sheet_links(
route, chunk, total, box, interval, mm, "centerline" in wanted
)
folder = _design_root(project_root) / "plan_pieces" / str(number)
for piece, data in pieces.items():
atomic_write_json(folder / f"{piece}.json", data)
return {piece: pieces[piece] for piece in wanted if piece in pieces}, box, chunk, total
return chosen, box, chunk, total
def plan_kind_source(
@@ -565,7 +582,7 @@ def plan_kind_source(
)
title = plan_title(kind["name"])
return {
"label": title if total <= 1 else f"{title} {chunk['number']}장",
"label": title if total <= 1 else f"{title} {chunk['number']}/{total}장",
"box": box,
"pieces": pieces,
"scale": kind["scale"],
@@ -599,7 +616,7 @@ def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[di
"id": f"plan_{kind['id']}"
if single
else f"plan_{kind['id']}_{chunk['number']}",
"label": title if single else f"{title} {chunk['number']}장",
"label": title if single else f"{title} {chunk['number']}/{len(sheets)}장",
"plan_kind": kind["id"],
"plan_kind_name": kind["name"],
}
+34 -5
View File
@@ -22,6 +22,9 @@
열 id 접두 `pipe:` = 이 집계표(횡단배수) 이름공간 · 같은 측점 박스 + 관은 종류로 갈라 셈.
머리 글 = M02 정의 이름(`구조물정의.json` types.name · 종류.{종류}.이름 — 사용자가 M02 에서 고침) ·
열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리).
열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}(145-3) — 주인 id = `linked_snapshots` 키
(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 칸을 낸 잇기 사본이 먼저 · 없으면 시설 본체 ·
합 = 열 계.
설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용.
`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈
(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122).
@@ -111,6 +114,21 @@ def _combo(item: Any) -> str | None:
return f"{item.get('layer')}|{item.get('owner') or ''}|{item['key']}"
class _Copy(str):
"""잇기 조합 id + 그 사본 주인 id(`linked_snapshots` 키 · `{구조물 id}__{그룹}`) — 145-3."""
copy: str
def _linked(combo: str | None, copy: str) -> str | None:
"""잇기 조합에 사본 주인 id 를 붙임 — 본체 조합(그냥 글)은 시설 id 가 주인."""
if combo is None:
return None
out = _Copy(combo)
out.copy = copy
return out
def one_pipe_length(project_root: str | Path) -> float | None:
"""1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None."""
found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME)
@@ -193,14 +211,19 @@ class _Sheet:
return
cid = f"pipe:{key}"
column = self.columns.setdefault(
cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort}
cid,
{"id": cid, "group": group, "label": label, "combos": [], "owners": {}, "_sort": sort},
)
for combo in combos:
if combo and combo not in column["combos"]:
column["combos"].append(combo)
column["combos"].append(str(combo))
row = self.rows.setdefault(round(chainage * 100), {"chainage_m": chainage, "values": {}})
if value is not None: # 열은 세움 — 놓였는데 값이 빈칸인 것이 보여야 함
row["values"][cid] = round((row["values"].get(cid) or 0.0) + value, 3)
# 사본 주인별 값(145-3 · 설계내역서가 대가 호표에 이음) — 잇기 사본이 먼저 · 없으면 본체
owner = next((c.copy for c in combos if isinstance(c, _Copy)), self.owner)
owners = column["owners"]
owners[owner] = round(owners.get(owner, 0.0) + value, 3)
def result(self) -> dict[str, Any]:
columns = sorted(self.columns.values(), key=lambda column: column["_sort"])
@@ -472,7 +495,11 @@ def _drain(
return
# 필수 컨테이너 잇기 — 정의 그룹에 없는 것(세월교)은 저장된 잇기에서
required = {"box_culvert": BOX_LINKS, "ford_bridge": FORD_LINKS}.get(type_id, PAVE_LINKS)
combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()}
combos = {
part: links.get(link)
or _linked(_combo(group.get(link)), f"{sheet.owner}{GROUP_JOIN}{link}")
for part, link in required.items()
}
if type_id == "ford_pavement":
_pavement(sheet, at, base, combos, names)
return
@@ -527,7 +554,7 @@ def pipe_summary(
# 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1)
combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None
if link:
entry["links"][link] = combo
entry["links"][link] = _linked(combo, item["복사본"])
else:
entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None}
@@ -552,7 +579,9 @@ def pipe_summary(
}
_drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length)
body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"]
body = links.get(body_link) or _combo(group.get(body_link))
body = links.get(body_link) or _linked(
_combo(group.get(body_link)), f"{owner}{GROUP_JOIN}{body_link}"
)
counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음)
no_box += type_id == "box_culvert" and counted and not box_length
if type_id == "ford_bridge":
+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 — 검은 띠 없음
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""141-3 평면도 조각 「배수유역 경계」 · 「유역 번호」 — 세부유역을 장 범위로 잘라 유역도 해치 · 배지로.
견본: 유역 1 = 장 안 정사각 · 유역 2 = 장 경계에 걸침(잘림) · 유역 3 = 장 밖(빠짐).
"""
from __future__ import annotations
from types import SimpleNamespace
from B07_DesignDetail import B07_DesignDetail_Router_Support_Basin as basin
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
PIECE_IDS,
build_plan_kind_drawing,
layer_id,
)
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import PIECES
BOX = (0.0, 0.0, 100.0, 100.0)
BASINS = [
{"ring": [(10, 10), (40, 10), (40, 40), (10, 40), (10, 10)], "props": {"index": 1}},
{"ring": [(80, 50), (120, 50), (120, 90), (80, 90), (80, 50)], "props": {"index": 2}},
{"ring": [(300, 300), (320, 300), (320, 320), (300, 300)], "props": {"index": 3}},
]
def _pieces(monkeypatch) -> dict:
monkeypatch.setattr(basin, "metric_basins", lambda _context: BASINS)
return basin.plan_basin_pieces(SimpleNamespace(), BOX)
def test_장_범위로_자르고_번호는_유역도와_같다(monkeypatch):
pieces = _pieces(monkeypatch)
rings = pieces["basin"]["items"]
assert [item["number"] for item in rings] == [1, 2] # 장 밖 3 은 빠짐
clipped = rings[1]["ring"]
assert max(x for x, _y in clipped) == 100.0 # 장 경계에서 잘림
numbers = {item["number"]: item["center"] for item in pieces["basin_no"]["items"]}
assert set(numbers) == {1, 2}
x, y = numbers[2]
assert 80 <= x <= 100 and 50 <= y <= 90 # 잘린 조각 안
def test_유역_저장본_없으면_빈_조각(monkeypatch):
def missing(_context):
raise FileNotFoundError
monkeypatch.setattr(basin, "metric_basins", missing)
assert basin.plan_basin_pieces(SimpleNamespace(), BOX) == {
"basin": {"items": []},
"basin_no": {"items": []},
}
def test_어느_평면도에나_조각_체크(monkeypatch):
assert {"basin", "basin_no"} <= set(PIECE_IDS)
assert ("basin", "배수유역 경계", False) in PIECES and (
"basin_no",
"유역 번호",
False,
) in PIECES
drawing = build_plan_kind_drawing("plan_x", "평면도(x)", BOX, _pieces(monkeypatch), 1200)
hatches = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin")]
assert [e["type"] for e in hatches].count("Hatch") == 2
badges = [e for e in drawing["entities"] if e["layerId"] == layer_id("basin_no")]
assert sorted(e["shapeData"]["label"] for e in badges if e["type"] == "Text") == ["1", "2"]
assert len({e["id"] for e in drawing["entities"]}) == len(drawing["entities"])
names = {layer["id"]: layer["name"] for layer in drawing["layers"]}
assert names[layer_id("basin")] == "배수유역 경계"
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""141-4 평면도 여러 장 — 노선 따라 자르기 유지 + 맞춤선 · 이웃 구간 흐리게 · 「n/N장」 · 돌린 맞춤 뺌."""
from __future__ import annotations
import math
from pathlib import Path
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_chunk_span_m,
plan_chunks,
sheet_links,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
SHEET_LAYER,
build_plan_kind_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import plan_kind_items
# 남북으로 곧은 노선 1,200 m · 측점 20 m
STATIONS = [(float(c), 0.0, float(c)) for c in range(0, 1201, 20)]
def test_돌린_맞춤_빼기_남북_노선은_그림칸_높이로_나눔():
span_w, span_h = _chunk_span_m(1200)
assert span_h < 800 < span_w # 옛 규칙 = 돌려 보면 한 장(그림칸 높이를 넘침)
chunks = plan_chunks(STATIONS[:41], 1200) # 800 m
assert len(chunks) == 2
for chunk in chunks:
assert chunk["end_m"] - chunk["start_m"] <= span_h
def test_맞춤선_경계_측점_직각_이웃_장_번호():
chunks = plan_chunks(STATIONS, 1200)
total = len(chunks)
assert total == 3
middle = chunks[1]
box = (-300.0, middle["start_m"] - 10.0, 300.0, middle["end_m"] + 10.0)
mm = 1000.0 / 1200
links = sheet_links(STATIONS, middle, total, box, 20.0, mm, neighbors=True)
first, last = links["lines"]
start_no, end_no = int(middle["start_m"] // 20), int(middle["end_m"] // 20)
assert first["label"] == f"MATCH LINE NO.{start_no} · 1장 이어짐"
assert last["label"] == f"MATCH LINE NO.{end_no} · 3장 이어짐"
(ax, ay), (bx, by) = first["points"]
assert ay == by == middle["start_m"] # 남북 노선에 직각 = 동서
assert math.hypot(bx - ax, by - ay) * mm == 60.0 # 종이 60 mm
# 이웃 구간 노선 = 도곽 안만(앞 · 뒤 10 m)
assert len(links["neighbors"]) == 2
for line in links["neighbors"]:
assert all(box[1] <= y <= box[3] for _x, y in line)
# 첫 장 · 끝 장은 한쪽만 · 중심선 안 고르면 이웃 노선 없음
only = sheet_links(STATIONS, chunks[0], total, box, 20.0, mm, neighbors=False)
assert [item["label"].endswith("· 2장 이어짐") for item in only["lines"]] == [True]
assert only["neighbors"] == []
def test_도면에_맞춤선_층_글은_다른_글과_안_겹침():
chunks = plan_chunks(STATIONS, 1200)
middle = chunks[1]
box = (-300.0, middle["start_m"] - 10.0, 300.0, middle["end_m"] + 10.0)
mm = 1000.0 / 1200
links = sheet_links(STATIONS, middle, 3, box, 20.0, mm, neighbors=True)
at = middle["start_m"]
pieces = {
"sheet_links": links,
# 맞춤선 글 자리에 시종점 글 후보가 처음 놓이는 견본
"endpoints": {"items": [{"label": "시점 No.0", "x": 0.0, "y": at, "nx": 1.0, "ny": 0.0}]},
}
drawing = build_plan_kind_drawing("plan_x_2", "평면도(x) 2/3장", box, pieces, 1200)
sheet = [e for e in drawing["entities"] if e["layerId"] == SHEET_LAYER]
labels = sorted(e["shapeData"]["label"] for e in sheet if e["type"] == "Text")
assert labels == [line["label"] for line in links["lines"]]
assert any(layer["id"] == SHEET_LAYER for layer in drawing["layers"])
assert sum(e["type"] == "PolyLine" for e in sheet) == 4 # 맞춤선 둘 + 이웃 노선 둘
def test_목록_도면명_n_N장(tmp_path: Path):
stations = [{"chainage_m": c, "center_x": x, "center_y": y} for c, x, y in STATIONS]
items = [
i for i in plan_kind_items(tmp_path, {"stations": stations}) if i["plan_kind"] == "terrain"
]
assert [item["label"] for item in items] == [f"평면도(지형) {n}/3장" for n in (1, 2, 3)]
@@ -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"]
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""145-3 이음 — B08 구조물 집계표 열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}.
주인 id = `linked_snapshots` 키(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 열 owners 합 = 열 계 ·
칸을 낸 잇기 사본이 먼저(없으면 시설 본체).
"""
from __future__ import annotations
import pytest
from test_120_b08_drain_summary import out # noqa: F401 — 픽스처(관 밖 횡단배수 견본)
from test_143_1_b08_linked_snapshots import _run as _run_143
from test_143_1_b08_linked_snapshots import root # noqa: F401 — 픽스처(실제 사본 폴더)
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
def _check_sums(result: dict, keys: set[str]) -> None:
for column in result["columns"]:
owners = column["owners"]
assert set(owners) <= keys, column["id"]
assert sum(owners.values()) == pytest.approx(result["total"].get(column["id"], 0.0))
def test_owners_합_열_계_주인은_사본_키(out): # noqa: F811
cols = {c["id"]: c for c in out["columns"]}
_check_sums(out, set(out["linked"]))
# 본체 칸 = 본체 잇기 사본(gboxbody) · 날개벽 = 유입 잇기 · 흙막이 = 유출 잇기
assert cols["pipe:box|body|2.0×2.0"]["owners"] == {"pipe@300.0__gboxbody": 9.6}
assert cols["pipe:box_culvert|wing|inlet"]["owners"] == {"pipe@300.0__gboxin": 1.0}
assert cols["pipe:revet|돌쌓기(메)|2.0"]["owners"] == {"pipe@300.0__gboxout": 10.0}
# 세월교 월류 폭 = 저장된 잇기(gford) · 관 공은 본체(사본 키 = 관 지점 id)
assert cols["pipe:ford|1000|2|width"]["owners"] == {"pipe@400.0__gford": 12.0}
assert cols["pipe:len|800"]["owners"] == {"pipe@300.0": 18.0}
# B05 구조물 = 구조물 id · 그룹 잇기 = {id}__{그룹}
assert cols["pipe:open_ditch"]["owners"] == {"s-ditch": 1.0}
assert cols["pipe:cross_underdrain"]["owners"] == {"s-ud__gcrossud": 1.0}
def test_실제_사본_폴더_키와_같다(root): # noqa: F811
result = _run_143(root)
linked = set(snapshots.linked_snapshots(root))
_check_sums(result, linked)
cols = {c["id"]: c for c in result["columns"]}
assert cols["pipe:len|800"]["owners"] == {"pipe@100.0": 16.0}
assert cols["pipe:len|1000"]["owners"] == {"pipe@500.0__gford": 18.0}