Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -19,6 +19,9 @@ export interface DesignDrawingItem {
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
confirmed: boolean;
|
||||
/** 계획평면도만 — 종류 id · 이름(PLAN 60장). */
|
||||
plan_kind?: string | null;
|
||||
plan_kind_name?: string | null;
|
||||
}
|
||||
|
||||
export interface CadDrawing {
|
||||
@@ -170,6 +173,51 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */
|
||||
export interface PlanKind {
|
||||
id: string;
|
||||
name: string;
|
||||
pieces: string[];
|
||||
}
|
||||
export interface PlanMark {
|
||||
chainage_m: number;
|
||||
refuge: boolean;
|
||||
spoil: boolean;
|
||||
}
|
||||
export interface PlanKindsState {
|
||||
pieces: { id: string; name: string }[];
|
||||
kinds: PlanKind[];
|
||||
marks: PlanMark[];
|
||||
}
|
||||
|
||||
export function fetchPlanKinds(projectId: string): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-kinds`);
|
||||
}
|
||||
|
||||
export function addPlanKind(
|
||||
projectId: string,
|
||||
name: string,
|
||||
pieces: string[],
|
||||
): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-kinds`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, pieces }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePlanKind(projectId: string, kindId: string): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function savePlanMarks(projectId: string, marks: PlanMark[]): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-marks`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ marks }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 도각 편집 화면이 싣는 도각 한 장 (실치수 1:1, 잠금 없는 도면층). */
|
||||
export interface FrameTemplateResponse {
|
||||
status: string;
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
"""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),
|
||||
],
|
||||
}
|
||||
@@ -39,9 +39,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
CROSS_STANDARD_ID,
|
||||
LANDUSE_ID,
|
||||
LIDAR_ID,
|
||||
MASS_HAUL_ID,
|
||||
PLAN_ID,
|
||||
WATERSHED_ID,
|
||||
_cross_sheet_plan,
|
||||
_drawing_list,
|
||||
@@ -50,10 +48,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
_read_json,
|
||||
_store_confirmed_drawing,
|
||||
landuse_source,
|
||||
lidar_source,
|
||||
plan_source,
|
||||
watershed_source,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingConfirmRequest,
|
||||
DesignDrawingConfirmResponse,
|
||||
@@ -342,13 +339,16 @@ async def get_design_drawing(
|
||||
if row
|
||||
else None
|
||||
)
|
||||
elif LIDAR_ID.fullmatch(drawing_id):
|
||||
# 라이다 계획평면도는 확정 DTM 격자로 음영기복 그림을 만들어 넘긴다.
|
||||
elif PLAN_KIND_ID.fullmatch(drawing_id):
|
||||
# 계획평면도 — 정보 조각을 서버가 계산 · 파일로 두고 그 종류가 고른 것만 얹는다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||
source_design = await asyncio.to_thread(lidar_source, context, longitudinal, drawing_id)
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
source_design = await asyncio.to_thread(
|
||||
plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id
|
||||
)
|
||||
elif LANDUSE_ID.fullmatch(drawing_id):
|
||||
# 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
@@ -358,13 +358,6 @@ async def get_design_drawing(
|
||||
source_design = await asyncio.to_thread(
|
||||
landuse_source, context, longitudinal, drawing_id
|
||||
)
|
||||
elif PLAN_ID.fullmatch(drawing_id):
|
||||
# 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||
source_design = await asyncio.to_thread(plan_source, context, longitudinal, drawing_id)
|
||||
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
|
||||
_read_drawing,
|
||||
project_root,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장).
|
||||
|
||||
종류 = 정보 조각 고르기 목록. 더하기 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다.
|
||||
대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
add_kind,
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
read_marks,
|
||||
write_marks,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
|
||||
|
||||
class PlanKindRequest(BaseModel):
|
||||
name: str
|
||||
pieces: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PlanMark(BaseModel):
|
||||
chainage_m: float = Field(ge=0)
|
||||
refuge: bool = False
|
||||
spoil: bool = False
|
||||
|
||||
|
||||
class PlanMarksRequest(BaseModel):
|
||||
marks: list[PlanMark] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _state(root: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "success",
|
||||
"pieces": [{"id": piece, "name": name} for piece, name, _locked in PIECES],
|
||||
"kinds": list_kinds(root),
|
||||
"marks": read_marks(root),
|
||||
}
|
||||
|
||||
|
||||
def _error(status: int, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)})
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
except ValueError as exc:
|
||||
return _error(400, exc)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/plan-kinds/{kind_id}")
|
||||
async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any:
|
||||
"""종류 지우기 — 기본 넷도 지울 수 있다."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(delete_kind, root, kind_id)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
except ValueError as exc:
|
||||
return _error(400, exc)
|
||||
except FileNotFoundError as exc:
|
||||
return _error(404, exc)
|
||||
|
||||
|
||||
@router.put("/{project_id}/plan-marks")
|
||||
async def put_plan_marks(project_id: UUID, request: PlanMarksRequest) -> Any:
|
||||
"""사용자가 정한 대피소 · 사토장 자리를 통째로 갈아 쓴다."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
marks = [mark.model_dump() for mark in request.marks]
|
||||
await asyncio.to_thread(write_marks, root, marks)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
except ValueError as exc:
|
||||
return _error(400, exc)
|
||||
@@ -28,22 +28,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
|
||||
LANDUSE_LABEL,
|
||||
build_landuse_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import (
|
||||
LIDAR_LABEL,
|
||||
build_lidar_plan_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
PLAN_KINDS,
|
||||
build_plan_drawing,
|
||||
plan_chunks,
|
||||
plan_drawing_id,
|
||||
plan_drawing_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
CROSS_SHEET_ID,
|
||||
build_cross_sheet,
|
||||
@@ -90,6 +81,10 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_station_map,
|
||||
_write_manifest,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
PLAN_KIND_ID,
|
||||
plan_kind_items,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingItem,
|
||||
)
|
||||
@@ -151,32 +146,16 @@ def _drawing_list(
|
||||
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 계획평면도 3종 — 축척 1/1,200 고정이라 노선이 길면 장이 나뉜다(장수는 노선이 정한다).
|
||||
plan_sheets = plan_chunks(plan_stations(longitudinal))
|
||||
for kind, _label, *_rest in PLAN_KINDS:
|
||||
for chunk in plan_sheets:
|
||||
drawing_id = plan_drawing_id(kind, chunk, len(plan_sheets))
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=drawing_id,
|
||||
kind="plan",
|
||||
label=plan_drawing_label(kind, chunk, len(plan_sheets)),
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 계획평면도(라이다) — 지표면 음영기복 배경. 같은 축척·같은 장 나눔.
|
||||
for chunk in plan_sheets:
|
||||
drawing_id = "plan_lidar" if len(plan_sheets) <= 1 else f"plan_lidar_{chunk['number']}"
|
||||
# 계획평면도 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가) × 장(PLAN 60장).
|
||||
for entry in plan_kind_items(project_root, longitudinal):
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=drawing_id,
|
||||
kind="plan_lidar",
|
||||
label=LIDAR_LABEL
|
||||
if len(plan_sheets) <= 1
|
||||
else f"{LIDAR_LABEL} {chunk['number']}장",
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
**entry,
|
||||
kind="plan",
|
||||
confirmed=bool(manifest_drawings.get(entry["id"], {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
plan_sheets = plan_chunks(plan_stations(longitudinal))
|
||||
# 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다.
|
||||
for chunk in plan_sheets:
|
||||
drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}"
|
||||
@@ -427,12 +406,10 @@ def _read_drawing(
|
||||
if saved.get("format") == DRAWING_FORMAT:
|
||||
if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID):
|
||||
kind = drawing_id # id와 kind가 같은 단장 도면
|
||||
elif PLAN_ID.fullmatch(drawing_id):
|
||||
elif PLAN_KIND_ID.fullmatch(drawing_id):
|
||||
kind = "plan"
|
||||
elif LANDUSE_ID.fullmatch(drawing_id):
|
||||
kind = "landuse"
|
||||
elif LIDAR_ID.fullmatch(drawing_id):
|
||||
kind = "plan_lidar"
|
||||
else:
|
||||
kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross"
|
||||
label = str(manifest_entry.get("label") or drawing_id)
|
||||
@@ -468,20 +445,16 @@ def _read_drawing(
|
||||
None,
|
||||
)
|
||||
|
||||
if LIDAR_ID.fullmatch(drawing_id):
|
||||
# stored_design = lidar_source()가 만든 노선 + 지표면 음영기복 그림.
|
||||
if PLAN_KIND_ID.fullmatch(drawing_id):
|
||||
# stored_design = plan_kind_source()가 계산 · 저장한 조각 중 그 종류가 고른 것.
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("라이다 계획평면도 자료가 없습니다.")
|
||||
label = str(stored_design.get("label") or LIDAR_LABEL)
|
||||
raise FileNotFoundError("계획평면도 자료가 없습니다.")
|
||||
label = str(stored_design.get("label") or drawing_id)
|
||||
return (
|
||||
"plan_lidar",
|
||||
"plan",
|
||||
label,
|
||||
build_lidar_plan_drawing(
|
||||
drawing_id,
|
||||
label,
|
||||
stored_design.get("route_xy") or [],
|
||||
stored_design.get("shade_image"),
|
||||
stored_design.get("shade_box"),
|
||||
build_plan_kind_drawing(
|
||||
drawing_id, label, stored_design["box"], stored_design.get("pieces") or {}
|
||||
),
|
||||
False,
|
||||
None,
|
||||
@@ -508,31 +481,6 @@ def _read_drawing(
|
||||
None,
|
||||
)
|
||||
|
||||
if PLAN_ID.fullmatch(drawing_id):
|
||||
# stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("계획평면도 자료가 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
label = str(stored_design.get("label") or drawing_id)
|
||||
return (
|
||||
"plan",
|
||||
label,
|
||||
build_plan_drawing(
|
||||
str(stored_design.get("kind") or "plan_terrain"),
|
||||
drawing_id,
|
||||
label,
|
||||
stored_design.get("route_xy") or [],
|
||||
stored_design.get("stations") or [],
|
||||
stored_design.get("contours") or [],
|
||||
stored_design.get("streams") or [],
|
||||
stored_design.get("structures") or [],
|
||||
interval,
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if drawing_id == WATERSHED_ID:
|
||||
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"""B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장).
|
||||
|
||||
저장 자리(프로젝트 저장소 `B07_DesignDetail/`):
|
||||
plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order} — M02 양식 층처럼 파일 하나씩
|
||||
plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m)
|
||||
plan_marks.json 사용자가 정한 대피소 · 사토장 자리
|
||||
|
||||
`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때
|
||||
기본 넷을 파일로 내려 두고 그 위에서 더하고 지운다 — 기본 넷도 지울 수 있다.
|
||||
조각은 서버가 도면을 열 때마다 원자료(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_Plan import plan_area_mm, plan_chunks
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
DEFAULT_KINDS,
|
||||
PIECE_IDS,
|
||||
curve_pieces,
|
||||
route_frame,
|
||||
slope_end,
|
||||
slope_pieces,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
_chunk_route,
|
||||
_hillshade_for_box,
|
||||
_sheet_box,
|
||||
map_background,
|
||||
plan_stations,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _read_json
|
||||
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
|
||||
|
||||
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
|
||||
_REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"}
|
||||
_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측)
|
||||
|
||||
|
||||
def plan_title(name: str) -> str:
|
||||
return f"계획평면도({name})"
|
||||
|
||||
|
||||
# ── 종류 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _kinds_dir(project_root: Path) -> Path:
|
||||
return _design_root(project_root) / "plan_kinds"
|
||||
|
||||
|
||||
def list_kinds(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 넷."""
|
||||
folder = _kinds_dir(project_root)
|
||||
if not folder.is_dir():
|
||||
return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS]
|
||||
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
|
||||
pieces = [piece for piece in document.get("pieces") or [] if piece in PIECE_IDS]
|
||||
rows.append(
|
||||
(
|
||||
int(document.get("order") or 0),
|
||||
path.stem,
|
||||
{"id": path.stem, "name": str(document.get("name") or path.stem), "pieces": pieces},
|
||||
)
|
||||
)
|
||||
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",
|
||||
{"name": kind["name"], "pieces": kind["pieces"], "order": order},
|
||||
)
|
||||
return kinds
|
||||
|
||||
|
||||
def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
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("얹을 정보를 하나 이상 고르세요.")
|
||||
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]}"
|
||||
atomic_write_json(
|
||||
_kinds_dir(project_root) / f"{kind_id}.json",
|
||||
{"name": text, "pieces": chosen, "order": len(kinds)},
|
||||
)
|
||||
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 입니다.")
|
||||
_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"),
|
||||
}
|
||||
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
|
||||
) -> dict[str, Any]:
|
||||
"""대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능."""
|
||||
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 * _REFUGE_OFFSET_M,
|
||||
"y": y + ny * _REFUGE_OFFSET_M,
|
||||
}
|
||||
)
|
||||
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],
|
||||
) -> tuple[dict[str, dict[str, Any]], tuple[float, float, float, float], dict[str, Any], int]:
|
||||
"""그 장의 조각들을 계산해 파일로 쓰고 (조각들, 도곽 범위, 장 구간, 전체 장수)를 낸다."""
|
||||
route_xy, chunk, total = _chunk_route(context, longitudinal, number)
|
||||
if not route_xy:
|
||||
raise FileNotFoundError("계획평면도에 그릴 노선이 없습니다.")
|
||||
box = _sheet_box(route_xy)
|
||||
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, DRAWING_SCALE_PLAN, 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 {"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
|
||||
)
|
||||
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)
|
||||
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}
|
||||
|
||||
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("계획평면도 종류를 찾을 수 없습니다.")
|
||||
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"]
|
||||
)
|
||||
title = plan_title(kind["name"])
|
||||
return {
|
||||
"label": title if total <= 1 else f"{title} {chunk['number']}장",
|
||||
"box": box,
|
||||
"pieces": pieces,
|
||||
}
|
||||
|
||||
|
||||
def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""도면 목록에 실을 종류 × 장 [{id, label, plan_kind}]."""
|
||||
sheets = plan_chunks(plan_stations(longitudinal))
|
||||
items = []
|
||||
for kind in list_kinds(project_root):
|
||||
title = plan_title(kind["name"])
|
||||
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
|
||||
@@ -26,6 +26,9 @@ class DesignDrawingItem(BaseModel):
|
||||
label: str
|
||||
chainage_m: float | None = None
|
||||
confirmed: bool = False
|
||||
# 계획평면도만 — 그 도면의 종류 id · 이름(PLAN 60장). 화면이 종류별로 묶는다.
|
||||
plan_kind: str | None = None
|
||||
plan_kind_name: str | None = None
|
||||
|
||||
|
||||
class DesignDrawingListResponse(BaseModel):
|
||||
|
||||
@@ -420,7 +420,23 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
|
||||
});
|
||||
|
||||
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
|
||||
const drawingPanel = buildDrawingSidePanel(
|
||||
drawings,
|
||||
selectDrawing,
|
||||
drawingError,
|
||||
devBypass,
|
||||
projectId
|
||||
? {
|
||||
projectId,
|
||||
// 종류 · 대피소 자리를 고쳤다 — 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다.
|
||||
onChanged: () => {
|
||||
if (!mayDiscardEdits()) return;
|
||||
cad.destroy();
|
||||
void renderB07DesignDetail(root);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
||||
import { buildPlanSection, type PlanKindsControls } from "./B07_DesignDetail_UI_PlanKinds";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -28,10 +29,8 @@ export const DRAWING_GROUPS: readonly {
|
||||
idPrefix?: string;
|
||||
}[] = [
|
||||
{ label: "표지", kind: "cover" },
|
||||
{ label: "계획평면도(지형)", idPrefix: "plan_terrain" },
|
||||
{ label: "계획평면도(노선배치도)", idPrefix: "plan_route" },
|
||||
{ label: "계획평면도(배치도)", idPrefix: "plan_layout" },
|
||||
{ label: "계획평면도(라이다)", idPrefix: "plan_lidar" },
|
||||
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)를 한 묶음으로 — PLAN 60장.
|
||||
{ label: "계획평면도", kind: "plan" },
|
||||
{ label: "종단면도", kind: "longitudinal" },
|
||||
{ label: "표준 횡단면도", kind: "cross_standard" },
|
||||
{ label: "횡단면도", kind: "cross" },
|
||||
@@ -46,6 +45,7 @@ export function buildDrawingSidePanel(
|
||||
onSelect: (drawing: DesignDrawingItem) => void,
|
||||
errorMessage?: string,
|
||||
devBypass = false,
|
||||
plan?: PlanKindsControls,
|
||||
): HTMLDivElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-drawing-list";
|
||||
@@ -89,6 +89,12 @@ export function buildDrawingSidePanel(
|
||||
};
|
||||
|
||||
for (const group of DRAWING_GROUPS) {
|
||||
if (group.kind === "plan") {
|
||||
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
|
||||
const plans = drawings.filter((item) => item.kind === "plan");
|
||||
panel.append(buildPlanSection(plans, drawingButton, plan));
|
||||
continue;
|
||||
}
|
||||
const items = group.kind
|
||||
? drawings.filter((item) => item.kind === group.kind)
|
||||
: group.idPrefix
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_UI_PlanKinds.ts
|
||||
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
|
||||
* 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장).
|
||||
*
|
||||
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
addPlanKind,
|
||||
deletePlanKind,
|
||||
fetchPlanKinds,
|
||||
savePlanMarks,
|
||||
type DesignDrawingItem,
|
||||
type PlanKindsState,
|
||||
type PlanMark,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
|
||||
export interface PlanKindsControls {
|
||||
projectId: string;
|
||||
/** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className: string,
|
||||
text = "",
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (text) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */
|
||||
async function run(action: () => Promise<unknown>, controls: PlanKindsControls): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
controls.onChanged();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
||||
const form = el("div", "b07-plan-tools__block");
|
||||
form.append(el("strong", "", "종류 더하기"));
|
||||
const name = el("input", "b07-plan-tools__name");
|
||||
name.type = "text";
|
||||
name.placeholder = "종류 이름";
|
||||
name.maxLength = 40;
|
||||
const boxes = state.pieces.map((piece) => {
|
||||
const label = el("label", "b07-plan-tools__piece");
|
||||
const box = el("input", "");
|
||||
box.type = "checkbox";
|
||||
box.value = piece.id;
|
||||
label.append(box, document.createTextNode(piece.name));
|
||||
return { label, box };
|
||||
});
|
||||
const pieceList = el("div", "b07-plan-tools__pieces");
|
||||
pieceList.append(...boxes.map((item) => item.label));
|
||||
const add = el("button", "b07-plan-tools__button", "더하기");
|
||||
add.type = "button";
|
||||
add.addEventListener("click", () => {
|
||||
const pieces = boxes.filter((item) => item.box.checked).map((item) => item.box.value);
|
||||
void run(() => addPlanKind(controls.projectId, name.value.trim(), pieces), controls);
|
||||
});
|
||||
form.append(name, pieceList, add);
|
||||
return form;
|
||||
}
|
||||
|
||||
function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
||||
const form = el("div", "b07-plan-tools__block");
|
||||
form.append(el("strong", "", "대피소 · 사토장 자리"));
|
||||
form.append(
|
||||
el(
|
||||
"p",
|
||||
"b07-plan-tools__hint",
|
||||
"구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(m)만 적으세요.",
|
||||
),
|
||||
);
|
||||
const rows = el("div", "b07-plan-tools__marks");
|
||||
const readers = new Map<HTMLElement, () => PlanMark>();
|
||||
const addRow = (mark: PlanMark): void => {
|
||||
const row = el("div", "b07-plan-tools__mark");
|
||||
const chainage = el("input", "b07-plan-tools__chainage");
|
||||
chainage.type = "number";
|
||||
chainage.min = "0";
|
||||
chainage.step = "0.1";
|
||||
chainage.value = String(mark.chainage_m);
|
||||
const flag = (text: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] => {
|
||||
const label = el("label", "b07-plan-tools__piece");
|
||||
const box = el("input", "");
|
||||
box.type = "checkbox";
|
||||
box.checked = checked;
|
||||
label.append(box, document.createTextNode(text));
|
||||
return [label, box];
|
||||
};
|
||||
const [refugeLabel, refuge] = flag("대피소", mark.refuge);
|
||||
const [spoilLabel, spoil] = flag("사토장", mark.spoil);
|
||||
const remove = el("button", "b07-plan-tools__remove", "×");
|
||||
remove.type = "button";
|
||||
remove.title = "이 자리 빼기";
|
||||
remove.addEventListener("click", () => {
|
||||
readers.delete(row);
|
||||
row.remove();
|
||||
});
|
||||
row.append(chainage, refugeLabel, spoilLabel, remove);
|
||||
readers.set(row, () => ({
|
||||
chainage_m: Number(chainage.value),
|
||||
refuge: refuge.checked,
|
||||
spoil: spoil.checked,
|
||||
}));
|
||||
rows.append(row);
|
||||
};
|
||||
state.marks.forEach(addRow);
|
||||
const more = el("button", "b07-plan-tools__button", "자리 더하기");
|
||||
more.type = "button";
|
||||
more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false }));
|
||||
const save = el("button", "b07-plan-tools__button", "자리 저장");
|
||||
save.type = "button";
|
||||
save.addEventListener("click", () => {
|
||||
const marks = [...readers.values()].map((read) => read());
|
||||
void run(() => savePlanMarks(controls.projectId, marks), controls);
|
||||
});
|
||||
const actions = el("div", "b07-plan-tools__actions");
|
||||
actions.append(more, save);
|
||||
form.append(rows, actions);
|
||||
return form;
|
||||
}
|
||||
|
||||
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 더하기 · 대피소 자리. */
|
||||
export function buildPlanSection(
|
||||
items: DesignDrawingItem[],
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
controls?: PlanKindsControls,
|
||||
): HTMLElement {
|
||||
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
|
||||
section.dataset.kind = "plan";
|
||||
section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`));
|
||||
|
||||
const kinds = new Map<string, DesignDrawingItem[]>();
|
||||
for (const item of items) {
|
||||
const key = item.plan_kind ?? item.id;
|
||||
kinds.set(key, [...(kinds.get(key) ?? []), item]);
|
||||
}
|
||||
for (const [kindId, sheets] of kinds) {
|
||||
const name = sheets[0].plan_kind_name ?? sheets[0].label;
|
||||
const block = el("div", "b07-plan-kind");
|
||||
block.dataset.planKind = kindId;
|
||||
// 한 장이면 [도면 단추][×] 한 줄 · 여러 장이면 [이름][×] 아래 장 단추.
|
||||
const head = el("div", "b07-plan-kind__head");
|
||||
const buttons = sheets.map((drawing, index) =>
|
||||
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
|
||||
);
|
||||
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
|
||||
if (controls) {
|
||||
const remove = el("button", "b07-plan-tools__remove", "×");
|
||||
remove.type = "button";
|
||||
remove.title = `「${name}」 종류 지우기`;
|
||||
remove.addEventListener("click", () => {
|
||||
if (
|
||||
!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 종류 더하기로 새로 만듭니다.`)
|
||||
)
|
||||
return;
|
||||
void run(() => deletePlanKind(controls.projectId, kindId), controls);
|
||||
});
|
||||
head.append(remove);
|
||||
}
|
||||
block.append(head, ...(sheets.length > 1 ? buttons : []));
|
||||
section.append(block);
|
||||
}
|
||||
|
||||
if (controls) {
|
||||
// 더하기 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
|
||||
const tools = el("details", "b07-plan-tools");
|
||||
tools.append(el("summary", "", "종류 더하기 · 대피소 자리"));
|
||||
tools.addEventListener("toggle", () => {
|
||||
if (!tools.open || tools.dataset.loaded) return;
|
||||
tools.dataset.loaded = "true";
|
||||
void fetchPlanKinds(controls.projectId)
|
||||
.then((state) => tools.append(addKindForm(state, controls), marksForm(state, controls)))
|
||||
.catch((error: unknown) => {
|
||||
delete tools.dataset.loaded;
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
section.append(tools);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
@@ -234,3 +234,78 @@
|
||||
}
|
||||
|
||||
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
|
||||
|
||||
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */
|
||||
.b07-plan-kind {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-plan-kind__head,
|
||||
.b07-plan-tools__mark,
|
||||
.b07-plan-tools__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-plan-kind__name {
|
||||
flex: 1;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-plan-tools {
|
||||
margin-top: var(--spacing-4);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b07-plan-tools summary {
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b07-plan-tools__block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-plan-tools__pieces {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.b07-plan-tools__piece {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b07-plan-tools__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-plan-tools__chainage {
|
||||
width: 72px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b07-plan-tools__button,
|
||||
.b07-plan-tools__remove {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b07-plan-tools__remove {
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ from B06_Section.B06_Section_Router_HaulPlan import (
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router
|
||||
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
|
||||
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
|
||||
@@ -638,6 +639,7 @@ app.include_router(b06_section_confirm_router, dependencies=protected_with_compa
|
||||
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_design_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_plan_kinds_router, dependencies=protected_with_company)
|
||||
# B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3).
|
||||
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
|
||||
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""B07 계획평면도 종류 · 정보 조각 (PLAN 60장).
|
||||
|
||||
① 측점 둘에서 성토사면 외곽선 폭 = 횡단 설계 값(노견 끝 · 설계선이 지반에 닿는 자리)
|
||||
② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음
|
||||
③ 대피소 · 사토장 자리 — 한 자리에 둘 다
|
||||
④ 고른 조각만 도면 층으로 얹힘
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
DEFAULT_KINDS,
|
||||
build_plan_kind_drawing,
|
||||
layer_id,
|
||||
slope_pieces,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
_refuge_items,
|
||||
add_kind,
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
read_marks,
|
||||
slope_records,
|
||||
write_marks,
|
||||
)
|
||||
|
||||
|
||||
def _samples(slope: float) -> list[dict[str, float]]:
|
||||
"""좌(+)가 높은 비탈 지반 — 오프셋 -20 ~ 20 m, 0.5 m 간격."""
|
||||
return [
|
||||
{"offset_m": i * 0.5, "elevation_m": 100.0 + slope * i * 0.5, "valid": True}
|
||||
for i in range(-40, 41)
|
||||
]
|
||||
|
||||
|
||||
def _project(tmp_path: Path) -> tuple[Path, dict, dict]:
|
||||
"""+y 로 곧게 가는 노선 · 측점 0 · 20 · 40 · 횡단 파일 · B06 저장 설계."""
|
||||
root = tmp_path / "project"
|
||||
cross_dir = root / "B06_Section" / "cross_sections"
|
||||
cross_dir.mkdir(parents=True)
|
||||
longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
longitudinal_path.parent.mkdir(parents=True)
|
||||
stations, designs = [], {}
|
||||
for chainage, slope in ((0, 0.3), (20, 0.5), (40, 0.4)):
|
||||
stations.append(
|
||||
{"chainage_m": float(chainage), "center_x": 0.0, "center_y": float(chainage)}
|
||||
)
|
||||
source = {
|
||||
"chainage_m": float(chainage),
|
||||
"samples": _samples(slope),
|
||||
# +y 진행의 좌측 = -x
|
||||
"frame": {"origin": {"x": 0.0, "y": float(chainage)}, "left_xy": [-1.0, 0.0]},
|
||||
}
|
||||
(cross_dir / f"cross_{chainage:05d}m.json").write_text(json.dumps(source), "utf-8")
|
||||
designs[chainage] = compute_cross_design(
|
||||
source["samples"], 100.0, ground_type="soil", section_mode="left_cut"
|
||||
)
|
||||
longitudinal = {"stations": stations}
|
||||
longitudinal_path.write_text(json.dumps(longitudinal), "utf-8")
|
||||
return longitudinal_path, longitudinal, designs
|
||||
|
||||
|
||||
def _meets_ground(design: dict, side: str) -> float:
|
||||
"""설계선이 바깥으로 가며 지반과 다시 같아지는 첫 오프셋 — 시험 쪽 독립 계산."""
|
||||
edge = design["road_edges"][side]["offset_m"]
|
||||
line = sorted((p["offset_m"], p["elevation_m"]) for p in design["design_line"])
|
||||
outward = (
|
||||
[p for p in line if p[0] > edge]
|
||||
if side == "left"
|
||||
else [p for p in line[::-1] if p[0] < edge]
|
||||
)
|
||||
slope = SLOPES[design["_c"]]
|
||||
for offset, elevation in outward:
|
||||
# 지반은 직선 비탈이라 어느 오프셋에서나 식으로 바로 잰다(설계선 꺾임점 포함).
|
||||
if abs(100.0 + slope * offset - elevation) < 1e-3:
|
||||
return offset
|
||||
raise AssertionError("설계선이 지반에 닿지 않음")
|
||||
|
||||
|
||||
SLOPES = {0: 0.3, 20: 0.5, 40: 0.4}
|
||||
|
||||
|
||||
def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> None:
|
||||
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"]
|
||||
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)
|
||||
expected = {
|
||||
"left_edge": design["road_edges"]["left"]["offset_m"],
|
||||
"left_end": _meets_ground(design, "left"),
|
||||
"right_edge": design["road_edges"]["right"]["offset_m"],
|
||||
"right_end": _meets_ground(design, "right"),
|
||||
}
|
||||
# 좌측은 -x 쪽 — 중심(0, c)에서 잰 거리가 횡단 설계 오프셋이다.
|
||||
assert -left_edge[index][0] == pytest.approx(expected["left_edge"], abs=1e-3)
|
||||
assert -left_end[index][0] == pytest.approx(expected["left_end"], abs=1e-3)
|
||||
assert -right_edge[index][0] == pytest.approx(expected["right_edge"], abs=1e-3)
|
||||
assert -right_end[index][0] == pytest.approx(expected["right_end"], abs=1e-3)
|
||||
# 성토 · 절토 비탈이 실제로 노견 밖에 있다
|
||||
assert abs(expected["left_end"]) > abs(expected["left_edge"])
|
||||
assert abs(expected["right_end"]) > abs(expected["right_edge"])
|
||||
# 비탈 눈금은 노견 끝에서 바깥으로 · 긴 것과 짧은 것이 섞임
|
||||
lengths = {round(math.dist(*tick), 2) for tick in ticks["ticks"]}
|
||||
assert len(ticks["ticks"]) > 20 and len(lengths) > 2
|
||||
|
||||
|
||||
def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
assert [k["name"] for k in list_kinds(root)] == ["지형", "노선배치도", "대피소", "라이다"]
|
||||
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
|
||||
assert added["pieces"] == ["centerline", "station"]
|
||||
with pytest.raises(ValueError):
|
||||
add_kind(root, "내 평면도", ["centerline"]) # 같은 이름
|
||||
with pytest.raises(ValueError):
|
||||
add_kind(root, "빈 것", [])
|
||||
delete_kind(root, "terrain") # 기본 넷도 지움
|
||||
again = list_kinds(root) # 다시 읽어 같음
|
||||
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "내 평면도"]
|
||||
assert again[-1]["pieces"] == ["centerline", "station"]
|
||||
with pytest.raises(FileNotFoundError):
|
||||
delete_kind(root, "terrain")
|
||||
for kind in list(again):
|
||||
delete_kind(root, kind["id"])
|
||||
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar"}
|
||||
|
||||
|
||||
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
write_marks(root, [{"chainage_m": 20, "refuge": True, "spoil": True}, {"chainage_m": 5}])
|
||||
assert read_marks(root) == [{"chainage_m": 20.0, "refuge": True, "spoil": True}]
|
||||
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
|
||||
items = _refuge_items(root, route, 0.0, 40.0)["items"]
|
||||
assert [item["label"] for item in items] == ["대피소 · 사토장"]
|
||||
assert items[0]["y"] == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_drawing_has_only_chosen_pieces() -> None:
|
||||
pieces = {
|
||||
"centerline": {"lines": [[[0.0, 0.0], [0.0, 40.0]]]},
|
||||
"radius": {"items": [{"label": "R=15", "at": [1.0, 20.0]}]},
|
||||
}
|
||||
drawing = build_plan_kind_drawing(
|
||||
"plan_k1", "계획평면도(시험)", (-50.0, -10.0, 50.0, 50.0), pieces
|
||||
)
|
||||
layers = {entity.get("layerId") for entity in drawing["entities"]}
|
||||
assert layer_id("centerline") in layers and layer_id("radius") in layers
|
||||
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
|
||||
Reference in New Issue
Block a user