auto: 2026-08-29 18:37 (EOMSANGDON-HOME)

This commit is contained in:
2026-08-29 18:37:18 +09:00
parent c100daabce
commit 07089e285a
5 changed files with 808 additions and 368 deletions
+112 -321
View File
@@ -17,6 +17,8 @@
from typing import Any
from uuid import UUID, uuid5
from config.config_system import DRAWING_SCALE_CROSS
# 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3).
GROUND_LAYER_ID = "b08-ground"
GROUND_COLOR = "#f5f7fa"
@@ -277,8 +279,14 @@ def _cross_line_entities(
design_line: list[Any] | None,
design: dict[str, Any] | None,
dy: float = 0.0,
paper: Any = None,
window: tuple[float, float] | None = None,
) -> list[dict[str, Any]]:
"""횡단 설계선을 설계(b08-design)/구조물(b08-structure, 측구 구간)로 분리한다."""
"""횡단 설계선을 설계(b07-design)/구조물(b07-structure, 측구 구간)로 분리한다.
paper가 오면 (실좌표 m) -> (종이 mm) 변환을 거쳐 좌표를 만들고, window가 오면
그 가로 범위로 자른다. dy는 paper 안에서 처리하므로 여기서 빼지 않는다.
"""
points: list[tuple[float, float]] = []
for point in design_line if isinstance(design_line, list) else []:
if not isinstance(point, dict):
@@ -286,14 +294,21 @@ def _cross_line_entities(
x = point.get("offset_m")
y = point.get("elevation_m")
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
points.append((float(x), float(y) - dy))
points.append((float(x), float(y) if paper else float(y) - dy))
if len(points) < 2:
return []
if window is not None:
points = _clip_polyline(points, window[0], window[1])
if len(points) < 2:
return []
def place(part: list[tuple[float, float]]) -> list[tuple[float, float]]:
return [paper(p) for p in part] if paper else part
bounds = _ditch_bounds(design)
entities: list[dict[str, Any]] = []
if bounds is None:
design_poly = polyline_entity(drawing_id, points, DESIGN_LAYER_ID, DESIGN_COLOR)
design_poly = polyline_entity(drawing_id, place(points), DESIGN_LAYER_ID, DESIGN_COLOR)
return [design_poly] if design_poly else []
lo, hi = bounds
@@ -302,259 +317,64 @@ def _cross_line_entities(
ditch_part = [p for p in points if lo - tolerance <= p[0] <= hi + tolerance]
after = [p for p in points if p[0] >= hi - tolerance]
for suffix, part in ((":a", before), (":b", after)):
poly = polyline_entity(drawing_id, part, DESIGN_LAYER_ID, DESIGN_COLOR, suffix)
poly = polyline_entity(drawing_id, place(part), DESIGN_LAYER_ID, DESIGN_COLOR, suffix)
if poly:
entities.append(poly)
structure = polyline_entity(drawing_id, ditch_part, STRUCTURE_LAYER_ID, STRUCTURE_COLOR)
structure = polyline_entity(drawing_id, place(ditch_part), STRUCTURE_LAYER_ID, STRUCTURE_COLOR)
if structure:
entities.append(structure)
return entities
# 횡단 수량 산출표 (납품 양식). 본문 6행 × 3그룹.
# 좌: 깍기(토사/암석)·측구(토사/암석)·쌓기·층따기
# 중: 면고르기(성토/절토)·지장목제거(성토/절토)·표토제거(성토/절토)
# 우: 편책·성토파종·절토살포·제근·(공란)·노면다짐
_CROSS_HEADER_KEYS: tuple[tuple[str, str], ...] = (
("지반고", "ground"),
("계획고", "planned"),
("성토고", "fill"),
("절토고", "cut"),
)
# 좌측 그룹: (그룹 라벨 or None, 하위 라벨 or None, 값 키 or None) — 행 순서대로.
_CROSS_LEFT_ROWS: tuple[tuple[str | None, str | None, str | None], ...] = (
("깍기", "토사", "cut_soil"),
(None, "암석", "cut_rock"),
("측구", "토사", "ditch_soil"),
(None, "암석", "ditch_rock"),
("쌓기", None, "embankment"),
("층따기", None, "benching"),
)
_CROSS_MIDDLE_ROWS: tuple[tuple[str | None, str, str], ...] = (
("면고르기", "성토", "grading_fill"),
(None, "절토", "grading_cut"),
("지장목제거", "성토", "tree_removal_fill"),
(None, "절토", "tree_removal_cut"),
("표토제거", "성토", "topsoil_fill"),
(None, "절토", "topsoil_cut"),
)
_CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = (
("편책", "fence"),
("성토파종", "fill_seeding"),
("절토살포", "cut_spraying"),
("제근", "grubbing"),
(None, None),
("노면다짐", "road_compaction"),
)
QUANTITY_VALUE_KEYS: tuple[str, ...] = (
"ground",
"planned",
"fill",
"cut",
"cut_soil",
"cut_rock",
"ditch_soil",
"ditch_rock",
"embankment",
"benching",
"grading_fill",
"grading_cut",
"tree_removal_fill",
"tree_removal_cut",
"topsoil_fill",
"topsoil_cut",
"fence",
"fill_seeding",
"cut_spraying",
"grubbing",
"road_compaction",
)
_CROSS_TABLE_WIDTH = 26.0
_CROSS_TABLE_ROW_HEIGHT = 1.6
_CROSS_TABLE_FONT = 0.55
# 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백)
_CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0)
# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/100 -> 10.0).
CROSS_MM = 1000.0 / DRAWING_SCALE_CROSS
# 단면을 그리는 가로 범위: 설계선(절·성토)·구조물이 원지반과 갈라지는 구간 + 여유(m).
CROSS_SECTION_MARGIN_M = 1.5
# 원지반과 "만난다"고 볼 표고 차이(m) — 이보다 작으면 붙어 있는 것으로 본다.
_TOUCH_TOLERANCE_M = 0.02
def _cross_column_edges(width: float) -> list[float]:
total = sum(_CROSS_COLUMN_WEIGHTS)
left = -width / 2.0
edges = [left]
accumulated = 0.0
for weight in _CROSS_COLUMN_WEIGHTS:
accumulated += weight
edges.append(left + width * accumulated / total)
return edges
def _section_window(
ground: list[tuple[float, float]],
design: list[tuple[float, float]],
margin_m: float = CROSS_SECTION_MARGIN_M,
) -> tuple[float, float]:
"""설계선이 원지반에서 갈라졌다가 다시 만나는 가로 범위(+여유)를 구한다.
B06 설계선은 절·성토 구간 바깥에서 원지반을 그대로 따라가므로, 표고가
갈라지는 구간이 곧 "그려야 할 단면"이다 (2026-08-29 사용자 기준).
"""
left = right = None
for x, y in design:
ground_y = _interpolate(ground, x)
if ground_y is None or abs(y - ground_y) <= _TOUCH_TOLERANCE_M:
continue
left = x if left is None else min(left, x)
right = x if right is None else max(right, x)
if left is None or right is None:
xs = [x for x, _y in ground] or [-6.0, 6.0]
left, right = min(xs), max(xs)
return (left - margin_m, right + margin_m)
def _cross_table_entities(
drawing_id: str,
quantity_table: dict[str, float | None],
table_top: float,
title_label: str,
) -> list[dict[str, Any]]:
"""횡단 수량 산출표(납품 양식)를 병합 셀 그리드+텍스트로 만든다."""
width = _CROSS_TABLE_WIDTH
row_h = _CROSS_TABLE_ROW_HEIGHT
font = _CROSS_TABLE_FONT
left = -width / 2.0
right = width / 2.0
edges = _cross_column_edges(width)
entities: list[dict[str, Any]] = []
def value_text(seed_key: str, x: float, y: float) -> dict[str, Any]:
value = quantity_table.get(seed_key)
return _text_entity(
f"{drawing_id}:qtable:{seed_key}",
_format(value) if isinstance(value, (int, float)) else "-",
x,
y,
CROSS_TABLE_LAYER_ID,
font,
TABLE_VALUE_COLOR,
)
def label_text(seed: str, label: str, x: float, y: float) -> dict[str, Any]:
return _text_entity(seed, label, x, y, CROSS_TABLE_LAYER_ID, font, TABLE_LABEL_COLOR)
def h_line(seed: str, x_from: float, x_to: float, y: float) -> None:
entities.append(
_line_entity(seed, (x_from, y), (x_to, y), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR)
)
def v_line(seed: str, x: float, y_from: float, y_to: float) -> None:
entities.append(
_line_entity(seed, (x, y_from), (x, y_to), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR)
)
# ── 행 y 좌표 (제목행, 헤더행, 본문 6행)
y_title_top = table_top
y_header_top = y_title_top - row_h
y_body_top = y_header_top - row_h
body_rows = len(_CROSS_LEFT_ROWS)
y_bottom = y_body_top - row_h * body_rows
def body_y(row_index: int) -> float:
return y_body_top - row_h * row_index
# ── 외곽/가로선
h_line(f"{drawing_id}:qgrid:top", left, right, y_title_top)
h_line(f"{drawing_id}:qgrid:title", left, right, y_header_top)
h_line(f"{drawing_id}:qgrid:header", left, right, y_body_top)
h_line(f"{drawing_id}:qgrid:bottom", left, right, y_bottom)
v_line(f"{drawing_id}:qgrid:vl", left, y_title_top, y_bottom)
v_line(f"{drawing_id}:qgrid:vr", right, y_title_top, y_bottom)
# 본문 행 사이 가로선 — 그룹 병합 셀(좌 colA, 중 colE)은 병합 지속 구간에서 끊는다.
for boundary in range(1, body_rows):
y = body_y(boundary)
left_merged = _CROSS_LEFT_ROWS[boundary][0] is None
middle_merged = _CROSS_MIDDLE_ROWS[boundary][0] is None
seed = f"{drawing_id}:qgrid:b{boundary}"
if left_merged:
h_line(f"{seed}:l", edges[1], edges[4], y)
else:
h_line(f"{seed}:l", edges[0], edges[4], y)
if middle_merged:
h_line(f"{seed}:m", edges[5], edges[8], y)
else:
h_line(f"{seed}:m", edges[4], edges[8], y)
h_line(f"{seed}:r", edges[8], edges[11], y)
# ── 헤더행 (지반고/계획고/성토고/절토고): 4쌍 균등 분할
header_cell = width / 8.0
for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS):
x_label = left + header_cell * (pair_index * 2 + 0.5)
x_value = left + header_cell * (pair_index * 2 + 1.5)
y_mid = y_header_top - row_h * 0.5
entities.append(label_text(f"{drawing_id}:qlabel:h:{key}", label, x_label, y_mid))
entities.append(value_text(key, x_value, y_mid))
if pair_index > 0:
v_line(
f"{drawing_id}:qgrid:hv{pair_index}",
left + header_cell * pair_index * 2,
y_header_top,
y_body_top,
)
v_line(
f"{drawing_id}:qgrid:hvl{pair_index}",
left + header_cell * (pair_index * 2 + 1),
y_header_top,
y_body_top,
)
# ── 제목행 (No.측점)
entities.append(
_text_entity(
f"{drawing_id}:qtitle",
title_label,
left + width * 0.03,
y_title_top - row_h * 0.5,
CROSS_TABLE_LAYER_ID,
font * 1.15,
TABLE_LABEL_COLOR,
align="left",
)
)
# ── 본문 세로선: colA|B 경계는 그룹 라벨이 하위 라벨과 분리된 행(깍기~측구 4행)만.
ab_rows = [i for i, row in enumerate(_CROSS_LEFT_ROWS) if row[1] is not None]
if ab_rows:
v_line(
f"{drawing_id}:qgrid:vab",
edges[1],
body_y(min(ab_rows)),
body_y(max(ab_rows) + 1),
)
for edge_index in (2, 3, 4, 5, 6, 7, 8, 9, 10):
v_line(f"{drawing_id}:qgrid:vc{edge_index}", edges[edge_index], y_body_top, y_bottom)
# ── 본문 셀 (좌/중/우 그룹)
def cell_mid(edge_from: int, edge_to: int) -> float:
return (edges[edge_from] + edges[edge_to]) / 2.0
for row_index in range(body_rows):
y_mid = body_y(row_index) - row_h * 0.5
group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index]
if group_l is not None and sub_l is not None:
# 그룹 라벨은 2행 병합 중앙 배치
y_group = body_y(row_index) - row_h # 병합 2행의 중앙
entities.append(
label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 1), y_group)
)
elif group_l is not None:
entities.append(
label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 2), y_mid)
)
if sub_l is not None:
entities.append(
label_text(f"{drawing_id}:qlabel:ls:{row_index}", sub_l, cell_mid(1, 2), y_mid)
)
if key_l is not None:
entities.append(value_text(key_l, cell_mid(2, 3), y_mid))
group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index]
if group_m is not None:
y_group = body_y(row_index) - row_h
entities.append(
label_text(f"{drawing_id}:qlabel:mg:{row_index}", group_m, cell_mid(4, 5), y_group)
)
entities.append(
label_text(f"{drawing_id}:qlabel:ms:{row_index}", sub_m, cell_mid(5, 6), y_mid)
)
entities.append(value_text(key_m, cell_mid(6, 7), y_mid))
label_r, key_r = _CROSS_RIGHT_ROWS[row_index]
if label_r is not None:
entities.append(
label_text(f"{drawing_id}:qlabel:r:{row_index}", label_r, cell_mid(8, 9), y_mid)
)
if key_r is not None:
entities.append(value_text(key_r, cell_mid(9, 10), y_mid))
return entities
def _clip_polyline(
points: list[tuple[float, float]], x0: float, x1: float
) -> list[tuple[float, float]]:
"""폴리라인을 [x0, x1]로 자른다 — 경계는 선형보간으로 새 점을 만든다."""
if not points:
return []
clipped: list[tuple[float, float]] = []
for index, (x, y) in enumerate(points):
if index > 0:
px, py = points[index - 1]
for edge in (x0, x1):
if (px < edge < x) or (x < edge < px):
span = x - px
ratio = (edge - px) / span if span else 0.0
clipped.append((edge, py + (y - py) * ratio))
if x0 <= x <= x1:
clipped.append((x, y))
return clipped
def build_cross_drawing(
@@ -566,18 +386,16 @@ def build_cross_drawing(
title_label: str,
design_elevation_m: float | None = None,
frame: dict[str, float] | None = None,
origin: tuple[float, float] = (0.0, 0.0),
) -> dict[str, Any]:
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
로컬 좌표 정규화: 계획고(design_elevation_m)를 y=0으로 두어 모든 측점
도면이 같은 화면 배치를 갖는다.
배치 규약: 이 단면의 선(지표+설계+암 경계) 전체 bbox 중심을 y=0에 두어
측점마다 콘텐츠가 화면 중앙에 온다(종단 경사 드리프트 제거). 테이블은
frame(노선 공통 최대 반높이 half_height)이 오면 전 측점 동일 y에 고정해
Fit-in-all 배율·중심이 측점 간 흔들리지 않게 한다. design_elevation_m는
현재 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
좌표는 종이 밀리미터(1/100 — 실거리 1 m = 10 mm)이고, 그리는 가로 범위는
설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의
bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재
배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
"""
ox, oy = origin
raw_ground = points_from_samples(source.get("samples", []), "offset_m")
raw_design: list[tuple[float, float]] = []
for point in design_line if isinstance(design_line, list) else []:
@@ -589,49 +407,63 @@ def build_cross_drawing(
raw_design.append((float(x), float(y)))
rock_offset = design.get("rock_boundary_offset_m") if isinstance(design, dict) else None
# 콘텐츠 bbox: 지표선 + 설계선 + 암 경계선(지표+오프셋) 표고 범위.
all_ys = [y for _x, y in raw_ground] + [y for _x, y in raw_design]
# 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다.
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
_cross_table_entities,
cross_table_height,
cross_table_width,
)
# 그릴 가로 범위: 설계선이 원지반과 갈라지는 구간 + 여유.
x0, x1 = _section_window(raw_ground, raw_design)
ground_clipped = _clip_polyline(raw_ground, x0, x1)
# 세로 정렬: 이 범위 안의 선(지표+설계+암 경계) bbox 중심을 0으로.
all_ys = [y for _x, y in ground_clipped] + [y for x, y in raw_design if x0 <= x <= x1]
if isinstance(rock_offset, (int, float)):
all_ys.extend(y + float(rock_offset) for _x, y in raw_ground)
all_ys.extend(y + float(rock_offset) for _x, y in ground_clipped)
dy = (min(all_ys) + max(all_ys)) / 2.0 if all_ys else 0.0
own_half_height = (max(all_ys) - min(all_ys)) / 2.0 if all_ys else 5.0
ground_points = [(x, y - dy) for x, y in raw_ground]
def paper(point: tuple[float, float]) -> tuple[float, float]:
return (point[0] * CROSS_MM + ox, (point[1] - dy) * CROSS_MM + oy)
entities: list[dict[str, Any]] = []
ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR)
ground = polyline_entity(
drawing_id, [paper(p) for p in ground_clipped], GROUND_LAYER_ID, GROUND_COLOR
)
if ground:
entities.append(ground)
entities.extend(_cross_line_entities(drawing_id, design_line, design, dy))
entities.extend(_cross_line_entities(drawing_id, design_line, design, dy, paper, (x0, x1)))
# 암 경계선: 지반선 복사 + 오프셋(음수=하향). 암 지반 지정 측점에만 존재.
if isinstance(rock_offset, (int, float)) and ground_points:
rock_points = [(x, y + float(rock_offset)) for x, y in ground_points]
if isinstance(rock_offset, (int, float)) and ground_clipped:
rock_points = [paper((x, y + float(rock_offset))) for x, y in ground_clipped]
rock = polyline_entity(drawing_id, rock_points, ROCK_LAYER_ID, ROCK_COLOR, dash=[6, 4])
if rock:
entities.append(rock)
# 테이블 상단: 노선 공통 최대 반높이 아래 고정(전 측점 동일). frame 없으면 자체 폴백.
half_height = (
frame["half_height"] if frame and "half_height" in frame else own_half_height + 1.0
)
table_bottom = -half_height
# 표는 단면 아래에 붙인다 (좌표는 mm).
half_height = own_half_height * CROSS_MM + 5.0
table_bottom = oy - half_height
if quantity_table is not None:
table_top = -half_height - 2.0
entities.extend(_cross_table_entities(drawing_id, quantity_table, table_top, title_label))
table_bottom = table_top - _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2)
table_top = oy - half_height - 8.0
entities.extend(
_cross_table_entities(drawing_id, quantity_table, table_top, title_label, ox)
)
table_bottom = table_top - cross_table_height()
# 외곽 테두리: 전 측점 동일 크기 사각형(노선 공통 half/half_height 기준) —
# Fit-in-all 바운딩박스를 고정해 측점 이동 시 배율·중심이 흔들리지 않는다.
half = frame["half"] if frame else max((abs(x) for x, _y in ground_points), default=12.0)
frame_x = max(half + 2.0, _CROSS_TABLE_WIDTH / 2.0 + 1.0)
frame_top = half_height + 1.0
frame_bottom = table_bottom - 1.0
# 외곽 테두리: 단면 범위와 표를 함께 감싼다.
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0)
center_x = ox + (x0 + x1) / 2.0 * CROSS_MM
frame_top = oy + half_height + 4.0
frame_bottom = table_bottom - 4.0
corners = [
(-frame_x, frame_bottom),
(frame_x, frame_bottom),
(frame_x, frame_top),
(-frame_x, frame_top),
(-frame_x, frame_bottom),
(center_x - frame_x, frame_bottom),
(center_x + frame_x, frame_bottom),
(center_x + frame_x, frame_top),
(center_x - frame_x, frame_top),
(center_x - frame_x, frame_bottom),
]
border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR)
if border:
@@ -649,44 +481,3 @@ def build_cross_drawing(
_layer(FRAME_LAYER_ID, "Frame", locked=True),
],
}
def extract_quantity_table(
drawing_id: str, drawing: dict[str, Any]
) -> dict[str, float | None] | None:
"""확정 도면 JSON에서 수량 산출표 Text 값을 결정적 id로 역추출한다.
값 셀 id = uuid5(NS, "{drawing_id}:qtable:{key}"). 하나도 없으면 None을
반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다.
"""
id_to_key = {
str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS
}
entities = drawing.get("entities")
if not isinstance(entities, list):
return None
table: dict[str, float | None] = {}
found = False
for entity in entities:
if not isinstance(entity, dict):
continue
key = id_to_key.get(str(entity.get("id")))
if key is None:
continue
found = True
shape = entity.get("shapeData")
label = shape.get("label") if isinstance(shape, dict) else None
try:
table[key] = float(str(label).replace(",", ""))
except (TypeError, ValueError):
table[key] = None
if not found:
return None
for key in QUANTITY_VALUE_KEYS:
table.setdefault(key, None)
ground = table.get("ground")
planned = table.get("planned")
if isinstance(ground, (int, float)) and isinstance(planned, (int, float)):
table["cut"] = max(ground - planned, 0.0)
table["fill"] = max(planned - ground, 0.0)
return table
@@ -0,0 +1,179 @@
"""B07 횡단면도 장 배치 — A1 한 장에 들어가는 만큼 단면을 담는다.
채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다
(2026-08-29 사용자 확정). 한 장에 몇 개가 들어가는지는 단면 블록의 크기가
정하며, 블록 크기는 작성 척도(1/100)와 "설계선이 원지반과 갈라지는 구간"
정한다 — 즉 종이가 허용하는 만큼만 담고 남으면 다음 장으로 넘긴다.
"""
import re
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
CROSS_MM,
CROSS_TABLE_LAYER_ID,
DESIGN_LAYER_ID,
DRAWING_FORMAT,
FRAME_LAYER_ID,
GROUND_LAYER_ID,
ROCK_LAYER_ID,
STRUCTURE_LAYER_ID,
_clip_polyline,
_layer,
_section_window,
build_cross_drawing,
points_from_samples,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
cross_table_height,
cross_table_width,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
entities_bbox,
frame_entities,
usable_area,
)
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않는 형식.
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})")
# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다.
_BLOCK_GAP_MM = 8.0
_BORDER_PAD_MM = 4.0
def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
for point in design_line if isinstance(design_line, list) else []:
if not isinstance(point, dict):
continue
x = point.get("offset_m")
y = point.get("elevation_m")
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
points.append((float(x), float(y)))
return points
def section_block_size(
source: dict[str, Any], design_line: list[Any] | None, with_table: bool = True
) -> tuple[float, float]:
"""단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다.
build_cross_drawing의 테두리 계산과 같은 규칙을 쓴다: 가로는 그리는 범위와
수량표 중 넓은 쪽, 세로는 단면 높이 + 수량표.
"""
ground = points_from_samples(source.get("samples", []), "offset_m")
design = _design_points(design_line)
x0, x1 = _section_window(ground, design)
ground_clipped = _clip_polyline(ground, x0, x1)
ys = [y for _x, y in ground_clipped] + [y for x, y in design if x0 <= x <= x1]
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM + 5.0
width = 2.0 * max(
(x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM,
cross_table_width() / 2.0 + _BORDER_PAD_MM,
)
# 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유
top = half_height + _BORDER_PAD_MM
below = half_height + _BORDER_PAD_MM
if with_table:
below += 8.0 + cross_table_height()
return (width, top + below)
def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
칸 크기는 전체 블록의 최대 폭·높이로 통일한다 — 장마다 칸이 달라지면
도면끼리 비교가 안 되기 때문이다. 한 장에 cols x rows개가 들어간다.
"""
if not blocks:
return []
usable_w, usable_h = usable_area()
cell_w = max(w for _c, w, _h in blocks) + _BLOCK_GAP_MM
cell_h = max(h for _c, _w, h in blocks) + _BLOCK_GAP_MM
cols = max(1, int(usable_w // cell_w))
rows = max(1, int(usable_h // cell_h))
per_sheet = cols * rows
sheets: list[dict[str, Any]] = []
for index in range(0, len(blocks), per_sheet):
group = blocks[index : index + per_sheet]
number = index // per_sheet + 1
sheets.append(
{
"id": f"cross_s{number:02d}",
"number": number,
"chainages": [chainage for chainage, _w, _h in group],
"cols": cols,
"rows": rows,
"cell": (cell_w, cell_h),
}
)
return sheets
def _cell_center(index: int, rows: int, cell: tuple[float, float]) -> tuple[float, float]:
"""좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열로 넘어간다."""
cell_w, cell_h = cell
usable_w, usable_h = usable_area()
column, row = divmod(index, rows)
x = -usable_w / 2.0 + cell_w * (column + 0.5)
y = -usable_h / 2.0 + cell_h * (row + 0.5)
return (x, y)
def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]:
"""한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로)."""
entities: list[dict[str, Any]] = []
rows = int(sheet.get("rows", 1))
cell = tuple(sheet.get("cell", (100.0, 100.0))) # type: ignore[arg-type]
for index, section in enumerate(sections):
seed_id = f"{sheet['id']}:{section['chainage']}"
# 1) 원점(0,0)에 한 번 만들어 블록이 원점 대비 어디에 놓이는지 잰다.
probe = build_cross_drawing(
section["source"],
seed_id,
section.get("design_line"),
section.get("design"),
section.get("quantity_table"),
section.get("title", ""),
)
bbox = entities_bbox(probe["entities"])
if bbox is None:
continue
min_x, min_y, max_x, max_y = bbox
center_x, center_y = _cell_center(index, rows, cell)
# 2) 칸 가운데에 오도록 원점을 옮겨 다시 만든다.
origin = (
center_x - (min_x + max_x) / 2.0,
center_y - (min_y + max_y) / 2.0,
)
placed = build_cross_drawing(
section["source"],
seed_id,
section.get("design_line"),
section.get("design"),
section.get("quantity_table"),
section.get("title", ""),
origin=origin,
)
entities.extend(placed["entities"])
bbox = entities_bbox(entities)
if bbox:
entities.extend(frame_entities(sheet["id"], bbox, fit=False))
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [
_layer(GROUND_LAYER_ID, "Existing Ground", locked=True),
_layer(DESIGN_LAYER_ID, "Design Plan"),
_layer(STRUCTURE_LAYER_ID, "Structure"),
_layer(ROCK_LAYER_ID, "Rock Boundary"),
_layer(CROSS_TABLE_LAYER_ID, "Quantity Table"),
_layer(FRAME_LAYER_ID, "Frame", locked=True),
],
}
@@ -0,0 +1,321 @@
"""B07 횡단 수량 산출표 — 납품 양식 표 작도와 편집값 역추출.
표는 도면 좌표(종이 mm) 기준이다. 값 셀 id는 uuid5(NS, "{drawing_id}:qtable:{key}")로
정해져 있어, 사용자가 CAD에서 고친 숫자를 그대로 되읽을 수 있다.
"""
from typing import Any
from uuid import uuid5
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_ENTITY_NS,
CROSS_TABLE_LAYER_ID,
TABLE_LABEL_COLOR,
TABLE_LINE_COLOR,
TABLE_VALUE_COLOR,
_format,
_line_entity,
_text_entity,
)
# 횡단 수량 산출표 (납품 양식). 본문 6행 × 3그룹.
# 좌: 깍기(토사/암석)·측구(토사/암석)·쌓기·층따기
# 중: 면고르기(성토/절토)·지장목제거(성토/절토)·표토제거(성토/절토)
# 우: 편책·성토파종·절토살포·제근·(공란)·노면다짐
_CROSS_HEADER_KEYS: tuple[tuple[str, str], ...] = (
("지반고", "ground"),
("계획고", "planned"),
("성토고", "fill"),
("절토고", "cut"),
)
# 좌측 그룹: (그룹 라벨 or None, 하위 라벨 or None, 값 키 or None) — 행 순서대로.
_CROSS_LEFT_ROWS: tuple[tuple[str | None, str | None, str | None], ...] = (
("깍기", "토사", "cut_soil"),
(None, "암석", "cut_rock"),
("측구", "토사", "ditch_soil"),
(None, "암석", "ditch_rock"),
("쌓기", None, "embankment"),
("층따기", None, "benching"),
)
_CROSS_MIDDLE_ROWS: tuple[tuple[str | None, str, str], ...] = (
("면고르기", "성토", "grading_fill"),
(None, "절토", "grading_cut"),
("지장목제거", "성토", "tree_removal_fill"),
(None, "절토", "tree_removal_cut"),
("표토제거", "성토", "topsoil_fill"),
(None, "절토", "topsoil_cut"),
)
_CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = (
("편책", "fence"),
("성토파종", "fill_seeding"),
("절토살포", "cut_spraying"),
("제근", "grubbing"),
(None, None),
("노면다짐", "road_compaction"),
)
QUANTITY_VALUE_KEYS: tuple[str, ...] = (
"ground",
"planned",
"fill",
"cut",
"cut_soil",
"cut_rock",
"ditch_soil",
"ditch_rock",
"embankment",
"benching",
"grading_fill",
"grading_cut",
"tree_removal_fill",
"tree_removal_cut",
"topsoil_fill",
"topsoil_cut",
"fence",
"fill_seeding",
"cut_spraying",
"grubbing",
"road_compaction",
)
# 표 치수는 종이 밀리미터다 (도면 좌표 = mm).
_CROSS_TABLE_WIDTH = 108.0
_CROSS_TABLE_ROW_HEIGHT = 5.0
_CROSS_TABLE_FONT = 2.0
# 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백)
_CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0)
def _cross_column_edges(width: float, center_x: float = 0.0) -> list[float]:
total = sum(_CROSS_COLUMN_WEIGHTS)
left = center_x - width / 2.0
edges = [left]
accumulated = 0.0
for weight in _CROSS_COLUMN_WEIGHTS:
accumulated += weight
edges.append(left + width * accumulated / total)
return edges
def _cross_table_entities(
drawing_id: str,
quantity_table: dict[str, float | None],
table_top: float,
title_label: str,
center_x: float = 0.0,
) -> list[dict[str, Any]]:
"""횡단 수량 산출표(납품 양식)를 병합 셀 그리드+텍스트로 만든다.
표는 center_x를 가운데로 놓는다 — 한 장에 여러 단면을 배치할 때 각 단면
블록의 중심으로 옮기기 위한 것이다.
"""
width = _CROSS_TABLE_WIDTH
row_h = _CROSS_TABLE_ROW_HEIGHT
font = _CROSS_TABLE_FONT
left = -width / 2.0
right = width / 2.0
edges = _cross_column_edges(width, center_x)
entities: list[dict[str, Any]] = []
def value_text(seed_key: str, x: float, y: float) -> dict[str, Any]:
value = quantity_table.get(seed_key)
return _text_entity(
f"{drawing_id}:qtable:{seed_key}",
_format(value) if isinstance(value, (int, float)) else "-",
x,
y,
CROSS_TABLE_LAYER_ID,
font,
TABLE_VALUE_COLOR,
)
def label_text(seed: str, label: str, x: float, y: float) -> dict[str, Any]:
return _text_entity(seed, label, x, y, CROSS_TABLE_LAYER_ID, font, TABLE_LABEL_COLOR)
def h_line(seed: str, x_from: float, x_to: float, y: float) -> None:
entities.append(
_line_entity(seed, (x_from, y), (x_to, y), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR)
)
def v_line(seed: str, x: float, y_from: float, y_to: float) -> None:
entities.append(
_line_entity(seed, (x, y_from), (x, y_to), CROSS_TABLE_LAYER_ID, TABLE_LINE_COLOR)
)
# ── 행 y 좌표 (제목행, 헤더행, 본문 6행)
y_title_top = table_top
y_header_top = y_title_top - row_h
y_body_top = y_header_top - row_h
body_rows = len(_CROSS_LEFT_ROWS)
y_bottom = y_body_top - row_h * body_rows
def body_y(row_index: int) -> float:
return y_body_top - row_h * row_index
# ── 외곽/가로선
h_line(f"{drawing_id}:qgrid:top", left, right, y_title_top)
h_line(f"{drawing_id}:qgrid:title", left, right, y_header_top)
h_line(f"{drawing_id}:qgrid:header", left, right, y_body_top)
h_line(f"{drawing_id}:qgrid:bottom", left, right, y_bottom)
v_line(f"{drawing_id}:qgrid:vl", left, y_title_top, y_bottom)
v_line(f"{drawing_id}:qgrid:vr", right, y_title_top, y_bottom)
# 본문 행 사이 가로선 — 그룹 병합 셀(좌 colA, 중 colE)은 병합 지속 구간에서 끊는다.
for boundary in range(1, body_rows):
y = body_y(boundary)
left_merged = _CROSS_LEFT_ROWS[boundary][0] is None
middle_merged = _CROSS_MIDDLE_ROWS[boundary][0] is None
seed = f"{drawing_id}:qgrid:b{boundary}"
if left_merged:
h_line(f"{seed}:l", edges[1], edges[4], y)
else:
h_line(f"{seed}:l", edges[0], edges[4], y)
if middle_merged:
h_line(f"{seed}:m", edges[5], edges[8], y)
else:
h_line(f"{seed}:m", edges[4], edges[8], y)
h_line(f"{seed}:r", edges[8], edges[11], y)
# ── 헤더행 (지반고/계획고/성토고/절토고): 4쌍 균등 분할
header_cell = width / 8.0
for pair_index, (label, key) in enumerate(_CROSS_HEADER_KEYS):
x_label = left + header_cell * (pair_index * 2 + 0.5)
x_value = left + header_cell * (pair_index * 2 + 1.5)
y_mid = y_header_top - row_h * 0.5
entities.append(label_text(f"{drawing_id}:qlabel:h:{key}", label, x_label, y_mid))
entities.append(value_text(key, x_value, y_mid))
if pair_index > 0:
v_line(
f"{drawing_id}:qgrid:hv{pair_index}",
left + header_cell * pair_index * 2,
y_header_top,
y_body_top,
)
v_line(
f"{drawing_id}:qgrid:hvl{pair_index}",
left + header_cell * (pair_index * 2 + 1),
y_header_top,
y_body_top,
)
# ── 제목행 (No.측점)
entities.append(
_text_entity(
f"{drawing_id}:qtitle",
title_label,
left + width * 0.03,
y_title_top - row_h * 0.5,
CROSS_TABLE_LAYER_ID,
font * 1.15,
TABLE_LABEL_COLOR,
align="left",
)
)
# ── 본문 세로선: colA|B 경계는 그룹 라벨이 하위 라벨과 분리된 행(깍기~측구 4행)만.
ab_rows = [i for i, row in enumerate(_CROSS_LEFT_ROWS) if row[1] is not None]
if ab_rows:
v_line(
f"{drawing_id}:qgrid:vab",
edges[1],
body_y(min(ab_rows)),
body_y(max(ab_rows) + 1),
)
for edge_index in (2, 3, 4, 5, 6, 7, 8, 9, 10):
v_line(f"{drawing_id}:qgrid:vc{edge_index}", edges[edge_index], y_body_top, y_bottom)
# ── 본문 셀 (좌/중/우 그룹)
def cell_mid(edge_from: int, edge_to: int) -> float:
return (edges[edge_from] + edges[edge_to]) / 2.0
for row_index in range(body_rows):
y_mid = body_y(row_index) - row_h * 0.5
group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index]
if group_l is not None and sub_l is not None:
# 그룹 라벨은 2행 병합 중앙 배치
y_group = body_y(row_index) - row_h # 병합 2행의 중앙
entities.append(
label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 1), y_group)
)
elif group_l is not None:
entities.append(
label_text(f"{drawing_id}:qlabel:lg:{row_index}", group_l, cell_mid(0, 2), y_mid)
)
if sub_l is not None:
entities.append(
label_text(f"{drawing_id}:qlabel:ls:{row_index}", sub_l, cell_mid(1, 2), y_mid)
)
if key_l is not None:
entities.append(value_text(key_l, cell_mid(2, 3), y_mid))
group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index]
if group_m is not None:
y_group = body_y(row_index) - row_h
entities.append(
label_text(f"{drawing_id}:qlabel:mg:{row_index}", group_m, cell_mid(4, 5), y_group)
)
entities.append(
label_text(f"{drawing_id}:qlabel:ms:{row_index}", sub_m, cell_mid(5, 6), y_mid)
)
entities.append(value_text(key_m, cell_mid(6, 7), y_mid))
label_r, key_r = _CROSS_RIGHT_ROWS[row_index]
if label_r is not None:
entities.append(
label_text(f"{drawing_id}:qlabel:r:{row_index}", label_r, cell_mid(8, 9), y_mid)
)
if key_r is not None:
entities.append(value_text(key_r, cell_mid(9, 10), y_mid))
return entities
def extract_quantity_table(
drawing_id: str, drawing: dict[str, Any]
) -> dict[str, float | None] | None:
"""확정 도면 JSON에서 수량 산출표 Text 값을 결정적 id로 역추출한다.
값 셀 id = uuid5(NS, "{drawing_id}:qtable:{key}"). 하나도 없으면 None을
반환해 호출부가 요청 본문 quantity_table 폴백을 쓰게 한다.
"""
id_to_key = {
str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable:{key}")): key for key in QUANTITY_VALUE_KEYS
}
entities = drawing.get("entities")
if not isinstance(entities, list):
return None
table: dict[str, float | None] = {}
found = False
for entity in entities:
if not isinstance(entity, dict):
continue
key = id_to_key.get(str(entity.get("id")))
if key is None:
continue
found = True
shape = entity.get("shapeData")
label = shape.get("label") if isinstance(shape, dict) else None
try:
table[key] = float(str(label).replace(",", ""))
except (TypeError, ValueError):
table[key] = None
if not found:
return None
for key in QUANTITY_VALUE_KEYS:
table.setdefault(key, None)
ground = table.get("ground")
planned = table.get("planned")
if isinstance(ground, (int, float)) and isinstance(planned, (int, float)):
table["cut"] = max(ground - planned, 0.0)
table["fill"] = max(planned - ground, 0.0)
return table
def cross_table_width() -> float:
"""수량 산출표 가로 폭(mm)."""
return _CROSS_TABLE_WIDTH
def cross_table_height() -> float:
"""수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행."""
return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2)
+195 -46
View File
@@ -17,14 +17,13 @@ from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Repository import (
get_confirmed_route_context,
get_cross_section_design,
get_cross_section_designs,
get_longitudinal_section,
merge_cross_section_design_by_round,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
QUANTITY_VALUE_KEYS,
build_cross_drawing,
extract_quantity_table,
infer_station_interval,
station_no_label,
)
@@ -32,6 +31,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
build_longitudinal_drawing,
longitudinal_chunks,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
CROSS_SHEET_ID,
build_cross_sheet,
plan_cross_sheets,
section_block_size,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
QUANTITY_VALUE_KEYS,
extract_quantity_table,
)
from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingConfirmRequest,
DesignDrawingConfirmResponse,
@@ -124,7 +133,11 @@ def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
temporary.replace(path)
def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]:
def _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
manifest_drawings = _read_manifest(project_root)["drawings"]
@@ -138,22 +151,48 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra
)
for chunk in longitudinal_chunks(longitudinal)
]
for path in _cross_files(longitudinal_path, longitudinal):
for sheet in _cross_sheet_plan(project_root, longitudinal_path, designs):
chainages = sheet["chainages"]
first = station_by_chainage.get(chainages[0], {})
last = station_by_chainage.get(chainages[-1], {})
span = str(first.get("label") or chainages[0])
if len(chainages) > 1:
span = f"{span}~{last.get('label') or chainages[-1]}"
drawings.append(
DesignDrawingItem(
id=sheet["id"],
kind="cross",
label=f"{sheet['number']}장 ({span})",
chainage_m=float(first.get("chainage_m", chainages[0])),
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
)
)
return drawings
def _cross_sheet_plan(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
한 장에 몇 개가 들어가는지는 단면 블록 크기가 정한다(작성 척도 1/100 +
설계선이 원지반과 갈라지는 구간). 설계 지정이 없으면 원지반 기준 폭이 된다.
"""
longitudinal = _read_json(longitudinal_path)
blocks: list[tuple[int, float, float]] = []
for path in sorted(_cross_files(longitudinal_path, longitudinal)):
match = _CROSS_ID.fullmatch(path.stem)
if not match:
continue
chainage = int(match.group(1))
station = station_by_chainage.get(chainage, {})
drawings.append(
DesignDrawingItem(
id=path.stem,
kind="cross",
label=str(station.get("label") or f"STA.{chainage // 1000}+{chainage % 1000:03d}"),
chainage_m=float(station.get("chainage_m", chainage)),
confirmed=bool(manifest_drawings.get(path.stem, {}).get("confirmed")),
)
)
return drawings
source = _read_json(path)
design = (designs or {}).get(chainage)
design_line = _cross_design_line(longitudinal_path, source, design)
width, height = section_block_size(source, design_line)
blocks.append((chainage, width, height))
return plan_cross_sheets(blocks)
def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]:
@@ -241,6 +280,53 @@ def _cross_design_line(
return None
def _cross_section_input(
longitudinal_path: Path,
longitudinal: dict[str, Any],
chainage: int,
design: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
path = longitudinal_path.parent.parent / "cross_sections" / f"cross_{chainage:05d}m.json"
if not path.is_file():
return None
source = _read_json(path)
interval = infer_station_interval(longitudinal.get("stations") or [])
return {
"chainage": chainage,
"source": source,
"design": design,
"design_line": _cross_design_line(longitudinal_path, source, design),
"quantity_table": _quantity_table(source),
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
}
def _read_cross_sheet(
project_root: Path,
longitudinal_path: Path,
drawing_id: str,
stored_designs: dict[str, Any] | None,
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정} 묶음이다."""
designs = stored_designs if isinstance(stored_designs, dict) else {}
plan = _cross_sheet_plan(project_root, longitudinal_path, designs)
sheet = next((item for item in plan if item["id"] == drawing_id), None)
if sheet is None:
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
longitudinal = _read_json(longitudinal_path)
sections = [
section
for section in (
_cross_section_input(longitudinal_path, longitudinal, chainage, designs.get(chainage))
for chainage in sheet["chainages"]
)
if section is not None
]
label = f"횡단면도 {sheet['number']}"
return "cross", label, build_cross_sheet(sheet, sections), False, None
def _read_drawing(
project_root: Path,
longitudinal_path: Path,
@@ -280,6 +366,9 @@ def _read_drawing(
None,
)
if CROSS_SHEET_ID.fullmatch(drawing_id):
return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design)
if not _CROSS_ID.fullmatch(drawing_id):
raise ValueError("올바르지 않은 도면 ID입니다.")
path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
@@ -322,6 +411,7 @@ def _store_confirmed_drawing(
drawing: dict[str, Any],
expected_ids: set[str],
quantity_table: dict[str, Any] | None = None,
quantity_tables: dict[str, Any] | None = None,
) -> bool:
if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list):
raise ValueError("CAD 도면 스키마가 올바르지 않습니다.")
@@ -343,6 +433,9 @@ def _store_confirmed_drawing(
}
if item.kind == "cross" and isinstance(quantity_table, dict):
entry["quantity_table"] = quantity_table
if isinstance(quantity_tables, dict) and quantity_tables:
# 장 도면: 측점별 수량표를 그대로 보관한다(뒷단계 수량산출이 측점 단위).
entry["quantity_tables"] = quantity_tables
manifest["drawings"][item.id] = entry
_write_manifest(project_root, manifest)
confirmed_ids = {
@@ -359,6 +452,18 @@ def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
_write_manifest(project_root, manifest)
async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
pool = get_db_pool()
async with pool.acquire() as connection:
rows = await get_cross_section_designs(connection, route_id)
return {
int(round(float(row["chainage_m"]))): row["design"]
for row in rows
if isinstance(row, dict) and isinstance(row.get("design"), dict)
}
@router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse)
async def get_design_drawing_list(
project_id: UUID,
@@ -366,7 +471,8 @@ async def get_design_drawing_list(
"""B07 좌측 패널용 도면 메타데이터만 캐시한다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
designs = await _designs_by_chainage(route_id)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
return DesignDrawingListResponse(
project_id=str(project_id), route_id=route_id, drawings=drawings
)
@@ -391,6 +497,7 @@ async def get_design_drawing(
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
design: dict[str, Any] | None = None
source_design: Any = None
cross_match = _CROSS_ID.fullmatch(drawing_id)
if cross_match:
pool = get_db_pool()
@@ -398,8 +505,12 @@ async def get_design_drawing(
design = await get_cross_section_design(
connection, route_id, int(cross_match.group(1))
)
source_design = design
elif CROSS_SHEET_ID.fullmatch(drawing_id):
# 장은 여러 측점을 담으므로 노선 전체 지정을 한 번에 읽어 넘긴다.
source_design = await _designs_by_chainage(route_id)
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
_read_drawing, project_root, longitudinal_path, drawing_id, design
_read_drawing, project_root, longitudinal_path, drawing_id, source_design
)
return DesignDrawingResponse(
project_id=str(project_id),
@@ -429,7 +540,7 @@ async def get_design_drawing(
def _recompute_confirmed_design(
longitudinal_path: Path, drawing_id: str, designation: dict[str, Any]
longitudinal_path: Path, cross_stem: str, designation: dict[str, Any]
) -> dict[str, Any]:
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
@@ -437,7 +548,7 @@ def _recompute_confirmed_design(
측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
"""
longitudinal = _read_json(longitudinal_path)
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{drawing_id}.json"
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json"
source = _read_json(cross_path)
samples = source.get("samples")
if not isinstance(samples, list):
@@ -469,16 +580,30 @@ async def confirm_design_drawing(
"""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path)
designs = await _designs_by_chainage(route_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
if not item:
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
sheet = None
if CROSS_SHEET_ID.fullmatch(drawing_id):
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
)
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
# 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백.
quantity_table = (
extract_quantity_table(drawing_id, request.drawing) or request.quantity_table
if item.kind == "cross"
else None
)
# 장에는 측점이 여럿이라 측점별로 뽑는다 (엔티티 id 씨앗 = "{장id}:{측점}").
quantity_table = None
quantity_tables: dict[str, Any] = {}
if sheet is not None:
for chainage in sheet["chainages"]:
table = extract_quantity_table(f"{drawing_id}:{chainage}", request.drawing)
if table:
quantity_tables[str(chainage)] = table
elif item.kind == "cross":
quantity_table = (
extract_quantity_table(drawing_id, request.drawing) or request.quantity_table
)
# 단계 완료 기준은 횡단도(cross)만 본다. 종단도(longitudinal)는 확정 여부와 무관.
all_confirmed = await asyncio.to_thread(
_store_confirmed_drawing,
@@ -487,33 +612,47 @@ async def confirm_design_drawing(
request.drawing,
{candidate.id for candidate in items if candidate.kind == "cross"},
quantity_table,
quantity_tables or None,
)
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
confirmed_design: dict[str, Any] | None = None
chainage_int: int | None = None
# 장은 담긴 측점 전부를 함께 확정한다.
recomputed: list[tuple[int, dict[str, Any]]] = []
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
if item.kind == "cross" and cross_match:
chainage_int = int(cross_match.group(1))
async with pool.acquire() as connection:
designation = await get_cross_section_design(connection, route_id, chainage_int)
if designation:
try:
confirmed_design = await asyncio.to_thread(
_recompute_confirmed_design, longitudinal_path, drawing_id, designation
)
except (ValueError, KeyError, FileNotFoundError, OSError):
logger.warning(
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s",
drawing_id,
exc_info=True,
targets: list[int] = []
if sheet is not None:
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))]
for chainage_int in targets:
designation = designs.get(chainage_int)
if not designation:
continue
try:
recomputed.append(
(
chainage_int,
await asyncio.to_thread(
_recompute_confirmed_design,
longitudinal_path,
f"cross_{chainage_int:05d}m",
designation,
),
)
)
except (ValueError, KeyError, FileNotFoundError, OSError):
logger.warning(
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s",
drawing_id,
chainage_int,
exc_info=True,
)
async with pool.acquire() as connection:
await connection.begin()
try:
if confirmed_design is not None and chainage_int is not None:
for chainage_int, confirmed_design in recomputed:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
@@ -534,7 +673,7 @@ async def confirm_design_drawing(
id=drawing_id,
confirmed=True,
all_confirmed=all_confirmed,
design=confirmed_design,
design=recomputed[0][1] if len(recomputed) == 1 else None,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -566,16 +705,26 @@ async def invalidate_design_drawing(
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
cross_match = _CROSS_ID.fullmatch(drawing_id)
stale: list[int] = []
if cross_match:
stale = [int(cross_match.group(1))]
elif CROSS_SHEET_ID.fullmatch(drawing_id):
designs = await _designs_by_chainage(route_id)
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
)
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
stale = list(sheet["chainages"]) if sheet else []
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
# 확정 도면을 편집하면 해당 측점 설계도 잠정 상태로 되돌린다.
if cross_match:
# 확정 도면을 편집하면 담긴 측점 설계도 잠정 상태로 되돌린다.
for chainage_int in stale:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=int(cross_match.group(1)),
chainage_int=chainage_int,
patch={"status": "provisional"},
)
async with connection.cursor() as cursor:
@@ -1,4 +1,4 @@
---
---
title: 원가 기초데이터 원천 (상용 내역 프로그램 배포자료 관측)
category: 05_원가정보
sources: