Files
Aislo/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py
T
eomsangdonandClaude Opus 5.5 f05d77d642 feat(B07): 평면도 축척 여덟 · 실거리 상수 축척화 · 막힌 글 먼 자리(141-2)
- 축척 = 1/500 · 600 · 1,000 · 1,200(기본) · 2,500 · 3,000 · 5,000 · 6,000(지식DB 근거)
- 비탈 눈금 간격 · 대피소 띄움 · 필지 버퍼 · 틈 = 1/1,200 실거리 × 축척 비(종이 거리 같게 · 1/6,000 눈금 10 m)
- 글 후보가 다 막히면 기호에서 2 · 3 배 먼 자리 + 지시선 — 검증_sub8 1/6,000 겹친 쌍 55 → 4
- 시험 test_119 · test_141 늘림

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

616 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장).
저장 자리(프로젝트 저장소 `B07_DesignDetail/`):
plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout, scale} — 파일 하나씩
plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m)
plan_marks.json 사용자가 정한 대피소 · 사토장 자리
`plan_kinds/` 가 없으면 기본 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도)를
쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각
없음). 처음 고칠 때 기본 종류를 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 종류도
고치고 지울 수 있다.
조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경)
에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다.
"""
import re
import uuid
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import (
_load_design_curves,
cross_station,
find_cross_file,
)
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines, ip_table_piece
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_structure_label,
plan_area_mm,
plan_chunks,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
DEFAULT_KINDS,
LEGACY_PIECES,
PIECE_IDS,
curve_pieces,
route_frame,
side_is_cut,
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_stations,
watershed_source,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_design_root,
_read_json,
_read_manifest,
_write_manifest,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
LABEL_CLIPPED,
LANDUSE_PIECES,
label_rects,
landuse_pieces,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import (
DRAWING_SCALE_BASIN,
DRAWING_SCALE_PLAN,
DRAWING_SCALE_PLAN_CHOICES,
)
KIND_ID = re.compile(r"^[a-z][a-z0-9]{0,31}$")
PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$")
_NAME_MAX = 40
WATERSHED_LAYOUT = "watershed"
_LAYOUTS = ("plan", WATERSHED_LAYOUT)
_REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"}
_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 기호를 중심선에서 띄우는 거리(좌측 · 1/1,200 실거리)
def plan_title(name: str) -> str:
return f"평면도({name})"
# ── 종류 ─────────────────────────────────────────────
def _kinds_dir(project_root: Path) -> Path:
return _design_root(project_root) / "plan_kinds"
# 이관 표시 `_{id}_added` — 있으면 그 기본 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음).
_ADDED_ONCE = ("landuse", "watershed")
def _scale(layout: str, value: Any) -> int:
"""종류 축척 분모 — 유역도 양식은 1/6,000 고정 · 없거나 목록 밖이면 1/1,200(옛 파일)."""
if layout == WATERSHED_LAYOUT:
return DRAWING_SCALE_BASIN
return value if value in DRAWING_SCALE_PLAN_CHOICES else DRAWING_SCALE_PLAN
def _document(kind: dict[str, Any], order: int) -> dict[str, Any]:
layout = kind.get("layout", "plan")
return {
"name": kind["name"],
"pieces": list(kind["pieces"]),
"order": order,
"layout": layout,
"scale": _scale(layout, kind.get("scale")),
}
def _add_defaults_once(folder: Path) -> None:
"""기본 종류가 늘기 전에 종류 파일을 내린 프로젝트 — 늘어난 기본 종류를 한 번 더한다."""
pending = [kind_id for kind_id in _ADDED_ONCE if not (folder / f"_{kind_id}_added").exists()]
if not pending:
return
documents = []
for path in folder.glob("*.json"):
try:
documents.append(_read_json(path))
except (OSError, ValueError):
continue
names = {str(document.get("name")) for document in documents}
order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1
for kind_id in pending:
kind = next(kind for kind in DEFAULT_KINDS if kind["id"] == kind_id)
if not (folder / f"{kind_id}.json").exists() and kind["name"] not in names:
atomic_write_json(folder / f"{kind_id}.json", _document(kind, order))
order += 1
(folder / f"_{kind_id}_added").touch()
def list_kinds(project_root: Path) -> list[dict[str, Any]]:
"""종류 목록 [{id, name, pieces, layout, scale}] — 차례대로. 파일이 없으면 기본 종류."""
folder = _kinds_dir(project_root)
if not folder.is_dir():
return [
{
"layout": "plan",
**kind,
"pieces": list(kind["pieces"]),
"scale": _scale(kind.get("layout", "plan"), None),
}
for kind in DEFAULT_KINDS
]
_add_defaults_once(folder)
rows: list[tuple[int, str, dict[str, Any]]] = []
for path in folder.glob("*.json"):
if not KIND_ID.fullmatch(path.stem):
continue
try:
document = _read_json(path)
except (OSError, ValueError):
continue
stored = set()
for piece in document.get("pieces") or []:
stored.update(LEGACY_PIECES.get(piece, (piece,)))
layout = document.get("layout") if document.get("layout") in _LAYOUTS else "plan"
pieces = [] if layout == WATERSHED_LAYOUT else [p for p in PIECE_IDS if p in stored]
rows.append(
(
int(document.get("order") or 0),
path.stem,
{
"id": path.stem,
"name": str(document.get("name") or path.stem),
"pieces": pieces,
"layout": layout,
"scale": _scale(layout, document.get("scale")),
},
)
)
return [row for _order, _id, row in sorted(rows, key=lambda item: item[:2])]
def _materialize(project_root: Path) -> list[dict[str, Any]]:
"""기본 종류를 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번."""
kinds = list_kinds(project_root)
folder = _kinds_dir(project_root)
if not folder.is_dir():
for order, kind in enumerate(kinds):
atomic_write_json(folder / f"{kind['id']}.json", _document(kind, order))
for kind_id in _ADDED_ONCE:
(folder / f"_{kind_id}_added").touch()
return kinds
def _checked(name: str, pieces: list[str], layout: str = "plan") -> tuple[str, list[str]]:
text = str(name or "").strip()
if not text or len(text) > _NAME_MAX:
raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.")
if layout not in _LAYOUTS:
raise ValueError("올바르지 않은 양식입니다.")
if layout == WATERSHED_LAYOUT:
return text, [] # 유역도 양식은 그림이 정해져 있다 — 조각을 고르지 않음
chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])]
if not chosen:
raise ValueError("얹을 정보를 하나 이상 고르세요.")
return text, chosen
def _checked_scale(layout: str, scale: int | None, current: int = DRAWING_SCALE_PLAN) -> int:
"""고른 축척 — 없으면 지금 값 · 목록 밖이면 거절 · 유역도 양식은 1/6,000 고정."""
if layout == WATERSHED_LAYOUT:
return DRAWING_SCALE_BASIN
if scale is None:
return current
if scale not in DRAWING_SCALE_PLAN_CHOICES:
raise ValueError("고를 수 없는 축척입니다.")
return scale
def add_kind(
project_root: Path,
name: str,
pieces: list[str],
layout: str = "plan",
scale: int | None = None,
) -> dict[str, Any]:
text, chosen = _checked(name, pieces, layout)
chosen_scale = _checked_scale(layout, scale)
kinds = _materialize(project_root)
if any(kind["name"] == text for kind in kinds):
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
kind = {
"id": f"k{uuid.uuid4().hex[:8]}",
"name": text,
"pieces": chosen,
"layout": layout,
"scale": chosen_scale,
}
atomic_write_json(_kinds_dir(project_root) / f"{kind['id']}.json", _document(kind, len(kinds)))
return kind
def update_kind(
project_root: Path, kind_id: str, name: str, pieces: list[str], scale: int | None = None
) -> dict[str, Any]:
"""종류 이름 · 조각 · 축척을 고친다(차례 · 양식은 그대로). 그 종류 확정본은 풀어 다시 그린다."""
if not KIND_ID.fullmatch(kind_id):
raise ValueError("올바르지 않은 종류 id 입니다.")
kinds = _materialize(project_root)
current = next((kind for kind in kinds if kind["id"] == kind_id), None)
path = _kinds_dir(project_root) / f"{kind_id}.json"
if current is None or not path.is_file():
raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.")
text, chosen = _checked(name, pieces, current["layout"])
chosen_scale = _checked_scale(current["layout"], scale, current["scale"])
if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds):
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
order = int(_read_json(path).get("order") or 0)
kind = {
"id": kind_id,
"name": text,
"pieces": chosen,
"layout": current["layout"],
"scale": chosen_scale,
}
atomic_write_json(path, _document(kind, order))
manifest = _read_manifest(project_root)
released = False
for drawing_id, entry in manifest["drawings"].items():
match = PLAN_KIND_ID.fullmatch(drawing_id)
if match and match.group(1) == kind_id and entry.get("confirmed"):
entry["confirmed"] = False
released = True
if released:
_write_manifest(project_root, manifest)
return kind
def delete_kind(project_root: Path, kind_id: str) -> None:
if not KIND_ID.fullmatch(kind_id):
raise ValueError("올바르지 않은 종류 id 입니다.")
_materialize(project_root)
path = _kinds_dir(project_root) / f"{kind_id}.json"
if not path.is_file():
raise FileNotFoundError("지울 종류를 찾을 수 없습니다.")
path.unlink()
# ── 대피소 · 사토장 자리(사용자 지정) ─────────────────
def _marks_path(project_root: Path) -> Path:
return _design_root(project_root) / "plan_marks.json"
def read_marks(project_root: Path) -> list[dict[str, Any]]:
path = _marks_path(project_root)
if not path.is_file():
return []
try:
marks = _read_json(path).get("marks")
except (OSError, ValueError):
return []
return [mark for mark in marks or [] if isinstance(mark, dict)]
def write_marks(project_root: Path, marks: list[dict[str, Any]]) -> list[dict[str, Any]]:
clean: list[dict[str, Any]] = []
for mark in marks:
chainage = mark.get("chainage_m")
if not isinstance(chainage, (int, float)) or chainage < 0:
raise ValueError("대피소 · 사토장 자리의 측점(m)이 올바르지 않습니다.")
refuge, spoil = bool(mark.get("refuge")), bool(mark.get("spoil"))
if refuge or spoil:
clean.append(
{"chainage_m": round(float(chainage), 2), "refuge": refuge, "spoil": spoil}
)
clean.sort(key=lambda mark: mark["chainage_m"])
atomic_write_json(_marks_path(project_root), {"marks": clean})
return clean
# ── 정보 조각 ─────────────────────────────────────────
def _station_design(
longitudinal_path: Path,
longitudinal: dict[str, Any],
source: dict[str, Any],
stored: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""그 측점의 횡단 설계 — B06 저장분 우선, 없으면 기본값으로 계산(횡단면도와 같은 규칙)."""
if isinstance(stored, dict) and stored.get("design_line") and stored.get("road_edges"):
return stored
try:
return compute_cross_design(
source.get("samples", []),
design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))),
ground_type="soil",
section_mode="left_cut",
**curve_widening_args(source),
)
except (ValueError, KeyError):
return None
def slope_records(
longitudinal_path: Path,
longitudinal: dict[str, Any],
designs: dict[int | float, dict[str, Any]],
start_m: float,
end_m: float,
) -> list[dict[str, Any]]:
"""측점별 노견 끝 · 비탈 끝 오프셋(좌 + · 우 -) — 장 범위와 앞뒤 한 측점."""
cross_dir = longitudinal_path.parent.parent / "cross_sections"
chainages = sorted(chainage for chainage, _x, _y in plan_stations(longitudinal))
inside = [i for i, c in enumerate(chainages) if start_m <= c <= end_m]
if not inside:
return []
chosen = chainages[max(inside[0] - 1, 0) : inside[-1] + 2]
records: list[dict[str, Any]] = []
for chainage in chosen:
path = find_cross_file(cross_dir, chainage)
if not path.is_file():
continue
source = _read_json(path)
design = _station_design(
longitudinal_path, longitudinal, source, designs.get(cross_station(chainage))
)
edges = (design or {}).get("road_edges") or {}
try:
left_edge = float(edges["left"]["offset_m"])
right_edge = float(edges["right"]["offset_m"])
except (KeyError, TypeError, ValueError):
continue
line, samples = (design or {}).get("design_line") or [], source.get("samples") or []
record: dict[str, Any] = {
"chainage_m": float(chainage),
"left_edge_m": left_edge,
"left_end_m": slope_end(line, samples, left_edge, "left"),
"right_edge_m": right_edge,
"right_end_m": slope_end(line, samples, right_edge, "right"),
}
for side in ("left", "right"):
elevation = edges[side].get("elevation_m")
if isinstance(elevation, (int, float)):
record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation)
frame = source.get("frame") or {}
origin, axis = frame.get("origin") or {}, frame.get("left_xy")
if isinstance(axis, list) and len(axis) == 2 and "x" in origin and "y" in origin:
record.update(x=float(origin["x"]), y=float(origin["y"]), nx=axis[0], ny=axis[1])
records.append(record)
return records
def _refuge_items(
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:
label = _REFUGE_TYPES.get(structure.type_id)
if not label:
continue
chainage = structure.chainage_m
if chainage is None and structure.start_m is not None and structure.end_m is not None:
chainage = (structure.start_m + structure.end_m) / 2.0
if chainage is not None:
spots.setdefault(round(float(chainage), 1), set()).add(label)
for mark in read_marks(project_root):
key = round(float(mark["chainage_m"]), 1)
if mark.get("refuge"):
spots.setdefault(key, set()).add("대피소")
if mark.get("spoil"):
spots.setdefault(key, set()).add("사토장")
items = []
for chainage, labels in sorted(spots.items()):
if not (start_m <= chainage <= end_m) or len(route) < 2:
continue
x, y, nx, ny = route_frame(route, chainage)
items.append(
{
"chainage_m": chainage,
"label": " · ".join(name for name in ("대피소", "사토장") if name in labels),
"x": x + nx * offset,
"y": y + ny * offset,
}
)
return {"items": items}
def build_pieces(
context: Any,
longitudinal: dict[str, Any],
longitudinal_path: Path,
designs: dict[int | float, dict[str, Any]],
number: int,
wanted: list[str],
scale: int = DRAWING_SCALE_PLAN,
) -> tuple[dict[str, dict[str, Any]], tuple[float, float, float, float], dict[str, Any], int]:
"""그 장의 조각들을 계산해 파일로 쓰고 (조각들, 도곽 범위, 장 구간, 전체 장수)를 낸다."""
route_xy, chunk, total = _chunk_route(context, longitudinal, number, scale)
if not route_xy:
raise FileNotFoundError("계획평면도에 그릴 노선이 없습니다.")
box = _sheet_box(route_xy, scale)
mm = 1000.0 / scale
start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"])
project_root = Path(context.project_root)
route = sorted(
(float(vertex.chainage_m), float(vertex.x), float(vertex.y)) for vertex in context.vertices
)
stations = plan_stations(longitudinal)
interval = infer_station_interval(longitudinal.get("stations") or [])
pieces: dict[str, dict[str, Any]] = {}
if {"contour", "stream"} & set(wanted):
background = map_background(project_root, context.crs, scale, plan_area_mm(), route_xy)
pieces["contour"] = {"lines": background["contours"]}
pieces["stream"] = {"lines": background["streams"]}
if "centerline" in wanted:
pieces["centerline"] = {"lines": [route_xy]}
if {*LEGACY_PIECES["slope_outline"], "slope_ticks"} & set(wanted):
records = slope_records(longitudinal_path, longitudinal, designs, start_m, end_m)
(
pieces["road_edge"],
pieces["fill_outline"],
pieces["cut_outline"],
pieces["slope_ticks"],
) = 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:
items = []
for chainage, x, y in stations:
if not (start_m <= chainage <= end_m):
continue
_x, _y, nx, ny = route_frame(route, chainage)
label = station_no_label(chainage, interval).replace("No.", "NO.")
number_only = "+" not in label
major = number_only and int(label[3:]) % 5 == 0
items.append(
{
"chainage_m": chainage,
"x": x,
"y": y,
"nx": nx,
"ny": ny,
"label": label,
"major": major,
}
)
pieces["station"] = {"items": items}
if "endpoints" in wanted and stations:
items = []
first, last = stations[0], stations[-1]
length = last[0] - first[0]
for (chainage, x, y), word in ((first, "시점"), (last, "종점")):
if not (start_m <= chainage <= end_m):
continue
_x, _y, nx, ny = route_frame(route, chainage)
label = f"{word} {station_no_label(chainage, interval)}"
if word == "종점":
label += f" L={length:.1f}m"
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, scale)
if "drainage" in wanted:
min_x, min_y, max_x, max_y = box
pieces["drainage"] = {
"items": [
{"x": float(item["x"]), "y": float(item["y"]), "label": _structure_label(item)}
for item in _plan_structures(context)
if isinstance(item.get("x"), (int, float))
and isinstance(item.get("y"), (int, float))
and min_x <= item["x"] <= max_x
and min_y <= item["y"] <= max_y
]
}
if "lidar" in wanted:
try:
image, image_box = _hillshade_for_box(context, box)
pieces["lidar"] = {"image": image, "box": list(image_box)}
except (FileNotFoundError, ValueError, OSError):
pieces["lidar"] = {"image": None, "box": None}
if set(LANDUSE_PIECES) & set(wanted):
# 라벨은 이 장 구간이 아니라 노선 전체를 비켜 간다 — 도곽 안에 다른 구간이 들 수 있다.
pieces.update(
landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route], mm)
)
if "parcel_info" in wanted:
clip_lines(pieces, label_rects(pieces["parcel_info"]["items"], mm), LABEL_CLIPPED)
if "ip_table" in wanted:
pieces["ip_table"] = ip_table_piece(
pieces, _load_design_curves(project_root), route, box, chunk, total, mm
)
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
def plan_kind_source(
context: Any,
longitudinal: dict[str, Any],
longitudinal_path: Path,
designs: dict[int | float, dict[str, Any]],
drawing_id: str,
) -> dict[str, Any]:
"""종류 도면 한 장의 입력 — 그 종류가 고른 조각 + 도곽 범위 + 도면명."""
match = PLAN_KIND_ID.fullmatch(drawing_id)
if not match:
raise ValueError("올바르지 않은 계획평면도 ID입니다.")
kind = next(
(item for item in list_kinds(Path(context.project_root)) if item["id"] == match.group(1)),
None,
)
if kind is None:
raise FileNotFoundError("계획평면도 종류를 찾을 수 없습니다.")
if kind["layout"] == WATERSHED_LAYOUT:
# 유역도 양식 — B04 배수유역 산출물 + 도엽 배경(사업지 CRS) 한 장.
return {"layout": WATERSHED_LAYOUT, "label": kind["name"], **watershed_source(context)}
number = int(match.group(2)) if match.group(2) else 1
pieces, box, chunk, total = build_pieces(
context, longitudinal, longitudinal_path, designs, number, kind["pieces"], kind["scale"]
)
title = plan_title(kind["name"])
return {
"label": title if total <= 1 else f"{title} {chunk['number']}장",
"box": box,
"pieces": pieces,
"scale": kind["scale"],
}
def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
"""도면 목록에 실을 종류 × 장 [{id, label, plan_kind}]."""
stations = plan_stations(longitudinal)
by_scale: dict[int, list[dict[str, Any]]] = {}
items = []
for kind in list_kinds(project_root):
if kind["layout"] == WATERSHED_LAYOUT: # 1/6,000 한 장 — 장 나눔 없음
items.append(
{
"id": f"plan_{kind['id']}",
"label": kind["name"],
"plan_kind": kind["id"],
"plan_kind_name": kind["name"],
}
)
continue
title = plan_title(kind["name"])
if kind["scale"] not in by_scale:
by_scale[kind["scale"]] = plan_chunks(stations, kind["scale"])
sheets = by_scale[kind["scale"]]
for chunk in sheets:
single = len(sheets) <= 1
items.append(
{
"id": f"plan_{kind['id']}"
if single
else f"plan_{kind['id']}_{chunk['number']}",
"label": title if single else f"{title} {chunk['number']}장",
"plan_kind": kind["id"],
"plan_kind_name": kind["name"],
}
)
return items