실측 결과 축척은 문제가 아니었음. 실거리 3 m 가 모든 단면에서 30.0 mm (mm/m = 10.0 단일값). 뒤죽박죽으로 보인 원인은 칸 크기 — 한 장 안에서 폭 116~218 mm, 높이 29~178 mm 로 제각각이었음. - 장별 완전 통일: 한 장 안 모든 칸을 그 장 최대 블록 크기로 통일(_grid_for). 테두리는 build_cross_drawing(cell_frame=...) 로 칸에 맞춰 그림. - 장 경계는 전체 최소 장수가 되도록 동적계획으로 선택(_sheet_breaks). 앞에서부터 채우면 바로 뒤의 큰 단면이 칸을 키워 6칸짜리 장에 1개만 실리는 낭비가 있었음. - 축척은 지식DB 「설계제원_총괄」 기준 1/100 고정 — 어떤 경우에도 줄이지 않고 안 들어가면 장을 나눔. 장수는 세트마다 달라짐(2026-09-04 사용자 확정). 검증(용화_LAS): 장 21개 전부 칸 크기 단일값, mm/m = 10 단일, 측점 65개 누락 0, 칸 밖 이탈 0.000 mm. 실서버 API 로도 동일 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
566 lines
22 KiB
Python
566 lines
22 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
|
|
|
|
from config.config_system import DRAWING_SCALE_CROSS
|
|
|
|
# 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(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,
|
|
width: int = 1,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": str(uuid5(_ENTITY_NS, seed)),
|
|
"type": "Line",
|
|
"lineColor": color,
|
|
"lineWidth": width,
|
|
"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,
|
|
width: int = 1,
|
|
) -> 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, width
|
|
)
|
|
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 table_entity(
|
|
seed: str,
|
|
origin: tuple[float, float],
|
|
column_widths: list[float],
|
|
row_heights: list[float],
|
|
cells: list[list[dict[str, Any] | None]],
|
|
layer_id: str,
|
|
color: str,
|
|
font_size: float,
|
|
text_color: str,
|
|
padding: float = 1.0,
|
|
) -> dict[str, Any]:
|
|
"""표 하나를 openwebcad Table 엔티티로 직렬화한다.
|
|
|
|
origin은 표의 좌측 상단이다. cells[r][c]는 칸 하나이고 None은 병합에 먹힌 자리다.
|
|
칸은 {"text", "colSpan", "rowSpan", "align", "color", "fontSize", "bold", "italic"}를 갖는다.
|
|
격자선은 담지 않는다 — 병합 자리에서 선을 끊는 규칙은 표가 스스로 안다.
|
|
"""
|
|
return {
|
|
"id": str(uuid5(_ENTITY_NS, seed)),
|
|
"type": "Table",
|
|
"lineColor": color,
|
|
"lineWidth": 1,
|
|
"layerId": layer_id,
|
|
"shapeData": {
|
|
"origin": {"x": origin[0], "y": origin[1]},
|
|
"columnWidths": list(column_widths),
|
|
"rowHeights": list(row_heights),
|
|
"cells": cells,
|
|
"style": {
|
|
"fontSize": font_size,
|
|
"fontFamily": "sans-serif",
|
|
"textColor": text_color,
|
|
"padding": padding,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
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_plus_label(chainage_m: float, interval_m: float, decimals: int = 1) -> str:
|
|
"""납품 도면 측점 표기 — `120+ 0.0` (M.N 은 소수 2자리 `0+16.90`).
|
|
|
|
종전에는 `No.120` 이었다. 납품 도면과 표기를 맞춘다(2026-09-03 사용자 확정).
|
|
"""
|
|
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
|
|
gap = " " if decimals == 1 else ""
|
|
return f"{number}+{gap}{remainder:.{decimals}f}"
|
|
|
|
|
|
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,
|
|
paper: Any = None,
|
|
window: tuple[float, float] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""횡단 설계선을 설계(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):
|
|
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) 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, place(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, place(part), DESIGN_LAYER_ID, DESIGN_COLOR, suffix)
|
|
if poly:
|
|
entities.append(poly)
|
|
structure = polyline_entity(drawing_id, place(ditch_part), STRUCTURE_LAYER_ID, STRUCTURE_COLOR)
|
|
if structure:
|
|
entities.append(structure)
|
|
return entities
|
|
|
|
|
|
# 도면 좌표 = 종이 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 _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 _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(
|
|
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,
|
|
origin: tuple[float, float] = (0.0, 0.0),
|
|
cell_frame: tuple[float, float, float, float] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
|
|
|
|
좌표는 종이 밀리미터(1/100 — 실거리 1 m = 10 mm)이고, 그리는 가로 범위는
|
|
설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의
|
|
bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재
|
|
배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
|
|
|
|
cell_frame(왼쪽, 아래, 오른쪽, 위 — 종이 mm)을 주면 테두리를 그 칸에 맞춰
|
|
그린다. 장 배치에서 한 장 안의 칸을 같은 크기로 통일할 때 쓴다(2026-09-04
|
|
사용자 확정 — 축척 1/100은 그대로, 칸만 통일).
|
|
"""
|
|
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 []:
|
|
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
|
|
|
|
# 표 모듈은 이 모듈의 직렬화 헬퍼를 쓰므로 순환을 피해 함수 안에서 들여온다.
|
|
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 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
|
|
|
|
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, [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, paper, (x0, x1)))
|
|
|
|
# 암 경계선: 지반선 복사 + 오프셋(음수=하향). 암 지반 지정 측점에만 존재.
|
|
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)
|
|
|
|
# 표는 단면 중심 아래에 붙인다 (좌표는 mm). 그리는 창이 좌우 비대칭이면
|
|
# 단면 중심이 원점과 어긋나므로 표도 같은 중심을 쓴다.
|
|
center_x = ox + (x0 + x1) / 2.0 * CROSS_MM
|
|
half_height = own_half_height * CROSS_MM + 5.0
|
|
table_bottom = oy - half_height
|
|
if quantity_table is not None:
|
|
table_top = oy - half_height - 8.0
|
|
entities.extend(
|
|
_cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x)
|
|
)
|
|
table_bottom = table_top - cross_table_height()
|
|
|
|
# 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다.
|
|
if cell_frame is not None:
|
|
frame_left, frame_bottom, frame_right, frame_top = cell_frame
|
|
else:
|
|
frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0)
|
|
frame_left, frame_right = center_x - frame_x, center_x + frame_x
|
|
frame_top = oy + half_height + 4.0
|
|
frame_bottom = table_bottom - 4.0
|
|
corners = [
|
|
(frame_left, frame_bottom),
|
|
(frame_right, frame_bottom),
|
|
(frame_right, frame_top),
|
|
(frame_left, frame_top),
|
|
(frame_left, 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,
|
|
# 실좌표(m) -> 종이(mm) 변환값. 프론트가 B06 산식으로 만든 구조물을 같은 자리에
|
|
# 얹는 데 쓴다 — x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy.
|
|
"cross_placements": [
|
|
{
|
|
"chainage_m": float(source.get("chainage_m", 0.0)),
|
|
"ox": ox,
|
|
"oy": oy,
|
|
"dy": dy,
|
|
"mm_per_m": CROSS_MM,
|
|
"x0": x0,
|
|
"x1": x1,
|
|
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
|
|
# 서버 설계선을 이 안에서만 골라내는 데 쓴다.
|
|
"frame": [frame_left, frame_bottom, frame_right, frame_top],
|
|
}
|
|
],
|
|
"layers": [
|
|
_layer(GROUND_LAYER_ID, "원지반", locked=True),
|
|
_layer(DESIGN_LAYER_ID, "계획선"),
|
|
_layer(STRUCTURE_LAYER_ID, "구조물"),
|
|
_layer(ROCK_LAYER_ID, "암 경계선"),
|
|
_layer(CROSS_TABLE_LAYER_ID, "수량 산출표"),
|
|
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
|
],
|
|
}
|