693 lines
26 KiB
Python
693 lines
26 KiB
Python
"""B07 CAD 도면 조립 엔진 — B06/B05 산출물을 openwebcad 엔티티로 직렬화한다.
|
|
|
|
납품 도면 양식 복제:
|
|
- 종단도: 30측점 N분할 + 하단 측점 테이블(곡선/측점/거리/추가거리/지반고/
|
|
계획고/절토고/성토고/구배). 곡선·구배 행은 B05 profile_alignment의
|
|
종단곡선(curves)·구배(segments) 기하를 그대로 표기한다.
|
|
- 횡단도: 지표/설계/구조물 레이어 분리 + 하단 수량 산출표(No.행, 지반고~
|
|
절토고 헤더행, 깍기·측구·쌓기·층따기 / 면고르기·지장목제거·표토제거 /
|
|
편책·성토파종·절토살포·제근·노면다짐 3그룹 병합 셀 그리드).
|
|
|
|
모든 값 텍스트는 잠금 해제 레이어의 Text 엔티티라 CAD에서 직접 수정할 수
|
|
있고, id가 결정적(uuid5)이라 확정 시 도면 JSON에서 수량표 값을 역추출한다.
|
|
|
|
좌표 규약: 종단도 x=chainage_m, 횡단도 x=offset_m(+좌/-우), y=elevation_m.
|
|
"""
|
|
|
|
from typing import Any
|
|
from uuid import UUID, uuid5
|
|
|
|
# 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3).
|
|
GROUND_LAYER_ID = "b08-ground"
|
|
GROUND_COLOR = "#f5f7fa"
|
|
DESIGN_LAYER_ID = "b08-design"
|
|
DESIGN_COLOR = "#b794f6"
|
|
STRUCTURE_LAYER_ID = "b08-structure"
|
|
STRUCTURE_COLOR = "#f6d55c"
|
|
ROCK_LAYER_ID = "b08-rock-boundary"
|
|
ROCK_COLOR = "#f59e0b"
|
|
FRAME_LAYER_ID = "b08-frame"
|
|
LONG_TABLE_LAYER_ID = "b08-long-table"
|
|
CROSS_TABLE_LAYER_ID = "b08-cross-table"
|
|
TABLE_LINE_COLOR = "#8ea0b5"
|
|
TABLE_LABEL_COLOR = "#e8edf4"
|
|
TABLE_VALUE_COLOR = "#ffe066"
|
|
|
|
# 종단도 분할 기준: 측점 30개 초과 시 30개 단위(경계 1측점 중복)로 나눈다.
|
|
LONG_SPLIT_STATION_COUNT = 30
|
|
|
|
_ENTITY_NS = UUID("f15df4cc-fbb1-4bc9-b04c-63052fe43f96")
|
|
_POLY_NS = UUID("9dd28aab-cee5-4df6-b8ae-b9167fbde9a8")
|
|
|
|
# 도면 직렬화 포맷 버전. 테이블·레이어 구성 변경 시 올린다 — 확정 저장본이
|
|
# 이 버전과 다르면 캐시를 버리고 원본에서 재생성한다(구양식 서빙 방지).
|
|
# v3: 횡단 로컬좌표(계획고=0) 정규화 + 공통 프레임 + 암 경계선 레이어.
|
|
# v4: 종단 그래프 축·회색 측점 세로선·기준선(datum) + 테이블 눈금·세로쓰기·구배 원 표기.
|
|
# v5: 횡단 콘텐츠 bbox 중심 정렬(경사 드리프트 제거) + 외곽 테두리 제거.
|
|
# v6: 외곽 테두리 복구 (콘텐츠 중심 정렬 유지, 노선 공통 크기 사각형).
|
|
# v7: 종단도 A1 도각 템플릿 프레임 병합 (b08-frame 잠금 레이어).
|
|
DRAWING_FORMAT = 7
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 엔티티 직렬화 헬퍼
|
|
# ---------------------------------------------------------------------------
|
|
def _line_entity(
|
|
seed: str,
|
|
start: tuple[float, float],
|
|
end: tuple[float, float],
|
|
layer_id: str,
|
|
color: str,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": str(uuid5(_ENTITY_NS, seed)),
|
|
"type": "Line",
|
|
"lineColor": color,
|
|
"lineWidth": 1,
|
|
"layerId": layer_id,
|
|
"shapeData": {
|
|
"startPoint": {"x": start[0], "y": start[1]},
|
|
"endPoint": {"x": end[0], "y": end[1]},
|
|
},
|
|
}
|
|
|
|
|
|
def polyline_entity(
|
|
drawing_id: str,
|
|
points: list[tuple[float, float]],
|
|
layer_id: str,
|
|
color: str,
|
|
suffix: str = "",
|
|
dash: list[int] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""점열을 openwebcad PolyLine(자식 Line 묶음)으로 직렬화한다 (점 2개 미만이면 None)."""
|
|
if len(points) < 2:
|
|
return None
|
|
seed_base = f"{drawing_id}:{layer_id}{suffix}"
|
|
children = []
|
|
for index in range(len(points) - 1):
|
|
child = _line_entity(
|
|
f"{seed_base}:{index}", points[index], points[index + 1], layer_id, color
|
|
)
|
|
if dash:
|
|
child["lineDash"] = dash
|
|
children.append(child)
|
|
poly: dict[str, Any] = {
|
|
"id": str(uuid5(_POLY_NS, seed_base)),
|
|
"type": "PolyLine",
|
|
"lineColor": color,
|
|
"lineWidth": 1,
|
|
"layerId": layer_id,
|
|
"shapeData": None,
|
|
"children": children,
|
|
}
|
|
if dash:
|
|
poly["lineDash"] = dash
|
|
return poly
|
|
|
|
|
|
def _text_entity(
|
|
seed: str,
|
|
label: str,
|
|
x: float,
|
|
y: float,
|
|
layer_id: str,
|
|
font_size: float,
|
|
color: str,
|
|
align: str = "center",
|
|
direction: tuple[float, float] = (1.0, 0.0),
|
|
) -> dict[str, Any]:
|
|
"""direction=(0,1)이면 세로쓰기(아래→위) — 납품 종단 테이블 값 표기."""
|
|
return {
|
|
"id": str(uuid5(_ENTITY_NS, seed)),
|
|
"type": "Text",
|
|
"lineColor": color,
|
|
"lineWidth": 1,
|
|
"layerId": layer_id,
|
|
"shapeData": {
|
|
"label": label,
|
|
"basePoint": {"x": x, "y": y},
|
|
"options": {
|
|
"textDirection": {"x": direction[0], "y": direction[1]},
|
|
"textAlign": align,
|
|
"textColor": color,
|
|
"fontSize": font_size,
|
|
"fontFamily": "sans-serif",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _layer(layer_id: str, name: str, locked: bool = False) -> dict[str, Any]:
|
|
return {"id": layer_id, "name": name, "isVisible": True, "isLocked": locked}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 공용 데이터 헬퍼
|
|
# ---------------------------------------------------------------------------
|
|
def points_from_samples(samples: list[Any], x_key: str) -> list[tuple[float, float]]:
|
|
"""유효 샘플에서 (x, elevation) 점열을 뽑는다 (x_key: chainage_m 또는 offset_m)."""
|
|
points: list[tuple[float, float]] = []
|
|
for sample in samples:
|
|
if not isinstance(sample, dict) or not sample.get("valid", False):
|
|
continue
|
|
x = sample.get(x_key)
|
|
y = sample.get("elevation_m", sample.get("z"))
|
|
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
|
|
points.append((float(x), float(y)))
|
|
return points
|
|
|
|
|
|
def _design_profile_points(longitudinal: dict[str, Any]) -> list[tuple[float, float]]:
|
|
profiles = longitudinal.get("design_profiles")
|
|
if not isinstance(profiles, list) or not profiles:
|
|
return []
|
|
points: list[tuple[float, float]] = []
|
|
for point in profiles[0].get("samples", []):
|
|
if not isinstance(point, dict):
|
|
continue
|
|
x = point.get("chainage_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 _interpolate(points: list[tuple[float, float]], x: float) -> float | None:
|
|
"""정렬 점열의 선형 보간(범위 밖 끝값 클램프). 점이 없으면 None."""
|
|
if not points:
|
|
return None
|
|
if x <= points[0][0]:
|
|
return points[0][1]
|
|
if x >= points[-1][0]:
|
|
return points[-1][1]
|
|
for index in range(1, len(points)):
|
|
x1, y1 = points[index]
|
|
if x > x1:
|
|
continue
|
|
x0, y0 = points[index - 1]
|
|
span = x1 - x0
|
|
if span <= 0:
|
|
return y1
|
|
return y0 + (y1 - y0) * (x - x0) / span
|
|
return points[-1][1]
|
|
|
|
|
|
def _stations(longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
|
|
stations = longitudinal.get("stations")
|
|
if not isinstance(stations, list):
|
|
return []
|
|
return [
|
|
station
|
|
for station in stations
|
|
if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float))
|
|
]
|
|
|
|
|
|
def infer_station_interval(stations: list[dict[str, Any]]) -> float:
|
|
"""연속 chainage 차이의 최빈값으로 측점 간격을 추정한다 (No. 표기·칸 폭 기준)."""
|
|
counts: dict[float, int] = {}
|
|
chainages = sorted(
|
|
float(s["chainage_m"])
|
|
for s in stations
|
|
if isinstance(s, dict) and isinstance(s.get("chainage_m"), (int, float))
|
|
)
|
|
for index in range(1, len(chainages)):
|
|
difference = round(chainages[index] - chainages[index - 1], 1)
|
|
if difference > 0:
|
|
counts[difference] = counts.get(difference, 0) + 1
|
|
if not counts:
|
|
return 20.0
|
|
return max(counts.items(), key=lambda pair: (pair[1], pair[0]))[0]
|
|
|
|
|
|
def station_no_label(chainage_m: float, interval_m: float) -> str:
|
|
"""납품 도면 측점 표기: No.n (비정규 측점은 No.n+잔여거리)."""
|
|
safe = interval_m if interval_m > 0 else 1.0
|
|
number = int((chainage_m + 1e-6) // safe)
|
|
remainder = chainage_m - number * safe
|
|
if remainder >= safe - 0.05:
|
|
number += 1
|
|
remainder = 0.0
|
|
if abs(remainder) < 0.05:
|
|
return f"No.{number}"
|
|
return f"No.{number}+{remainder:.1f}"
|
|
|
|
|
|
def _profile_alignment(longitudinal: dict[str, Any]) -> dict[str, Any]:
|
|
alignment = longitudinal.get("profile_alignment")
|
|
return alignment if isinstance(alignment, dict) else {}
|
|
|
|
|
|
def _format(value: float | None, decimals: int = 2) -> str:
|
|
return "" if value is None else f"{value:.{decimals}f}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 횡단도: 레이어 분리 + 수량 산출표 (납품 양식)
|
|
# ---------------------------------------------------------------------------
|
|
def _ditch_bounds(design: dict[str, Any] | None) -> tuple[float, float] | None:
|
|
"""설계 dict에서 측구(구조물) 오프셋 구간 [lo, hi]를 구한다. 없으면 None."""
|
|
if not isinstance(design, dict) or not design.get("ditch_enabled"):
|
|
return None
|
|
ditch = design.get("ditch")
|
|
edges = design.get("road_edges")
|
|
side = design.get("ditch_side")
|
|
if not isinstance(ditch, dict) or not isinstance(edges, dict) or side not in ("left", "right"):
|
|
return None
|
|
ditch_type = ditch.get("type")
|
|
if ditch_type == "standard":
|
|
width = ditch.get("top_width_m")
|
|
elif ditch_type == "l_type":
|
|
width = ditch.get("width_m")
|
|
else:
|
|
return None
|
|
edge = edges.get(side)
|
|
if not isinstance(edge, dict) or not isinstance(width, (int, float)):
|
|
return None
|
|
inner = edge.get("offset_m")
|
|
if not isinstance(inner, (int, float)):
|
|
return None
|
|
outer = float(inner) + (float(width) if side == "left" else -float(width))
|
|
return (min(float(inner), outer), max(float(inner), outer))
|
|
|
|
|
|
def _cross_line_entities(
|
|
drawing_id: str,
|
|
design_line: list[Any] | None,
|
|
design: dict[str, Any] | None,
|
|
dy: float = 0.0,
|
|
) -> list[dict[str, Any]]:
|
|
"""횡단 설계선을 설계(b08-design)/구조물(b08-structure, 측구 구간)로 분리한다."""
|
|
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) - dy))
|
|
if len(points) < 2:
|
|
return []
|
|
|
|
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)
|
|
return [design_poly] if design_poly else []
|
|
|
|
lo, hi = bounds
|
|
tolerance = 1e-6
|
|
before = [p for p in points if p[0] <= lo + tolerance]
|
|
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)
|
|
if poly:
|
|
entities.append(poly)
|
|
structure = polyline_entity(drawing_id, 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)
|
|
|
|
|
|
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 _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 build_cross_drawing(
|
|
source: dict[str, Any],
|
|
drawing_id: str,
|
|
design_line: list[Any] | None,
|
|
design: dict[str, Any] | None,
|
|
quantity_table: dict[str, float | None] | None,
|
|
title_label: str,
|
|
design_elevation_m: float | None = None,
|
|
frame: dict[str, float] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
|
|
|
|
로컬 좌표 정규화: 계획고(design_elevation_m)를 y=0으로 두어 모든 측점
|
|
도면이 같은 화면 배치를 갖는다.
|
|
|
|
배치 규약: 이 단면의 선(지표+설계+암 경계) 전체 bbox 중심을 y=0에 두어
|
|
측점마다 콘텐츠가 화면 중앙에 온다(종단 경사 드리프트 제거). 테이블은
|
|
frame(노선 공통 최대 반높이 half_height)이 오면 전 측점 동일 y에 고정해
|
|
Fit-in-all 배율·중심이 측점 간 흔들리지 않게 한다. design_elevation_m는
|
|
현재 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
|
|
"""
|
|
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 []:
|
|
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)):
|
|
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]
|
|
if isinstance(rock_offset, (int, float)):
|
|
all_ys.extend(y + float(rock_offset) for _x, y in raw_ground)
|
|
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]
|
|
entities: list[dict[str, Any]] = []
|
|
ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR)
|
|
if ground:
|
|
entities.append(ground)
|
|
entities.extend(_cross_line_entities(drawing_id, design_line, design, dy))
|
|
|
|
# 암 경계선: 지반선 복사 + 오프셋(음수=하향). 암 지반 지정 측점에만 존재.
|
|
if isinstance(rock_offset, (int, float)) and ground_points:
|
|
rock_points = [(x, y + float(rock_offset)) for x, y in ground_points]
|
|
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
|
|
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)
|
|
|
|
# 외곽 테두리: 전 측점 동일 크기 사각형(노선 공통 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
|
|
corners = [
|
|
(-frame_x, frame_bottom),
|
|
(frame_x, frame_bottom),
|
|
(frame_x, frame_top),
|
|
(-frame_x, frame_top),
|
|
(-frame_x, frame_bottom),
|
|
]
|
|
border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR)
|
|
if border:
|
|
entities.append(border)
|
|
|
|
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),
|
|
],
|
|
}
|
|
|
|
|
|
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
|