feat(B07): 계획평면도 조각 잘게 · 종류 업데이트 API (67-1)
- 성토사면 외곽선 → 노견선 · 성토 외곽선 · 절토 외곽선 셋 · 배수시설 기호 조각 신설 · 라이다 음영
- 기본 넷 처음 체크 = 지금 그림(배수시설 기호는 빼 둠) · 옛 종류 파일 slope_outline 은 셋으로 펼침
- PUT /plan-kinds/{id} — 이름 · 조각 고침(기본 넷 포함) · 그 종류 확정본 풀림
- GET /plan-kinds 에 측점 간격 실음 — 측점 NO + 거리 입력용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장).
|
||||
|
||||
계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다.
|
||||
정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 ·
|
||||
대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중
|
||||
무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음).
|
||||
정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO ·
|
||||
시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩
|
||||
있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만
|
||||
한다(파일 입출력 없음).
|
||||
|
||||
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200).
|
||||
"""
|
||||
@@ -25,10 +26,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_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_Template import (
|
||||
compass_entities,
|
||||
@@ -40,21 +44,29 @@ from config.config_system import DRAWING_SCALE_PLAN
|
||||
|
||||
# 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저).
|
||||
PIECES: tuple[tuple[str, str, bool], ...] = (
|
||||
("lidar", "라이다 배경", True),
|
||||
("lidar", "라이다 음영", True),
|
||||
("contour", "등고선", True),
|
||||
("stream", "계류", True),
|
||||
("slope_ticks", "비탈 눈금", False),
|
||||
("slope_outline", "성토사면 외곽선", False),
|
||||
("road_edge", "노견선", False),
|
||||
("fill_outline", "성토 외곽선", False),
|
||||
("cut_outline", "절토 외곽선", False),
|
||||
("centerline", "중심선", False),
|
||||
("ip", "IP", False),
|
||||
("radius", "R", False),
|
||||
("station", "측점 NO", False),
|
||||
("endpoints", "시종점", False),
|
||||
("refuge", "대피소 · 사토장", False),
|
||||
("drainage", "배수시설 기호", 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", "slope_outline", "slope_ticks", "ip", "radius")
|
||||
_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"]},
|
||||
@@ -80,13 +92,16 @@ _COLORS = {
|
||||
"contour": CONTOUR_COLOR,
|
||||
"stream": STREAM_COLOR,
|
||||
"centerline": "#ff4d4d",
|
||||
"slope_outline": "#4dd8e6",
|
||||
"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,
|
||||
}
|
||||
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
|
||||
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
|
||||
@@ -129,6 +144,27 @@ def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float
|
||||
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:
|
||||
@@ -137,11 +173,7 @@ def slope_end(
|
||||
설계선(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))
|
||||
)
|
||||
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]
|
||||
@@ -150,36 +182,61 @@ def slope_end(
|
||||
)
|
||||
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:
|
||||
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]]:
|
||||
"""측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다.
|
||||
) -> 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,
|
||||
(x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로
|
||||
써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다.
|
||||
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
|
||||
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을
|
||||
따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||||
"""
|
||||
records = sorted(records, key=lambda record: record["chainage_m"])
|
||||
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": [], "stations": []}, {"ticks": []}
|
||||
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 = {
|
||||
@@ -192,6 +249,7 @@ def slope_pieces(
|
||||
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)
|
||||
@@ -206,6 +264,8 @@ def slope_pieces(
|
||||
}
|
||||
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"]
|
||||
@@ -213,15 +273,18 @@ def slope_pieces(
|
||||
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}
|
||||
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(
|
||||
@@ -326,7 +389,7 @@ def _piece_entities(
|
||||
},
|
||||
}
|
||||
)
|
||||
elif piece in ("contour", "stream", "slope_outline"):
|
||||
elif piece in ("contour", "stream", *_OUTLINE):
|
||||
for index, points in enumerate(data.get("lines") or []):
|
||||
line(points, f":{index}")
|
||||
elif piece == "centerline":
|
||||
@@ -395,6 +458,23 @@ def _piece_entities(
|
||||
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")
|
||||
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"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장).
|
||||
|
||||
종류 = 정보 조각 고르기 목록. 더하기 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다.
|
||||
종류 = 정보 조각 고르기 목록. 추가 · 업데이트 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다.
|
||||
대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다.
|
||||
"""
|
||||
|
||||
@@ -13,13 +13,17 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import _confirmed_source
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_json
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
add_kind,
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
read_marks,
|
||||
update_kind,
|
||||
write_marks,
|
||||
)
|
||||
|
||||
@@ -55,16 +59,27 @@ def _error(status: int, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)})
|
||||
|
||||
|
||||
async def _station_interval(project_id: UUID) -> float | None:
|
||||
"""종단 측점 간격(m) — 측점 NO + 거리 입력을 누가거리로 바꾸는 기준. 종단이 없으면 None."""
|
||||
try:
|
||||
_route_id, _root, longitudinal_path, _bypassed = await _confirmed_source(project_id)
|
||||
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||
except (FileNotFoundError, PermissionError, OSError, ValueError):
|
||||
return None
|
||||
return infer_station_interval(longitudinal.get("stations") or [])
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan-kinds")
|
||||
async def get_plan_kinds(project_id: UUID) -> Any:
|
||||
"""고를 수 있는 조각 · 지금 종류 · 대피소 자리."""
|
||||
"""고를 수 있는 조각 · 지금 종류 · 대피소 자리 · 측점 간격."""
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
state = await asyncio.to_thread(_state, root)
|
||||
return {**state, "station_interval_m": await _station_interval(project_id)}
|
||||
|
||||
|
||||
@router.post("/{project_id}/plan-kinds")
|
||||
async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any:
|
||||
"""종류 더하기 — 조각을 골라 이름을 붙인다."""
|
||||
"""종류 추가 — 조각을 골라 이름을 붙인다."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(add_kind, root, request.name, request.pieces)
|
||||
@@ -73,6 +88,19 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any:
|
||||
return _error(400, exc)
|
||||
|
||||
|
||||
@router.put("/{project_id}/plan-kinds/{kind_id}")
|
||||
async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest) -> Any:
|
||||
"""종류 업데이트 — 이름 · 조각을 고친다(기본 넷도)."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(update_kind, root, kind_id, request.name, request.pieces)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
except ValueError as exc:
|
||||
return _error(400, exc)
|
||||
except FileNotFoundError as exc:
|
||||
return _error(404, exc)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/plan-kinds/{kind_id}")
|
||||
async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any:
|
||||
"""종류 지우기 — 기본 넷도 지울 수 있다."""
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
plan_marks.json 사용자가 정한 대피소 · 사토장 자리
|
||||
|
||||
`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때
|
||||
기본 넷을 파일로 내려 두고 그 위에서 더하고 지운다 — 기본 넷도 지울 수 있다.
|
||||
기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다.
|
||||
조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경)
|
||||
에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다.
|
||||
"""
|
||||
@@ -24,23 +24,35 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
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_Plan import plan_area_mm, plan_chunks
|
||||
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_Router_Support_Basin import (
|
||||
_chunk_route,
|
||||
_hillshade_for_box,
|
||||
_plan_structures,
|
||||
_sheet_box,
|
||||
map_background,
|
||||
plan_stations,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _read_json
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_design_root,
|
||||
_read_json,
|
||||
_read_manifest,
|
||||
_write_manifest,
|
||||
)
|
||||
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_PLAN
|
||||
@@ -76,7 +88,10 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]:
|
||||
document = _read_json(path)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
pieces = [piece for piece in document.get("pieces") or [] if piece in PIECE_IDS]
|
||||
stored = set()
|
||||
for piece in document.get("pieces") or []:
|
||||
stored.update(LEGACY_PIECES.get(piece, (piece,)))
|
||||
pieces = [piece for piece in PIECE_IDS if piece in stored]
|
||||
rows.append(
|
||||
(
|
||||
int(document.get("order") or 0),
|
||||
@@ -100,13 +115,18 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]:
|
||||
return kinds
|
||||
|
||||
|
||||
def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]:
|
||||
text = str(name or "").strip()
|
||||
if not text or len(text) > _NAME_MAX:
|
||||
raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.")
|
||||
chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])]
|
||||
if not chosen:
|
||||
raise ValueError("얹을 정보를 하나 이상 고르세요.")
|
||||
return text, chosen
|
||||
|
||||
|
||||
def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
text, chosen = _checked(name, pieces)
|
||||
kinds = _materialize(project_root)
|
||||
if any(kind["name"] == text for kind in kinds):
|
||||
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
|
||||
@@ -118,6 +138,31 @@ def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]
|
||||
return {"id": kind_id, "name": text, "pieces": chosen}
|
||||
|
||||
|
||||
def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
"""종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다."""
|
||||
if not KIND_ID.fullmatch(kind_id):
|
||||
raise ValueError("올바르지 않은 종류 id 입니다.")
|
||||
text, chosen = _checked(name, pieces)
|
||||
kinds = _materialize(project_root)
|
||||
if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds):
|
||||
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
|
||||
path = _kinds_dir(project_root) / f"{kind_id}.json"
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.")
|
||||
order = int(_read_json(path).get("order") or 0)
|
||||
atomic_write_json(path, {"name": text, "pieces": chosen, "order": 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 {"id": kind_id, "name": text, "pieces": chosen}
|
||||
|
||||
|
||||
def delete_kind(project_root: Path, kind_id: str) -> None:
|
||||
if not KIND_ID.fullmatch(kind_id):
|
||||
raise ValueError("올바르지 않은 종류 id 입니다.")
|
||||
@@ -223,6 +268,10 @@ def slope_records(
|
||||
"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:
|
||||
@@ -298,11 +347,14 @@ def build_pieces(
|
||||
pieces["stream"] = {"lines": background["streams"]}
|
||||
if "centerline" in wanted:
|
||||
pieces["centerline"] = {"lines": [route_xy]}
|
||||
if {"slope_outline", "slope_ticks"} & set(wanted):
|
||||
if {*LEGACY_PIECES["slope_outline"], "slope_ticks"} & set(wanted):
|
||||
records = slope_records(longitudinal_path, longitudinal, designs, start_m, end_m)
|
||||
pieces["slope_outline"], pieces["slope_ticks"] = slope_pieces(
|
||||
route, records, start_m, end_m
|
||||
)
|
||||
(
|
||||
pieces["road_edge"],
|
||||
pieces["fill_outline"],
|
||||
pieces["cut_outline"],
|
||||
pieces["slope_ticks"],
|
||||
) = slope_pieces(route, records, start_m, end_m)
|
||||
if {"ip", "radius"} & set(wanted):
|
||||
pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box)
|
||||
if "station" in wanted:
|
||||
@@ -341,6 +393,18 @@ def build_pieces(
|
||||
pieces["endpoints"] = {"items": items}
|
||||
if "refuge" in wanted:
|
||||
pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m)
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음
|
||||
③ 대피소 · 사토장 자리 — 한 자리에 둘 다
|
||||
④ 고른 조각만 도면 층으로 얹힘
|
||||
⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 ·
|
||||
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -19,13 +21,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
layer_id,
|
||||
slope_pieces,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
_kinds_dir,
|
||||
_refuge_items,
|
||||
add_kind,
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
read_marks,
|
||||
slope_records,
|
||||
update_kind,
|
||||
write_marks,
|
||||
)
|
||||
|
||||
@@ -89,8 +94,11 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No
|
||||
longitudinal_path, longitudinal, designs = _project(tmp_path)
|
||||
records = slope_records(longitudinal_path, longitudinal, designs, 0.0, 40.0)
|
||||
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
|
||||
outline, ticks = slope_pieces(route, records, 0.0, 40.0)
|
||||
left_edge, left_end, right_edge, right_end = outline["lines"]
|
||||
edge, fill, cut, ticks = slope_pieces(route, records, 0.0, 40.0)
|
||||
left_edge, right_edge = edge["lines"]
|
||||
# 좌(+)가 높은 지반 — 좌측 비탈은 절토 · 우측은 성토, 각각 한 줄로 이어짐
|
||||
assert all(r["left_cut"] and not r["right_cut"] for r in records)
|
||||
(left_end,), (right_end,) = cut["lines"], fill["lines"]
|
||||
for chainage in (20, 40): # 견본 측점 둘
|
||||
design = {**designs[chainage], "_c": chainage}
|
||||
index = next(i for i, p in enumerate(left_edge) if abs(p[1] - chainage) < 1e-6)
|
||||
@@ -157,3 +165,68 @@ def test_drawing_has_only_chosen_pieces() -> None:
|
||||
assert layer_id("contour") not in layers and layer_id("station") not in layers
|
||||
labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"]
|
||||
assert "R=15" in labels and "계획평면도(시험)" in labels
|
||||
|
||||
|
||||
def test_split_runs_switch_cut_fill_and_stay_joined() -> None:
|
||||
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
|
||||
base = {"left_edge_m": 2.0, "left_end_m": 5.0, "right_edge_m": -2.0, "right_end_m": -5.0}
|
||||
records = [
|
||||
{"chainage_m": 0.0, **base, "left_cut": True, "right_cut": False},
|
||||
{"chainage_m": 40.0, **base, "left_cut": False, "right_cut": False},
|
||||
]
|
||||
_edge, fill, cut, _ticks = slope_pieces(route, records, 0.0, 40.0)
|
||||
(cut_left,) = cut["lines"]
|
||||
fill_left = next(line for line in fill["lines"] if line[0][0] < 0)
|
||||
assert cut_left[-1] == fill_left[0] # 바뀌는 자리에서 두 선이 이어짐
|
||||
assert cut_left[0][1] == 0.0 and fill_left[-1][1] == 40.0
|
||||
|
||||
|
||||
def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
outline = {"road_edge", "fill_outline", "cut_outline"}
|
||||
for (
|
||||
kind
|
||||
) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음
|
||||
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "old.json").write_text(
|
||||
json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8"
|
||||
)
|
||||
(kind,) = list_kinds(root)
|
||||
assert set(kind["pieces"]) == outline | {"station"}
|
||||
|
||||
|
||||
def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
manifest = _read_manifest(root)
|
||||
manifest["drawings"] = {
|
||||
"plan_terrain": {"confirmed": True},
|
||||
"plan_route": {"confirmed": True},
|
||||
}
|
||||
_write_manifest(root, manifest)
|
||||
update_kind(root, "terrain", "지형2", ["contour", "centerline"]) # 기본 종류 고침
|
||||
again = {k["id"]: k for k in list_kinds(root)} # 다시 읽어 같음
|
||||
assert again["terrain"] == {
|
||||
"id": "terrain",
|
||||
"name": "지형2",
|
||||
"pieces": ["contour", "centerline"],
|
||||
}
|
||||
assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로
|
||||
drawings = _read_manifest(root)["drawings"]
|
||||
assert drawings["plan_terrain"]["confirmed"] is False
|
||||
assert drawings["plan_route"]["confirmed"] is True
|
||||
with pytest.raises(ValueError):
|
||||
update_kind(root, "terrain", "노선배치도", ["contour"]) # 다른 종류와 같은 이름
|
||||
with pytest.raises(ValueError):
|
||||
update_kind(root, "terrain", "지형", [])
|
||||
with pytest.raises(FileNotFoundError):
|
||||
update_kind(root, "nokind", "없음", ["contour"])
|
||||
|
||||
|
||||
def test_drainage_symbol_piece() -> None:
|
||||
pieces = {"drainage": {"items": [{"x": 0.0, "y": 20.0, "label": "D800"}]}}
|
||||
drawing = build_plan_kind_drawing("plan_k2", "시험", (-50.0, -10.0, 50.0, 50.0), pieces)
|
||||
mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")]
|
||||
assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2
|
||||
assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine)
|
||||
|
||||
Reference in New Issue
Block a user