Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py
T
eomsangdonandClaude Opus 5.5 138e80c79f feat(B07): 계획평면도 종류 · 정보 조각 서버 (PLAN 60-1 · 60-2)
- 정보 조각(등고선 · 계류 · 중심선 · 성토사면 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · 대피소/사토장 · 라이다 배경)마다 장별 파일(plan_pieces/{장}/{조각}.json)
- 외곽선 = 노견 끝 + 설계선이 지반에 닿는 자리(B06 횡단 설계 결과) · 측점 자리는 횡단 파일 중심 · 좌축
- 종류 = 조각 고르기 목록(plan_kinds/{id}.json) · 기본 넷(지형 · 노선배치도 · 대피소 · 라이다) · 더하기 · 지우기 API
- 대피소 · 사토장 = 구조물 정본 refuge · spoil_bank + 사용자 지정 자리(plan_marks.json)
- 도면 목록 · 단건 조회를 종류 × 장으로 · 옛 plan_* id 그대로 이어짐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-06 17:51:40 +09:00

476 lines
18 KiB
Python

"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장).
계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다.
정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 ·
대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중
무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음).
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200).
"""
import math
from bisect import bisect_left
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_Plan import (
_COMPASS_MARGIN,
_COMPASS_SIZE,
_FONT_SIZE,
_ROUTE_WIDTH,
_TITLE_FONT_SIZE,
CONTOUR_COLOR,
MM,
STREAM_COLOR,
)
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),
("slope_ticks", "비탈 눈금", False),
("slope_outline", "성토사면 외곽선", False),
("centerline", "중심선", False),
("ip", "IP", False),
("radius", "R", False),
("station", "측점 NO", False),
("endpoints", "시종점", False),
("refuge", "대피소 · 사토장", False),
)
PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES)
_TERRAIN = ("contour", "stream", "centerline", "slope_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"],
},
{
"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"],
},
)
_COLORS = {
"contour": CONTOUR_COLOR,
"stream": STREAM_COLOR,
"centerline": "#ff4d4d",
"slope_outline": "#4dd8e6",
"slope_ticks": "#ff4d4d",
"ip": "#4d7dff",
"radius": "#4d7dff",
"station": "#ffe066",
"endpoints": "#ff9d4d",
"refuge": "#7ddc7d",
}
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
_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 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 slope_end(
design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str
) -> float:
"""노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝.
설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다.
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
"""
ground = 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))
)
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
offsets = [offset for offset, _z in ground]
def ground_at(offset: float) -> float:
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)
end, differs = edge, False
for offset, elevation in outward:
if abs(elevation - ground_at(offset)) <= 1e-3:
return offset if differs else end
differs, end = True, offset
return end
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]]:
"""측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다.
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
(x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로
써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다.
"""
records = sorted(records, key=lambda record: record["chainage_m"])
if len(route) < 2 or not records:
return {"lines": [], "stations": []}, {"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}
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 ("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]])
outline = {
"lines": [lines[key] for key in keys],
"stations": [
{key: record[key] for key in ("chainage_m", *keys)}
for record in records
if start_m <= record["chainage_m"] <= end_m
],
}
return outline, {"ticks": ticks}
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 라벨은 곡선 한가운데에서 교각점 반대쪽(곡선 안쪽)으로 조금 띄운다.
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}",
"at": [middle[0] + dx / length * 4.0, middle[1] + dy / length * 4.0],
}
)
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 _piece_entities(
drawing_id: str, piece: str, data: dict[str, Any], paper: Any
) -> list[dict[str, Any]]:
layer, color = layer_id(piece), _COLORS.get(piece, TABLE_LABEL_COLOR)
out: list[dict[str, Any]] = []
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, 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
)
)
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", "slope_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)
text(f"{index}", item["name"], (apex[0], apex[1] - _CIRCLE_MM - 3.0), _LABEL_FONT)
elif piece == "radius":
for index, item in enumerate(data.get("items") or []):
text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT)
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 = _MAJOR_FONT * 1.8
text(
f"{index}",
item["label"],
(cx + nx * gap, cy + ny * gap),
_MAJOR_FONT,
direction=_reading(nx, ny),
)
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"], (cx + nx * 9.0, cy + ny * 9.0), _LABEL_FONT + 0.5)
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"], (cx + size + 1.0, cy), _LABEL_FONT, align="left")
return out
def build_plan_kind_drawing(
drawing_id: str,
label: str,
box: tuple[float, float, float, float],
pieces: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""고른 조각만 얹어 계획평면도 한 장을 만든다. `box` = 그 장 도곽 범위(실좌표 m)."""
min_x, min_y = box[0], box[1]
def paper(point: Any) -> tuple[float, float]:
return ((point[0] - min_x) * MM, (point[1] - min_y) * MM)
entities: list[dict[str, Any]] = []
for piece in PIECE_IDS:
if piece in pieces:
entities.extend(_piece_entities(drawing_id, piece, pieces[piece], paper))
# 도곽 · 방위표 자리는 조각과 무관하게 같은 자리 — 그 장 범위를 기준으로 잡는다.
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/{DRAWING_SCALE_PLAN:,}",
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(("", DRAWING_SCALE_PLAN))},
)
)
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("b07-plan-title", "표제"),
_layer(FRAME_LAYER_ID, "도각", locked=True),
],
}