"""B07 표준 횡단면도 CAD 조립 — 변수 모식도에 치수를 넣고, 측구 확대도·암반선 2단을 함께 낸다. 사용자 지시(2026-09-04) — 「표준 횡단면도 좌상단에 기본값으로 B06 좌측 패널의 변수 위치 안내와 비슷한 그림을 넣고, 변수 이름 자리에 도면처럼 치수를 적을 것. 측구는 작으니 부분 확대도로. 발파·암반이면 암반선과 각도를 넣어 절토측이 2단(토사 각도 + 암반 각도)으로 표현될 것」. 배치는 B06 좌측 패널 모식도(`B06_Section_UI_Standard_Diagram.ts`)와 같다 — 좌가 절토·측구, 우가 성토, 가운데가 계획고. 다른 점은 **실치수**라는 것이다. 모식도는 위치 안내라 비율이 없지만 도면은 축척(본 그림 1/50, 측구 확대도 1/10)대로 그리고 치수선을 붙인다. 값은 B06 「표준 횡단면 설정」(`standard_cross_section`)을 그대로 읽는다 — 여기서 기하를 다시 정하지 않는다. 저장값이 없으면 config 기본값을 쓴다. 좌표 규약: 종이 mm = 실거리 m x MM. 원점은 계획고(노면 중심). """ import math from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, FRAME_LAYER_ID, TABLE_LABEL_COLOR, _layer, _text_entity, polyline_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, frame_entities, scale_fields, ) from config.config_system import ( DRAWING_SCALE_CROSS_STANDARD, DRAWING_SCALE_DITCH_DETAIL, STANDARD_CROSS_SECTION, ) STANDARD_KIND = "cross_standard" STANDARD_LABEL = "표준 횡단면도" # 도면 좌표 = 종이 mm. 본 그림 1/50 -> 1 m = 20 mm, 측구 확대도 1/10 -> 1 m = 100 mm. MM = 1000.0 / DRAWING_SCALE_CROSS_STANDARD DETAIL_MM = 1000.0 / DRAWING_SCALE_DITCH_DETAIL SECTION_LAYER_ID = "b07-std-section" ROCK_LAYER_ID = "b07-std-rock" DIM_LAYER_ID = "b07-std-dim" DETAIL_LAYER_ID = "b07-std-detail" NOTE_LAYER_ID = "b07-std-note" TITLE_LAYER_ID = "b07-std-title" SECTION_COLOR = "#111111" ROCK_COLOR = "#a63d3d" DIM_COLOR = "#2f6fb0" DETAIL_COLOR = "#111111" NOTE_COLOR = "#333333" _LINE_WIDTH = 2 _TITLE_FONT_SIZE = 7.0 _LABEL_FONT_SIZE = 3.0 _DIM_FONT_SIZE = 2.6 _NOTE_FONT_SIZE = 2.8 # 그림에 세울 절·성토 높이(m) — 표준도는 실제 지형이 없으므로 대표 높이로 그린다. _CUT_HEIGHT_M = 3.0 _FILL_HEIGHT_M = 3.0 # 암반 구간: 절토 밑에서 이만큼이 암반이고 그 위가 토사다(2단 절토). _ROCK_HEIGHT_M = 1.5 _DIM_TICK_MM = 1.6 # 치수선 끝 눈금 반길이 _DIM_OFFSET_MM = 8.0 # 치수선을 그림에서 띄우는 거리 _DIM_GAP_MM = 6.0 # 치수선 단 사이 def _ground(kind: str, standard: dict[str, Any] | None) -> dict[str, Any]: """표준 횡단면 설정에서 한 지반유형 값을 꺼낸다. 없으면 config 기본값.""" stored = (standard or {}).get(kind) if isinstance(stored, dict) and stored: merged = dict(STANDARD_CROSS_SECTION.get(kind) or {}) merged.update(stored) return merged return dict(STANDARD_CROSS_SECTION.get(kind) or {}) def _number(value: Any, fallback: float) -> float: return float(value) if isinstance(value, (int, float)) else fallback def _dim_entities( drawing_id: str, tag: str, start: tuple[float, float], end: tuple[float, float], label: str, layer_id: str = DIM_LAYER_ID, ) -> list[dict[str, Any]]: """치수선 한 벌(치수선 + 양끝 눈금 + 치수값). 좌표는 종이 mm.""" entities: list[dict[str, Any]] = [] line = polyline_entity(drawing_id, [start, end], layer_id, DIM_COLOR, suffix=f":dim:{tag}") if line: entities.append(line) dx, dy = end[0] - start[0], end[1] - start[1] length = math.hypot(dx, dy) or 1.0 nx, ny = -dy / length, dx / length for index, point in enumerate((start, end)): tick = polyline_entity( drawing_id, [ (point[0] - nx * _DIM_TICK_MM, point[1] - ny * _DIM_TICK_MM), (point[0] + nx * _DIM_TICK_MM, point[1] + ny * _DIM_TICK_MM), ], layer_id, DIM_COLOR, suffix=f":dim:{tag}:tick:{index}", ) if tick: entities.append(tick) entities.append( _text_entity( f"{drawing_id}:dim:{tag}:text", label, (start[0] + end[0]) / 2.0 + nx * 2.2, (start[1] + end[1]) / 2.0 + ny * 2.2, layer_id, _DIM_FONT_SIZE, DIM_COLOR, ) ) return entities def _section_geometry(values: dict[str, Any]) -> dict[str, Any]: """표준 단면의 실좌표(m) 꼭짓점. 좌가 절토·측구, 우가 성토(B06 모식도와 같은 배치).""" road = _number(values.get("road_width_m"), 3.0) shoulder_left = _number(values.get("shoulder_left_m"), 0.5) shoulder_right = _number(values.get("shoulder_right_m"), 0.5) ditch = values.get("ditch") if isinstance(values.get("ditch"), dict) else {} top_width = _number(ditch.get("top_width_m"), 0.9) bottom_width = _number(ditch.get("bottom_width_m"), 0.3) depth = _number(ditch.get("depth_m"), 0.3) slope = values.get("cross_slope_pct") if isinstance(values.get("cross_slope_pct"), dict) else {} cross_pct = _number(slope.get("max"), _number(slope.get("min"), 3.0)) cut_ratio = _number(values.get("cut_slope_ratio"), 1.0) fill_ratio = _number(values.get("fill_slope_ratio"), 1.2) road_left = -(road / 2.0 + shoulder_left) road_right = road / 2.0 + shoulder_right # 횡단경사는 측구(좌) 쪽으로 내려간다 — 노면 좌끝이 계획고보다 낮다. drop = abs(road_left) * cross_pct / 100.0 surface = [(road_right, 0.0), (road_left, -drop)] ditch_top_left = road_left - top_width ditch_bottom_y = -drop - depth inset = (top_width - bottom_width) / 2.0 ditch_line = [ (road_left, -drop), (road_left - inset, ditch_bottom_y), (ditch_top_left + inset, ditch_bottom_y), (ditch_top_left, -drop), ] cut_top = (ditch_top_left - _CUT_HEIGHT_M * cut_ratio, -drop + _CUT_HEIGHT_M) fill_toe = (road_right + _FILL_HEIGHT_M * fill_ratio, -_FILL_HEIGHT_M) return { "road": road, "shoulder_left": shoulder_left, "shoulder_right": shoulder_right, "top_width": top_width, "bottom_width": bottom_width, "depth": depth, "cross_pct": cross_pct, "cut_ratio": cut_ratio, "fill_ratio": fill_ratio, "road_left": road_left, "road_right": road_right, "drop": drop, "surface": surface, "ditch_line": ditch_line, "ditch_top_left": ditch_top_left, "cut_start": (ditch_top_left, -drop), "cut_top": cut_top, "fill_toe": fill_toe, } def _rock_entities( drawing_id: str, geometry: dict[str, Any], rock_ratio: float, paper: Any ) -> list[dict[str, Any]]: """암반선과 2단 절토(아래=암반각, 위=토사각)를 절토측에 덧그린다.""" entities: list[dict[str, Any]] = [] start_x, start_y = geometry["cut_start"] soil_ratio = geometry["cut_ratio"] # 아래 단: 암반각으로 _ROCK_HEIGHT_M 만큼 올라간다. bench = (start_x - _ROCK_HEIGHT_M * rock_ratio, start_y + _ROCK_HEIGHT_M) # 위 단: 그 위는 토사각. upper = ( bench[0] - (_CUT_HEIGHT_M - _ROCK_HEIGHT_M) * soil_ratio, bench[1] + (_CUT_HEIGHT_M - _ROCK_HEIGHT_M), ) two_stage = polyline_entity( drawing_id, [paper((start_x, start_y)), paper(bench), paper(upper)], ROCK_LAYER_ID, ROCK_COLOR, suffix=":rock:cut", width=_LINE_WIDTH, ) if two_stage: entities.append(two_stage) # 암반선 — 2단이 갈리는 높이의 수평 파선. boundary = polyline_entity( drawing_id, [paper((bench[0] - 1.5, bench[1])), paper((geometry["road_right"], bench[1]))], ROCK_LAYER_ID, ROCK_COLOR, suffix=":rock:boundary", dash=[6, 4], ) if boundary: entities.append(boundary) label_x, label_y = paper((bench[0] - 1.6, bench[1])) entities.append( _text_entity( f"{drawing_id}:rock:boundary:text", "암반선", label_x, label_y + 2.5, ROCK_LAYER_ID, _LABEL_FONT_SIZE, ROCK_COLOR, align="right", ) ) mid_lower = paper(((start_x + bench[0]) / 2.0, (start_y + bench[1]) / 2.0)) mid_upper = paper(((bench[0] + upper[0]) / 2.0, (bench[1] + upper[1]) / 2.0)) entities.append( _text_entity( f"{drawing_id}:rock:lower", f"암반 1:{rock_ratio:g}", mid_lower[0] - 6.0, mid_lower[1], ROCK_LAYER_ID, _DIM_FONT_SIZE, ROCK_COLOR, align="right", ) ) entities.append( _text_entity( f"{drawing_id}:rock:upper", f"토사 1:{soil_ratio:g}", mid_upper[0] - 6.0, mid_upper[1], ROCK_LAYER_ID, _DIM_FONT_SIZE, ROCK_COLOR, align="right", ) ) return entities def _ditch_detail_entities( drawing_id: str, geometry: dict[str, Any], origin: tuple[float, float] ) -> list[dict[str, Any]]: """측구 부분 확대도(1/10) — 작아서 본 그림에서는 치수를 읽을 수 없다.""" entities: list[dict[str, Any]] = [] top_width = geometry["top_width"] bottom_width = geometry["bottom_width"] depth = geometry["depth"] inset = (top_width - bottom_width) / 2.0 ox, oy = origin def paper(point: tuple[float, float]) -> tuple[float, float]: return (ox + point[0] * DETAIL_MM, oy + point[1] * DETAIL_MM) shape = [ (0.0, 0.0), (inset, -depth), (inset + bottom_width, -depth), (top_width, 0.0), ] outline = polyline_entity( drawing_id, [paper(point) for point in shape], DETAIL_LAYER_ID, DETAIL_COLOR, suffix=":detail:ditch", width=_LINE_WIDTH, ) if outline: entities.append(outline) entities.extend( _dim_entities( drawing_id, "detail-top", paper((0.0, 0.0 + 0.06)), paper((top_width, 0.0 + 0.06)), f"{top_width * 1000:.0f}", DETAIL_LAYER_ID, ) ) entities.extend( _dim_entities( drawing_id, "detail-bottom", paper((inset, -depth - 0.06)), paper((inset + bottom_width, -depth - 0.06)), f"{bottom_width * 1000:.0f}", DETAIL_LAYER_ID, ) ) entities.extend( _dim_entities( drawing_id, "detail-depth", paper((top_width + 0.08, 0.0)), paper((top_width + 0.08, -depth)), f"{depth * 1000:.0f}", DETAIL_LAYER_ID, ) ) title = paper((top_width / 2.0, 0.3)) entities.append( _text_entity( f"{drawing_id}:detail:title", f"측구 상세도 (S = 1/{DRAWING_SCALE_DITCH_DETAIL})", title[0], title[1], DETAIL_LAYER_ID, _LABEL_FONT_SIZE, TABLE_LABEL_COLOR, ) ) return entities def build_standard_cross_drawing( drawing_id: str, label: str, standard: dict[str, Any] | None = None ) -> dict[str, Any]: """표준 횡단면도 한 장을 만든다 — 본 그림 + 치수 + 측구 확대도 + 암반 2단 + 주기.""" soil = _ground("soil", standard) rock = _ground("rock", standard) paved = _ground("paved", standard) geometry = _section_geometry(soil) def paper(point: tuple[float, float]) -> tuple[float, float]: return (point[0] * MM, point[1] * MM) entities: list[dict[str, Any]] = [] # 본 그림: 절토면 - 측구 - 노면 - 성토면을 한 줄로 잇는다. outline = [ geometry["cut_top"], *geometry["ditch_line"][::-1], *geometry["surface"][::-1], geometry["fill_toe"], ] body = polyline_entity( drawing_id, [paper(point) for point in outline], SECTION_LAYER_ID, SECTION_COLOR, suffix=":section", width=_LINE_WIDTH, ) if body: entities.append(body) # 중심선(계획고). center = polyline_entity( drawing_id, [paper((0.0, 1.2)), paper((0.0, -1.2))], SECTION_LAYER_ID, SECTION_COLOR, suffix=":center", dash=[10, 3, 2, 3], ) if center: entities.append(center) entities.append( _text_entity( f"{drawing_id}:center:text", "계획고", *paper((0.0, 1.45)), SECTION_LAYER_ID, _LABEL_FONT_SIZE, SECTION_COLOR, ) ) # 치수선 — 노면 아래 두 단(위: 노견·노폭·노견, 아래: 노면 전폭). base_y = min(geometry["fill_toe"][1], -geometry["drop"] - geometry["depth"]) dim_y = base_y * MM - _DIM_OFFSET_MM half = geometry["road"] / 2.0 entities.extend( _dim_entities( drawing_id, "shoulder-left", (geometry["road_left"] * MM, dim_y), (-half * MM, dim_y), f"{geometry['shoulder_left'] * 1000:.0f}", ) ) entities.extend( _dim_entities( drawing_id, "road", (-half * MM, dim_y), (half * MM, dim_y), f"{geometry['road'] * 1000:.0f}", ) ) entities.extend( _dim_entities( drawing_id, "shoulder-right", (half * MM, dim_y), (geometry["road_right"] * MM, dim_y), f"{geometry['shoulder_right'] * 1000:.0f}", ) ) entities.extend( _dim_entities( drawing_id, "ditch-top", (geometry["ditch_top_left"] * MM, dim_y), (geometry["road_left"] * MM, dim_y), f"{geometry['top_width'] * 1000:.0f}", ) ) roadbed_width_m = geometry["road"] + geometry["shoulder_left"] + geometry["shoulder_right"] entities.extend( _dim_entities( drawing_id, "roadbed", (geometry["road_left"] * MM, dim_y - _DIM_GAP_MM), (geometry["road_right"] * MM, dim_y - _DIM_GAP_MM), f"{roadbed_width_m * 1000:.0f}", ) ) # 경사·횡단경사 표기. cut_mid = paper( ( (geometry["cut_start"][0] + geometry["cut_top"][0]) / 2.0, (geometry["cut_start"][1] + geometry["cut_top"][1]) / 2.0, ) ) fill_mid = paper( ( (geometry["road_right"] + geometry["fill_toe"][0]) / 2.0, (0.0 + geometry["fill_toe"][1]) / 2.0, ) ) entities.append( _text_entity( f"{drawing_id}:cut:text", f"절토 1:{geometry['cut_ratio']:g}", cut_mid[0] - 4.0, cut_mid[1] + 3.0, SECTION_LAYER_ID, _DIM_FONT_SIZE, SECTION_COLOR, align="right", ) ) entities.append( _text_entity( f"{drawing_id}:fill:text", f"성토 1:{geometry['fill_ratio']:g}", fill_mid[0] + 4.0, fill_mid[1] + 3.0, SECTION_LAYER_ID, _DIM_FONT_SIZE, SECTION_COLOR, align="left", ) ) entities.append( _text_entity( f"{drawing_id}:cross-slope:text", f"횡단경사 {geometry['cross_pct']:g}%", *paper((geometry["road_left"] / 2.0, 0.6)), SECTION_LAYER_ID, _DIM_FONT_SIZE, SECTION_COLOR, ) ) # 암반 구간 2단 절토. entities.extend( _rock_entities(drawing_id, geometry, _number(rock.get("cut_slope_ratio"), 0.4), paper) ) body_bbox = entities_bbox(entities) right = body_bbox[2] if body_bbox else 0.0 top = body_bbox[3] if body_bbox else 0.0 # 측구 부분 확대도 — 본 그림 오른쪽 위. entities.extend(_ditch_detail_entities(drawing_id, geometry, (right + 28.0, top - 30.0))) # 주기: 구간별로 달라지는 값만 적는다(기본은 토사). notes = [ "※ 본 그림은 토사 구간 기준임.", f"※ 암 구간 — 절토 1:{_number(rock.get('cut_slope_ratio'), 0.4):g}, " f"L형 측구 {_number((rock.get('ditch_l_type') or {}).get('width_m'), 0.5) * 1000:.0f}" f"×{_number((rock.get('ditch_l_type') or {}).get('depth_m'), 0.1) * 1000:.0f}mm " "(횡단면도에서 일반·L형 중 선택).", f"※ 포장 구간 — 절·성토 경사는 토사와 같고 횡단경사만 " f"{_number((paved.get('cross_slope_pct') or {}).get('min'), 1.5):g}~" f"{_number((paved.get('cross_slope_pct') or {}).get('max'), 2.0):g}% 임.", "※ 치수 단위 mm.", ] note_bbox = entities_bbox(entities) note_x = note_bbox[0] if note_bbox else 0.0 note_y = (note_bbox[1] if note_bbox else 0.0) - 12.0 for index, note in enumerate(notes): entities.append( _text_entity( f"{drawing_id}:note:{index}", note, note_x, note_y - index * 5.0, NOTE_LAYER_ID, _NOTE_FONT_SIZE, NOTE_COLOR, align="left", ) ) bbox = entities_bbox(entities) if bbox: min_bx, _min_by, max_bx, max_by = bbox entities.append( _text_entity( f"{drawing_id}:title", label, (min_bx + max_bx) / 2.0, max_by + 12.0, TITLE_LAYER_ID, _TITLE_FONT_SIZE, TABLE_LABEL_COLOR, ) ) entities.append( _text_entity( f"{drawing_id}:scale", f"S = 1/{DRAWING_SCALE_CROSS_STANDARD}", max_bx, max_by + 5.0, TITLE_LAYER_ID, _DIM_FONT_SIZE, TABLE_LABEL_COLOR, align="right", ) ) entities.extend( frame_entities( drawing_id, entities_bbox(entities) or bbox, fit=False, fields={ "도면명": label, **scale_fields(("", DRAWING_SCALE_CROSS_STANDARD)), }, ) ) return { "format": DRAWING_FORMAT, "entities": entities, "layers": [ _layer(SECTION_LAYER_ID, "표준 단면"), _layer(ROCK_LAYER_ID, "암반선·2단 절토"), _layer(DIM_LAYER_ID, "치수"), _layer(DETAIL_LAYER_ID, "측구 상세도"), _layer(NOTE_LAYER_ID, "주기"), _layer(TITLE_LAYER_ID, "표제"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], }