Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py
T
eomsangdonandClaude Opus 5.5 0d49a4c3c1 feat(B07): 평면도 여러 장 맞춤선 · 이웃 구간 흐리게 · n/N장 · 돌린 맞춤 뺌(141-4)
- 장 나눔 — 돌린 방향 맞춤 뺌(그림을 안 돌리므로 그림칸 높이 넘침 흠)
- 장 경계 측점에 노선 직각 맞춤선 + 「MATCH LINE NO.x · n장 이어짐」 · 글은 글 자리 잡기 장애물
- 이웃 구간 노선 = 도곽 안 흐린 파선(중심선 고를 때) · 도면명 「n/N장」
- 새 test_141_4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-09 15:16:02 +09:00

638 lines
24 KiB
Python

"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장).
계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다.
정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO ·
시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩
있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만
한다(파일 입출력 없음).
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x (1000 / 종류 축척 분모) (기본 1/1,200).
"""
import math
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
FRAME_LAYER_ID,
TABLE_LABEL_COLOR,
_layer,
_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, balloon_size
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_COMPASS_MARGIN,
_COMPASS_SIZE,
_FONT_SIZE,
_ROUTE_WIDTH,
_STRUCTURE_FONT_SIZE,
_STRUCTURE_SIZE_MM,
_TITLE_FONT_SIZE,
CONTOUR_COLOR,
MM,
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,
frame_entities,
scale_fields,
)
from config.config_system import DRAWING_SCALE_PLAN
# 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저).
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),
("cut_outline", "절토 외곽선", False),
("centerline", "중심선", False),
("ip", "IP", False),
("radius", "R", False),
("ip_table", "IP 표", False),
("station", "측점 NO", False),
("endpoints", "시종점", False),
("refuge", "대피소 · 사토장", False),
("drainage", "배수시설 기호", False),
# 용지도(93장) — 연속지적도 · 행정구역
("parcel", "필지 경계", False),
("parcel_info", "필지 정보", False),
("emd", "읍면동 경계", False),
("sgg", "시군구 경계", False),
)
PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES)
# 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다.
LEGACY_PIECES: dict[str, tuple[str, ...]] = {
"slope_outline": ("road_edge", "fill_outline", "cut_outline"),
}
_OUTLINE = LEGACY_PIECES["slope_outline"]
_TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius")
# 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다.
DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
{"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]},
{
"id": "route",
"name": "노선배치도",
"pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints", "ip_table"],
},
{
"id": "layout",
"name": "대피소",
"pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "refuge"],
},
# 라이다 = 지형과 같은 정보 · 배경만 라이다(등고선 · 계류 대신).
{
"id": "lidar",
"name": "라이다",
"pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"],
},
# 용지도 = 옛 따로 도면과 같은 정보(등고선 · 노선 · 지적 · 행정 경계).
{
"id": "landuse",
"name": "용지도",
"pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"],
},
# 배수유역도 = 양식이 다른 종류(1/6,000 한 장 · 정보표) — 조각을 고르지 않는다(109장).
{"id": "watershed", "name": "배수유역도", "pieces": [], "layout": "watershed"},
)
_COLORS = {
"contour": CONTOUR_COLOR,
"stream": STREAM_COLOR,
"centerline": "#ff4d4d",
"road_edge": "#4dd8e6",
"fill_outline": "#4dd8e6",
"cut_outline": "#4dd8e6",
"slope_ticks": "#ff4d4d",
"ip": "#4d7dff",
"radius": "#4d7dff",
"station": "#ffe066",
"endpoints": "#ff9d4d",
"refuge": "#7ddc7d",
"drainage": STRUCTURE_COLOR,
"parcel": "#8c6b4f",
"parcel_info": "#a8703f", # 밝음 · 어두움 바탕 둘 다 읽히는 중간 밤색
"emd": "#2f7d4f",
"sgg": "#a63d3d",
}
# 행정 · 지적 경계 (선굵기, 파선)
_BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = {
"parcel": (1, None),
"emd": (2, [8, 4]),
"sgg": (3, [14, 5, 3, 5]),
}
PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
_LABEL_FONT = 2.5
_MAJOR_FONT = 4.5
_TICK_MM = 1.5
_CIRCLE_MM = 1.2
def layer_id(piece: str) -> str:
return f"b07-plan-{piece}"
def curve_pieces(
curves: list[dict[str, Any]],
route: list[tuple[float, float, float]],
box: tuple[float, float, float, float],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""곡선표에서 IP(교각점 · 접선)와 R 라벨을 만든다. 번호는 노선 전체 차례(IP1 …)."""
ips: list[dict[str, Any]] = []
radii: list[dict[str, Any]] = []
min_x, min_y, max_x, max_y = box
for number, curve in enumerate(curves, start=1):
apex, start, end = curve.get("apex"), curve.get("start"), curve.get("end")
radius = curve.get("radius_m")
if not (apex and start and end) or not isinstance(radius, (int, float)):
continue
if not (min_x <= apex[0] <= max_x and min_y <= apex[1] <= max_y):
continue
ips.append(
{
"name": f"IP{number}",
"apex": list(apex[:2]),
"start": list(start[:2]),
"end": list(end[:2]),
}
)
# 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]),
default=(apex[0], apex[1]),
)
dx, dy = middle[0] - apex[0], middle[1] - apex[1]
length = math.hypot(dx, dy) or 1.0
radii.append(
{
"label": f"R={round(float(radius), 1):g}",
"middle": list(middle),
"dir": [dx / length, dy / length],
}
)
return {"items": ips}, {"items": radii}
# ── 도면 조립 ─────────────────────────────────────────
def _circle(center: tuple[float, float], radius: float) -> list[tuple[float, float]]:
return [
(center[0] + radius * math.cos(a), center[1] + radius * math.sin(a))
for a in (2 * math.pi * i / 24 for i in range(25))
]
def _reading(nx: float, ny: float) -> tuple[float, float]:
"""글자 방향 — 노선 방향(법선을 돌린 것)으로 쓰되 거꾸로 서지 않게."""
tx, ty = ny, -nx
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,
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(
drawing_id,
[paper(p) for p in points],
layer,
color,
suffix=suffix,
width=width,
dash=dash,
)
if entity:
out.append(entity)
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":
image, box = data.get("image"), data.get("box")
if image and box:
left, bottom = paper((box[0], box[1]))
right, top = paper((box[2], box[3]))
corners = [(left, bottom), (right, bottom), (right, top), (left, top)]
out.append(
{
"id": f"{drawing_id}:lidar",
"type": "Image",
"lineColor": "#ffffff",
"lineWidth": 1,
"layerId": layer,
"shapeData": {
"points": [{"x": x, "y": y} for x, y in corners],
"imageData": image,
},
}
)
elif piece in ("contour", "stream", *_OUTLINE):
for index, points in enumerate(data.get("lines") or []):
line(points, f":{index}")
elif piece == "centerline":
for index, points in enumerate(data.get("lines") or []):
line(points, f":{index}", width=_ROUTE_WIDTH)
elif piece == "slope_ticks":
for index, points in enumerate(data.get("ticks") or []):
line(points, f":{index}")
elif piece == "ip":
for index, item in enumerate(data.get("items") or []):
line([item["start"], item["apex"], item["end"]], f":{index}", dash=[4, 2])
apex = paper(item["apex"])
out_circle = polyline_entity(
drawing_id, _circle(apex, _CIRCLE_MM), layer, color, suffix=f":c:{index}"
)
if out_circle:
out.append(out_circle)
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 []):
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":
for index, item in enumerate(data.get("items") or []):
cx, cy = paper((item["x"], item["y"]))
nx, ny = item["nx"], item["ny"]
line(
[
(item["x"] - nx * _TICK_MM / mm, item["y"] - ny * _TICK_MM / mm),
(item["x"] + nx * _TICK_MM / mm, item["y"] + ny * _TICK_MM / mm),
],
f":{index}",
)
if item.get("major"):
circle = polyline_entity(
drawing_id, _circle((cx, cy), _CIRCLE_MM), layer, color, suffix=f":c:{index}"
)
if circle:
out.append(circle)
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"],
_MAJOR_FONT,
[
{
"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 []):
nx, ny = item["nx"], item["ny"]
reach = 6.0 / mm # 종이 6mm 막대
line(
[
(item["x"] - nx * reach, item["y"] - ny * reach),
(item["x"] + nx * reach, item["y"] + ny * reach),
],
f":{index}",
width=2,
)
cx, cy = paper((item["x"], item["y"]))
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 []):
x, y, half = item["x"], item["y"], size / mm
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"], _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 []):
cx, cy = paper((item["x"], item["y"]))
corners = [(0, 1), (1, 0), (0, -1), (-1, 0), (0, 1)]
diamond = polyline_entity(
drawing_id,
[(cx + sx * size, cy + sy * size) for sx, sy in corners],
layer,
color,
suffix=f":{index}",
)
if diamond:
out.append(diamond)
text(
f"{index}", item["label"], _STRUCTURE_FONT_SIZE, _side_spots(cx, cy, size), (cx, cy)
)
elif piece in _BOUNDARY_STYLE:
width, dash = _BOUNDARY_STYLE[piece]
for index, points in enumerate(data.get("lines") or []):
line(points, f":{index}", width=width, dash=dash)
elif piece == "parcel_info":
for index, item in enumerate(data.get("items") or []):
out.extend(
_balloon_entities(
f"{drawing_id}:{piece}",
str(index),
item["lines"],
paper(item["anchor"]),
paper(item["center"]),
"rect",
layer=layer,
color=color,
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,
box: tuple[float, float, float, float],
pieces: dict[str, dict[str, Any]],
scale: int = DRAWING_SCALE_PLAN,
) -> dict[str, Any]:
"""고른 조각만 얹어 계획평면도 한 장을 만든다. `box` = 그 장 도곽 범위(실좌표 m)."""
min_x, min_y = box[0], box[1]
mm = 1000.0 / scale
def paper(point: Any) -> tuple[float, float]:
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:
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(
compass_entities(
drawing_id,
(
frame_box[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0,
frame_box[3] - _COMPASS_SIZE / 2.0,
),
_COMPASS_SIZE,
)
)
bbox = entities_bbox(entities) or frame_box
min_bx, _min_by, max_bx, max_by = bbox
entities.append(
_text_entity(
f"{drawing_id}:title",
label,
(min_bx + max_bx) / 2.0,
max_by + 12.0,
"b07-plan-title",
_TITLE_FONT_SIZE,
TABLE_LABEL_COLOR,
)
)
entities.append(
_text_entity(
f"{drawing_id}:scale",
f"S = 1/{scale:,}",
max_bx,
max_by + 5.0,
"b07-plan-title",
_FONT_SIZE,
TABLE_LABEL_COLOR,
align="right",
)
)
entities.extend(
frame_entities(
drawing_id,
entities_bbox(entities) or bbox,
fit=False,
fields={"도면명": label, **scale_fields(("", scale))},
)
)
layers = [
_layer(layer_id(piece), name, locked=locked)
for piece, name, locked in PIECES
if piece in pieces
]
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [
*layers,
*([_layer(SHEET_LAYER, "맞춤선 · 이웃 구간")] if links else []),
_layer("b07-plan-title", "표제"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
}