Files
Aislo/B08_DesignDetail/B08_DesignDetail_Engine_Cad_Long.py
T
eomsangdonandClaude Fable 5 2a248ff742 refactor(B07,B08): 도면 코드 B08_DesignDetail 이동 + 수량 슬롯 B07_Quantity 확보
- B07_wf4_DesignDetail(8파일+openwebcad) -> B08_DesignDetail로 git mv (이력 보존)
- B08_wf5_Quantity -> B07_Quantity (구 수량 백업 zip 보관 폴더)
- 파생 문자열 일괄 전환: /b07-cad -> /b08-cad 정적 서빙, CAD 레이어 b07-* -> b08-*,
  postMessage aislo:b07:* -> aislo:b08:*, 패키지명 aislo-b08-cad, CSS .b08-*,
  라우트 키/슬러그(B08_DESIGN_DETAIL/b08-design-detail, B07_QUANTITY/b07-quantity)
- 상세설계 워크플로우 stage 4 -> 5 (확정/무효화 전이 3곳), 확정 완료 시 B09로 이동
- WORKFLOW_STEP_ROUTES 순서 재배열: index4=수량(B07), index5=상세설계(B08)
- [임시] B08 이동 테스트 버튼 제거, openwebcad dist 재빌드
- 주석 의미 정렬: 수량 인계 주석 B08->B07, 도면 참조 B07->B08

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:10:10 +09:00

661 lines
23 KiB
Python

"""B08 종단도 CAD 조립 — 30측점 N분할 + 그래프 축·격자 + 하단 측점 테이블.
납품 양식:
- 그래프 영역: 좌측 Y축(표고 눈금·라벨), 기준선(X축, 최저 표고에서 5m 이상
여유), 측점별 회색 세로선.
- 테이블 영역: 측점 세로선 없이 가로 구분선 위 눈금(틱)으로 측점 표현,
값 텍스트는 세로쓰기. 행: 곡선/측점/거리/추가거리/지반고/계획고/절토고/
성토고/구배.
- 곡선행: 종단곡선 BVC·EVC 세로틱 + 수평선 + R/L 표기(브래킷형).
- 구배행: 구간 사선(상향/하향) + 가로 "구배% L=길이" + 구배 변화점에
원(내부 세로쓰기 계획고), 노선 시·종점은 반원.
"""
import math
from typing import Any
from uuid import uuid5
from B08_DesignDetail.B08_DesignDetail_Engine_Cad import (
_ENTITY_NS,
DESIGN_COLOR,
DESIGN_LAYER_ID,
DRAWING_FORMAT,
FRAME_LAYER_ID,
GROUND_COLOR,
GROUND_LAYER_ID,
LONG_SPLIT_STATION_COUNT,
LONG_TABLE_LAYER_ID,
TABLE_LABEL_COLOR,
TABLE_LINE_COLOR,
TABLE_VALUE_COLOR,
_design_profile_points,
_format,
_interpolate,
_layer,
_line_entity,
_profile_alignment,
_stations,
_text_entity,
infer_station_interval,
points_from_samples,
polyline_entity,
station_no_label,
)
from B08_DesignDetail.B08_DesignDetail_Engine_Template import (
entities_bbox,
frame_entities,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
# 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외).
LONG_GRID_LAYER_ID = "b08-long-grid"
GRID_COLOR = "#5b6572"
_VERTICAL = (0.0, 1.0) # 세로쓰기(아래→위)
# 테이블 행: (키, 헤더 라벨, 행 높이 m). 값 세로쓰기 행은 높게 잡는다.
_ROW_TALL = 9.0
_LONG_TABLE_ROWS: tuple[tuple[str, str, float], ...] = (
("curve", "곡선", 5.0),
("station", "측점", 7.0),
("distance", "거리", 4.5),
("cum_distance", "추가거리", _ROW_TALL),
("ground", "지반고", _ROW_TALL),
("design", "계획고", _ROW_TALL),
("cut", "절토고", _ROW_TALL),
("fill", "성토고", _ROW_TALL),
("grade", "구배", 10.0),
)
_FONT_SIZE = 2.2
_TICK_LEN = 0.8 # 측점 눈금 길이
def _circle_entity(
seed: str, center: tuple[float, float], radius: float, layer_id: str, color: str
) -> dict[str, Any]:
return {
"id": str(uuid5(_ENTITY_NS, seed)),
"type": "Circle",
"lineColor": color,
"lineWidth": 1,
"layerId": layer_id,
"shapeData": {"center": {"x": center[0], "y": center[1]}, "radius": radius},
}
def _arc_entity(
seed: str,
center: tuple[float, float],
radius: float,
start_angle: float,
end_angle: float,
layer_id: str,
color: str,
) -> dict[str, Any]:
return {
"id": str(uuid5(_ENTITY_NS, seed)),
"type": "Arc",
"lineColor": color,
"lineWidth": 1,
"layerId": layer_id,
"shapeData": {
"center": {"x": center[0], "y": center[1]},
"radius": radius,
"startAngle": start_angle,
"endAngle": end_angle,
"counterClockwise": True,
},
}
def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
"""측점 30개 단위 분할 목록. 각 항목: {id, label, start_m, end_m}.
30개 이하면 단일 도면(id="longitudinal", 기존 manifest 호환). 초과 시
경계 측점 1개를 중복시켜 도면 간 선이 이어지게 한다(납품 도면 관례).
"""
stations = sorted(_stations(longitudinal), key=lambda s: float(s["chainage_m"]))
if not stations:
return [{"id": "longitudinal", "label": "종단도", "start_m": None, "end_m": None}]
if len(stations) <= LONG_SPLIT_STATION_COUNT:
return [
{
"id": "longitudinal",
"label": "종단도",
"start_m": float(stations[0]["chainage_m"]),
"end_m": float(stations[-1]["chainage_m"]),
}
]
chunks: list[dict[str, Any]] = []
stride = LONG_SPLIT_STATION_COUNT - 1 # 경계 1측점 중복
index = 0
number = 1
while index < len(stations) - 1:
chunk_stations = stations[index : index + LONG_SPLIT_STATION_COUNT]
chunks.append(
{
"id": f"longitudinal_{number}",
"label": f"종단도({number})",
"start_m": float(chunk_stations[0]["chainage_m"]),
"end_m": float(chunk_stations[-1]["chainage_m"]),
}
)
index += stride
number += 1
return chunks
def _long_table_values(
stations: list[dict[str, Any]],
all_stations: list[dict[str, Any]],
ground_points: list[tuple[float, float]],
longitudinal: dict[str, Any],
interval_m: float,
) -> list[dict[str, str]]:
"""청크 측점별 테이블 셀 문자열. 거리는 노선 전체 기준 직전 측점과의 차."""
chainage_all = sorted(float(s["chainage_m"]) for s in all_stations)
rows: list[dict[str, str]] = []
for station in stations:
chainage = float(station["chainage_m"])
ground = station.get("center_z")
ground = (
float(ground)
if isinstance(ground, (int, float))
else _interpolate(ground_points, chainage)
)
design = design_elevation_from_longitudinal(longitudinal, chainage)
cut = max(ground - design, 0.0) if ground is not None and design is not None else None
fill = max(design - ground, 0.0) if ground is not None and design is not None else None
position = chainage_all.index(chainage) if chainage in chainage_all else -1
distance = chainage - chainage_all[position - 1] if position > 0 else None
rows.append(
{
"station": station_no_label(chainage, interval_m),
"distance": _format(distance, 1),
"cum_distance": _format(chainage, 1),
"ground": _format(ground),
"design": _format(design),
"cut": _format(cut) if cut else "",
"fill": _format(fill) if fill else "",
}
)
return rows
def _graph_grid_entities(
drawing_id: str,
chainages: list[float],
x0: float,
x1: float,
datum_y: float,
top_y: float,
) -> list[dict[str, Any]]:
"""그래프 영역: 기준선(X축)·Y축(표고 눈금/라벨)·측점 회색 세로선."""
entities: list[dict[str, Any]] = [
# 기준선(X축)
_line_entity(
f"{drawing_id}:lgx", (x0, datum_y), (x1, datum_y), LONG_GRID_LAYER_ID, GRID_COLOR
),
# Y축
_line_entity(
f"{drawing_id}:lgy", (x0, datum_y), (x0, top_y), LONG_GRID_LAYER_ID, GRID_COLOR
),
]
# Y축 표고 눈금·라벨 (5m 간격, 가로쓰기)
level = datum_y
tick_index = 0
while level <= top_y + 1e-6:
entities.append(
_line_entity(
f"{drawing_id}:lgyt{tick_index}",
(x0 - 1.0, level),
(x0, level),
LONG_GRID_LAYER_ID,
GRID_COLOR,
)
)
entities.append(
_text_entity(
f"{drawing_id}:lgyl{tick_index}",
_format(level),
x0 - 1.6,
level,
LONG_GRID_LAYER_ID,
_FONT_SIZE,
TABLE_LABEL_COLOR,
align="right",
)
)
level += 5.0
tick_index += 1
# 측점별 회색 세로선 (기준선 → 그래프 상단)
for index, x in enumerate(chainages):
entities.append(
_line_entity(
f"{drawing_id}:lgv{index}",
(x, datum_y),
(x, top_y),
LONG_GRID_LAYER_ID,
GRID_COLOR,
)
)
return entities
def _curve_row_entities(
drawing_id: str,
longitudinal: dict[str, Any],
x0: float,
x1: float,
y_top: float,
row_height: float,
) -> list[dict[str, Any]]:
"""곡선행: 종단곡선(BVC~EVC) 세로틱+수평선+R/L 표기 (브래킷형)."""
entities: list[dict[str, Any]] = []
curves = _profile_alignment(longitudinal).get("curves")
if not isinstance(curves, list):
return entities
y_line = y_top - row_height * 0.62
y_text = y_top - row_height * 0.30
tick = row_height * 0.24
for index, curve in enumerate(curves):
if not isinstance(curve, dict) or curve.get("omitted"):
continue
bvc = curve.get("bvc_m")
evc = curve.get("evc_m")
if not isinstance(bvc, (int, float)) or not isinstance(evc, (int, float)):
continue
start = max(float(bvc), x0)
end = min(float(evc), x1)
if end <= start:
continue
seed = f"{drawing_id}:lcurve:{index}"
entities.append(
_line_entity(
f"{seed}:l", (start, y_line), (end, y_line), LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR
)
)
entities.append(
_line_entity(
f"{seed}:t0",
(start, y_line - tick),
(start, y_line + tick),
LONG_TABLE_LAYER_ID,
TABLE_LINE_COLOR,
)
)
entities.append(
_line_entity(
f"{seed}:t1",
(end, y_line - tick),
(end, y_line + tick),
LONG_TABLE_LAYER_ID,
TABLE_LINE_COLOR,
)
)
r_m = curve.get("r_m")
l_m = curve.get("l_m")
parts = []
if isinstance(r_m, (int, float)):
parts.append(f"R={r_m:g}")
if isinstance(l_m, (int, float)):
parts.append(f"L={l_m:g}")
if parts:
entities.append(
_text_entity(
f"{seed}:txt",
" ".join(parts),
(start + end) / 2.0,
y_text,
LONG_TABLE_LAYER_ID,
_FONT_SIZE * 0.9,
TABLE_VALUE_COLOR,
)
)
return entities
def _grade_break_points(
segments: list[Any], x0: float, x1: float, route_start: float, route_end: float
) -> list[tuple[float, bool]]:
"""구배 변화점 목록 [(chainage, 노선 시·종점 여부)] — 청크 범위 내만."""
boundaries: set[float] = set()
for segment in segments:
if not isinstance(segment, dict):
continue
for key in ("from_m", "to_m"):
value = segment.get(key)
if isinstance(value, (int, float)) and x0 - 1e-6 <= float(value) <= x1 + 1e-6:
boundaries.add(round(float(value), 4))
return [
(chainage, abs(chainage - route_start) < 1e-3 or abs(chainage - route_end) < 1e-3)
for chainage in sorted(boundaries)
]
def _grade_row_entities(
drawing_id: str,
longitudinal: dict[str, Any],
stations: list[dict[str, Any]],
x0: float,
x1: float,
y_top: float,
row_height: float,
route_start: float,
route_end: float,
) -> list[dict[str, Any]]:
"""구배행: 구간 사선 + 가로 구배 표기 + 변화점 원(내부 세로쓰기 계획고).
노선 시·종점은 반원(상반원)으로 표기한다. profile_alignment.segments가
없으면(구 폴백 계획선) 측점 간 계획고 차이 텍스트만 표기한다.
"""
entities: list[dict[str, Any]] = []
y_mid = y_top - row_height * 0.5
y_high = y_top - row_height * 0.18
y_low = y_top - row_height * 0.82
radius = row_height * 0.40
segments = _profile_alignment(longitudinal).get("segments")
if not (isinstance(segments, list) and segments):
# 폴백: 측점 간 계획고 차이 기반 구배 텍스트
for index in range(len(stations) - 1):
c0 = float(stations[index]["chainage_m"])
c1 = float(stations[index + 1]["chainage_m"])
d0 = design_elevation_from_longitudinal(longitudinal, c0)
d1 = design_elevation_from_longitudinal(longitudinal, c1)
span = c1 - c0
if d0 is None or d1 is None or span <= 0:
continue
entities.append(
_text_entity(
f"{drawing_id}:lgrade:fb{index}",
f"{(d1 - d0) / span * 100:.2f}%",
(c0 + c1) / 2.0,
y_mid,
LONG_TABLE_LAYER_ID,
_FONT_SIZE * 0.9,
TABLE_VALUE_COLOR,
)
)
return entities
# 구간 사선 + 가로 구배/거리 표기 (원 반경만큼 안쪽으로 클리핑)
for index, segment in enumerate(segments):
if not isinstance(segment, dict):
continue
from_m = segment.get("from_m")
to_m = segment.get("to_m")
grade = segment.get("grade_percent")
if not isinstance(from_m, (int, float)) or not isinstance(to_m, (int, float)):
continue
start = max(float(from_m), x0) + radius
end = min(float(to_m), x1) - radius
if end <= start:
continue
seed = f"{drawing_id}:lgrade:{index}"
rising = isinstance(grade, (int, float)) and grade >= 0
entities.append(
_line_entity(
f"{seed}:d",
(start, y_low if rising else y_high),
(end, y_high if rising else y_low),
LONG_TABLE_LAYER_ID,
TABLE_LINE_COLOR,
)
)
if isinstance(grade, (int, float)):
length = segment.get("length_m")
length_text = f" L={length:g}" if isinstance(length, (int, float)) else ""
entities.append(
_text_entity(
f"{seed}:txt",
f"{grade:.2f}%{length_text}",
(start + end) / 2.0,
y_high + 0.2 if rising else y_low - 0.2,
LONG_TABLE_LAYER_ID,
_FONT_SIZE * 0.85,
TABLE_VALUE_COLOR,
)
)
# 구배 변화점: 원(정원) 또는 노선 시·종점 반원 + 내부 세로쓰기 계획고
for index, (chainage, is_route_end) in enumerate(
_grade_break_points(segments, x0, x1, route_start, route_end)
):
seed = f"{drawing_id}:lgradebp:{index}"
if is_route_end:
entities.append(
_arc_entity(
seed,
(chainage, y_mid),
radius,
0.0,
math.pi,
LONG_TABLE_LAYER_ID,
TABLE_LINE_COLOR,
)
)
else:
entities.append(
_circle_entity(
seed, (chainage, y_mid), radius, LONG_TABLE_LAYER_ID, TABLE_LINE_COLOR
)
)
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
if elevation is not None:
entities.append(
_text_entity(
f"{seed}:txt",
_format(elevation),
chainage,
y_mid,
LONG_TABLE_LAYER_ID,
_FONT_SIZE * 0.8,
TABLE_VALUE_COLOR,
direction=_VERTICAL,
)
)
return entities
def _long_table_entities(
drawing_id: str,
stations: list[dict[str, Any]],
values: list[dict[str, str]],
longitudinal: dict[str, Any],
table_top: float,
interval_m: float,
route_start: float,
route_end: float,
) -> list[dict[str, Any]]:
"""종단 테이블: 가로 구분선 + 측점 눈금(세로선 없음) + 세로쓰기 값."""
chainages = [float(s["chainage_m"]) for s in stations]
header_width = max(interval_m, 12.0)
left = chainages[0] - header_width
right = chainages[-1]
entities: list[dict[str, Any]] = []
# 행 y 경계 (가변 행 높이)
boundaries = [table_top]
for _key, _header, height in _LONG_TABLE_ROWS:
boundaries.append(boundaries[-1] - height)
bottom = boundaries[-1]
# 가로 구분선 + 측점 눈금(구분선 아래 짧은 틱)
for row_index, y in enumerate(boundaries):
entities.append(
_line_entity(
f"{drawing_id}:ltgrid:h{row_index}",
(left, y),
(right, y),
LONG_TABLE_LAYER_ID,
TABLE_LINE_COLOR,
)
)
if row_index < len(boundaries) - 1:
for column_index, x in enumerate(chainages):
entities.append(
_line_entity(
f"{drawing_id}:lttick:{row_index}:{column_index}",
(x, y),
(x, y - _TICK_LEN),
LONG_TABLE_LAYER_ID,
TABLE_LINE_COLOR,
)
)
# 외곽·헤더 구분 세로선 (측점 세로선은 없음)
for seed, x in (("v-left", left), ("v-header", chainages[0]), ("v-right", right)):
entities.append(
_line_entity(
f"{drawing_id}:ltgrid:{seed}",
(x, table_top),
(x, bottom),
LONG_TABLE_LAYER_ID,
TABLE_LINE_COLOR,
)
)
for row_index, (key, header, height) in enumerate(_LONG_TABLE_ROWS):
y_top = boundaries[row_index]
y_mid = y_top - height * 0.5
entities.append(
_text_entity(
f"{drawing_id}:ltable:header:{key}",
header,
left + header_width / 2.0,
y_mid,
LONG_TABLE_LAYER_ID,
_FONT_SIZE,
TABLE_LABEL_COLOR,
)
)
if key == "curve":
entities.extend(
_curve_row_entities(drawing_id, longitudinal, chainages[0], right, y_top, height)
)
continue
if key == "grade":
entities.extend(
_grade_row_entities(
drawing_id,
longitudinal,
stations,
chainages[0],
right,
y_top,
height,
route_start,
route_end,
)
)
continue
for value_index, row_values in enumerate(values):
label = row_values.get(key, "")
if not label:
continue
if key == "distance":
# 거리는 직전 측점과의 구간 중앙, 가로쓰기 (청크 첫 측점 제외)
if value_index == 0:
continue
entities.append(
_text_entity(
f"{drawing_id}:ltable:{key}:{value_index}",
label,
(chainages[value_index - 1] + chainages[value_index]) / 2.0,
y_mid,
LONG_TABLE_LAYER_ID,
_FONT_SIZE * 0.9,
TABLE_VALUE_COLOR,
)
)
continue
# 측점·추가거리·지반고·계획고·절토고·성토고: 측점 위치 세로쓰기
entities.append(
_text_entity(
f"{drawing_id}:ltable:{key}:{value_index}",
label,
chainages[value_index],
y_mid,
LONG_TABLE_LAYER_ID,
_FONT_SIZE,
TABLE_VALUE_COLOR,
direction=_VERTICAL,
)
)
return entities
def build_longitudinal_drawing(
longitudinal: dict[str, Any], drawing_id: str, chunk: dict[str, Any]
) -> dict[str, Any]:
"""종단도 청크 하나를 지반선+계획선+그래프 축·격자+측점 테이블로 만든다."""
start_m = chunk.get("start_m")
end_m = chunk.get("end_m")
def in_range(x: float) -> bool:
if start_m is None or end_m is None:
return True
return start_m - 1e-6 <= x <= end_m + 1e-6
ground_all = points_from_samples(longitudinal.get("samples", []), "chainage_m")
ground_points = [point for point in ground_all if in_range(point[0])]
design_points = [point for point in _design_profile_points(longitudinal) if in_range(point[0])]
all_stations = _stations(longitudinal)
stations = sorted(
(s for s in all_stations if in_range(float(s["chainage_m"]))),
key=lambda s: float(s["chainage_m"]),
)
interval_m = infer_station_interval(all_stations)
entities: list[dict[str, Any]] = []
ground = polyline_entity(drawing_id, ground_points, GROUND_LAYER_ID, GROUND_COLOR)
if ground:
entities.append(ground)
design = polyline_entity(drawing_id, design_points, DESIGN_LAYER_ID, DESIGN_COLOR)
if design:
entities.append(design)
if stations:
chainages = [float(s["chainage_m"]) for s in stations]
elevations = [y for _x, y in [*ground_points, *design_points]]
min_e = min(elevations) if elevations else 0.0
max_e = max(elevations) if elevations else 10.0
# 기준선(datum): 최저 표고에서 5m 이상 여유를 두고 5m 단위로 내림.
datum_y = math.floor((min_e - 5.0) / 5.0) * 5.0
top_y = max_e + 3.0
entities.extend(
_graph_grid_entities(drawing_id, chainages, chainages[0], chainages[-1], datum_y, top_y)
)
values = _long_table_values(stations, all_stations, ground_all, longitudinal, interval_m)
all_chainages = sorted(float(s["chainage_m"]) for s in all_stations)
table_top = datum_y - 3.0 # 그래프-테이블 영역 분리 간격
entities.extend(
_long_table_entities(
drawing_id,
stations,
values,
longitudinal,
table_top,
interval_m,
all_chainages[0],
all_chainages[-1],
)
)
# A1 도각 프레임: 콘텐츠 bbox를 감싸도록 배치 (잠금 레이어, 좌표는 콘텐츠 불변).
bbox = entities_bbox(entities)
if bbox:
entities.extend(frame_entities(drawing_id, bbox))
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [
_layer(GROUND_LAYER_ID, "Existing Ground", locked=True),
_layer(DESIGN_LAYER_ID, "Design Plan"),
_layer(LONG_GRID_LAYER_ID, "Graph Grid", locked=True),
_layer(LONG_TABLE_LAYER_ID, "Station Table"),
_layer(FRAME_LAYER_ID, "Frame", locked=True),
],
}