"""B07 전개도 그림 — 한 장 = 박스 줄바꿈 배치 · 형태 무늬 · 양 끝 측점 · 우하단 표 · 도각. 그림 241(콘크리트포장 · 전석메쌓기 전개도) 꼴: 박스 = 거리(종점 − 시점) × H(포장 B) · 좌우 끝 = 시점 · 종점 측점(붉은 지시선 + 기울인 글) · 끝 옆 「H=2.0」(포장 「B=3.5」) · 박스 아래 「L=6.0m」 · 우하단 표(측점 · 거리 · 높이/폭 · 면적 · 계) 무늬 = 기본 도형(PolyLine · Circle · Line)으로 직접 — CAD Hatch 는 DXF 내보내면 채움이 사라짐. 흔들림 = 도면 id · 박스 열쇠 씨앗(같은 자료 = 같은 그림 · 엔티티 id 도 uuid5 결정). 좌표 = 종이 mm · 가로 축척 자동(한 장에 드는 가장 큰 그림) · 세로는 가장 높은 박스가 12~40 mm. """ from __future__ import annotations import math import random from typing import Any from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, FRAME_LAYER_ID, TABLE_LABEL_COLOR, TABLE_VALUE_COLOR, _circle_entity, _layer, _line_entity, _text_entity, polyline_entity, table_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import station_text, table_rows from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( drawing_area, frame_entities, scale_fields, ) BOX_LAYER_ID = "b07-dev-box" FILL_LAYER_ID = "b07-dev-fill" LABEL_LAYER_ID = "b07-dev-label" TABLE_LAYER_ID = "b07-dev-table" BOX_COLOR = "#f5f7fa" FILL_COLOR = "#aab4c0" LEADER_COLOR = "#ff4d4d" TABLE_COLOR = "#e03131" #: 가로 축척 후보(분모) — 작은 것부터 대어 보고 처음 드는 것 SCALES = (50, 100, 200, 300, 500, 600, 1000, 1200, 2000, 2500, 3000, 5000) _MARGIN = 10.0 _TITLE_H = 26.0 # 제목 자리 _LEAD = 18.0 # 박스 위 지시선 · 측점 글 자리 _BELOW = 9.0 # 박스 아래 L= 자리 _GAP = 16.0 # 박스 사이(오른 끝 기울인 글 자리) _MIN_H, _MAX_H = 12.0, 40.0 # 가장 높은 박스 세로 mm _EMPTY_H = 0.6 # 높이 빈칸 박스 = 가장 높은 박스의 몫 _FONT, _TITLE_FONT = 2.5, 7.0 _TABLE_FONT = 3.2 # 표는 그림 241 처럼 크게(작도 영역 폭의 약 1/4) _ROW_H = 9.0 _COLUMNS = (80.0, 32.0, 32.0, 40.0) _SLANT = (0.5, math.sqrt(3) / 2) # 60° def _fmt(value: float | None) -> str: return "" if value is None else f"{value:,.1f}" def _size(value: float | None) -> str: """높이 · 폭 — 소수 둘째 자리까지(3.55 · 3.5 · 2.0) · 면적이 쓴 값 그대로 보이게.""" if value is None: return "" text = f"{value:.2f}".rstrip("0") return f"{text}0" if text.endswith(".") else text # ── 무늬 ──────────────────────────────────────────────── def _stone(rng: random.Random, cx: float, cy: float, r: float) -> list[tuple[float, float]]: """흔든 7각 돌 하나(닫힌 점열).""" turn = rng.uniform(0, math.tau) points = [ ( cx + r * rng.uniform(0.7, 1.0) * math.cos(turn + i * math.tau / 7), cy + r * rng.uniform(0.7, 1.0) * math.sin(turn + i * math.tau / 7), ) for i in range(7) ] return [*points, points[0]] def _stones(seed, rng, box, rows, cap=500): """박스 안 엇갈린 격자에 흔든 돌 — 높이에 `rows` 줄(크기 = 높이 몫) · 박스를 안 넘음.""" x0, y0, x1, y1 = box r = min((y1 - y0) / (rows * 2.3), (y1 - y0) / 2 - 0.3, (x1 - x0) / 2 - 0.3) if r < 0.3: return [] step = 2.3 * r out = [] y, row = y0 + 0.3 + r, 0 while y <= y1 - 0.3 - r + 1e-9 and len(out) < cap: x = x0 + 0.3 + r + (step / 2 if row % 2 else 0.0) while x <= x1 - 0.3 - r + 1e-9 and len(out) < cap: room = (x - x0 - 0.3 - r, x1 - 0.3 - r - x) # 왼 · 오 여유 — 돌이 박스를 안 넘게 jx = max(-room[0], min(rng.uniform(-0.15, 0.15) * step, room[1])) line = polyline_entity( seed, _stone(rng, x + jx, y, r), FILL_LAYER_ID, FILL_COLOR, f":s{len(out)}" ) if line: out.append(line) x += step y += step * 0.87 row += 1 return out def _dots(seed, rng, box, per_mm2, radius, cap): x0, y0, x1, y1 = box count = min(cap, int((x1 - x0) * (y1 - y0) * per_mm2)) return [ _circle_entity( f"{seed}:d{i}", (rng.uniform(x0 + radius, x1 - radius), rng.uniform(y0 + radius, y1 - radius)), radius, FILL_LAYER_ID, FILL_COLOR, ) for i in range(count) ] def _grid(seed, box, step): x0, y0, x1, y1 = box out = [] x = x0 + step while x < x1 - 0.1: out.append( _line_entity(f"{seed}:gx{len(out)}", (x, y0), (x, y1), FILL_LAYER_ID, FILL_COLOR) ) x += step y = y0 + step while y < y1 - 0.1: out.append( _line_entity(f"{seed}:gy{len(out)}", (x0, y), (x1, y), FILL_LAYER_ID, FILL_COLOR) ) y += step return out def _logs(seed, box): """통나무 · 목재틀 = 가운데 원 한 줄.""" x0, y0, x1, y1 = box r = min((y1 - y0) / 2 - 0.3, 4.0) if r < 0.3: return [] count = max(int((x1 - x0 - 0.4) // (2 * r + 0.4)), 0) cy = (y0 + y1) / 2 return [ _circle_entity( f"{seed}:o{i}", (x0 + 0.4 + r + i * (2 * r + 0.4), cy), r, FILL_LAYER_ID, FILL_COLOR ) for i in range(count) ] def _stakes(seed, box, step=2.5): """바자 = 세운 말뚝 줄.""" x0, y0, x1, y1 = box count = max(int((x1 - x0) // step), 0) return [ _line_entity( f"{seed}:k{i}", (x0 + (i + 0.5) * step, y0), (x0 + (i + 0.5) * step, y1), FILL_LAYER_ID, FILL_COLOR, ) for i in range(count) ] def fill_entities(seed: str, form: str, group: str, box: tuple[float, float, float, float]): """형태 → 무늬(찰 작은 돌 · 메 큰 돌 · 콘크리트 · 쇄석 점 · 돌망태 격자+돌 · 통나무 원).""" rng = random.Random(seed) if group == "gravel": return _dots(seed, rng, box, 0.025, 0.9, 900) if group == "concrete" or form == "콘크리트": return _dots(seed, rng, box, 0.045, 0.6, 1200) if form == "돌쌓기(찰)": return _stones(seed, rng, box, 5) if form == "돌쌓기(메)": return _stones(seed, rng, box, 2.5) if form == "돌망태": cells = max(round((box[3] - box[1]) / 12.0), 1) # 망태 한 단 ≈ 12 mm return _grid(seed, box, (box[3] - box[1]) / cells) + _stones(seed, rng, box, 3 * cells) if form == "통나무·목재틀": return _logs(seed, box) if form == "바자": return _stakes(seed, box) return [] # ── 배치 ──────────────────────────────────────────────── def _vertical(rows: list[dict[str, Any]], mm_h: float) -> float: """세로 mm/m — 가로와 같게 두되 가장 높은 박스가 12~40 mm 안에 들게.""" tallest = max((r["height_m"] for r in rows if r["height_m"]), default=None) if not tallest: return _MIN_H return min(max(mm_h, _MIN_H / tallest), _MAX_H / tallest) def layout(rows, area, table_box, mm_h, strict=True): """박스 줄바꿈 배치 → [(row, x0, y0, x1, y1)] · 영역을 넘치면 None(strict).""" ax0, ay0, ax1, ay1 = area v = _vertical(rows, mm_h) tallest = max((r["height_m"] or 0.0 for r in rows), default=0.0) empty_h = (tallest * v if tallest else _MIN_H) * _EMPTY_H left, top = ax0 + _MARGIN, ay1 - _TITLE_H placed, line, x, line_h = [], [], left, 0.0 def right_limit(height: float) -> float: bottom = top - _LEAD - height - _BELOW return table_box[0] - _GAP if bottom < table_box[3] + 4.0 else ax1 - _MARGIN for row in rows: w = max((row["end_m"] - row["start_m"]) * mm_h, 2.0) h = row["height_m"] * v if row["height_m"] else empty_h if line and x + w > right_limit(max(line_h, h)): top -= _LEAD + line_h + _BELOW placed += line line, x, line_h = [], left, 0.0 if strict and ( x + w > right_limit(max(line_h, h)) or top - _LEAD - h - _BELOW < ay0 + _MARGIN ): return None line.append((row, x, top - _LEAD - h, x + w, top - _LEAD)) x += w + _GAP line_h = max(line_h, h) return placed + line def _choose(rows, area, table_box): """처음 드는 축척 — 끝까지 안 들면 가장 작은 축척으로 그대로(도각이 넘침 경고).""" for denominator in SCALES: placed = layout(rows, area, table_box, 1000.0 / denominator) if placed is not None: return denominator, placed denominator = SCALES[-1] return denominator, layout(rows, area, table_box, 1000.0 / denominator, strict=False) # ── 글 · 표 ───────────────────────────────────────────── def _box_entities(drawing_id, index, row, box, label, station): x0, y0, x1, y1 = box seed = f"{drawing_id}:b{index}" out = [ polyline_entity( drawing_id, [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)], BOX_LAYER_ID, BOX_COLOR, f":b{index}", ) ] out += fill_entities(f"{seed}:{row['key']}", row["form"], row["group"], (x0, y0, x1, y1)) height = "미입력" if row["height_m"] is None else _size(row["height_m"]) for end, x, chainage in (("s", x0, row["start_m"]), ("e", x1, row["end_m"])): out.append( _line_entity( f"{seed}:{end}:lead", (x, y1), (x, y1 + _LEAD - 2.0), LABEL_LAYER_ID, LEADER_COLOR ) ) out.append( _text_entity( f"{seed}:{end}:no", station(chainage), x + 0.8, y1 + 1.2, LABEL_LAYER_ID, _FONT, TABLE_LABEL_COLOR, "left", _SLANT, ) ) out.append( _text_entity( f"{seed}:{end}:h", f"{label}={height}", x + 3.6, y1 + 1.2, LABEL_LAYER_ID, _FONT, TABLE_VALUE_COLOR, "left", _SLANT, ) ) length = row["end_m"] - row["start_m"] out.append( _text_entity( f"{seed}:len", f"L={length:.1f}m", (x0 + x1) / 2, y0 - 5.0, LABEL_LAYER_ID, _FONT, TABLE_LABEL_COLOR, ) ) return [entity for entity in out if entity] def _table(drawing_id, lines, label, origin): """측점(NO) · 거리(M) · 높이/폭(M) · 면적(㎡) · 계.""" def cell(text, align="center", color=TABLE_LABEL_COLOR): return {"text": text, "align": align, "color": color} head = "폭" if label == "B" else "높이" cells = [ [cell("측 점"), cell("거 리"), cell(head), cell("면 적")], [cell("(NO)"), cell("(M)"), cell("(M)"), cell("(㎡)")], ] for item in lines: cells.append( [ cell(item["station"]), cell(_fmt(item["distance"]), "right", TABLE_VALUE_COLOR), cell(_size(item["height"]), "right", TABLE_VALUE_COLOR), cell(_fmt(item["area"]), "right", TABLE_VALUE_COLOR), ] ) areas = [item["area"] for item in lines if item["area"] is not None] cells.append( [ cell("계"), cell(_fmt(sum(item["distance"] for item in lines)), "right", TABLE_VALUE_COLOR), cell(""), cell(_fmt(sum(areas)) if areas else "", "right", TABLE_VALUE_COLOR), ] ) return table_entity( f"{drawing_id}:table", origin, list(_COLUMNS), [_ROW_H] * len(cells), cells, TABLE_LAYER_ID, TABLE_COLOR, _TABLE_FONT, TABLE_LABEL_COLOR, ) def build_development_drawing( drawing_id: str, sheet: dict[str, Any], interval_m: float ) -> dict[str, Any]: """전개도 한 장 → {format, entities, layers} — 작도 영역에 그대로 놓고 도각을 두름.""" area = drawing_area() ax0, ay0, ax1, ay1 = area rows = sheet["rows"] lines = table_rows(rows, interval_m) label = "B" if sheet["group"] in ("concrete", "gravel") else "H" table_w, table_h = sum(_COLUMNS), _ROW_H * (len(lines) + 3) table_box = (ax1 - _MARGIN - table_w, ay0 + _MARGIN, ax1 - _MARGIN, ay0 + _MARGIN + table_h) denominator, placed = _choose(rows, area, table_box) def station(chainage: float) -> str: return station_text(chainage, interval_m) entities: list[dict[str, Any]] = [ _text_entity( f"{drawing_id}:title", sheet["title"], (ax0 + ax1) / 2, ay1 - 14.0, LABEL_LAYER_ID, _TITLE_FONT, TABLE_LABEL_COLOR, ), _text_entity( f"{drawing_id}:scale", f"S=1:{denominator:,}", ax1 - _MARGIN, ay1 - 14.0, LABEL_LAYER_ID, _FONT, TABLE_LABEL_COLOR, "right", ), ] for index, (row, *box) in enumerate(placed or []): entities += _box_entities(drawing_id, index, row, tuple(box), label, station) entities.append(_table(drawing_id, lines, label, (table_box[0], table_box[3]))) if sheet.get("missing"): entities.append( _text_entity( f"{drawing_id}:missing", f"길이 미입력 {sheet['missing']}곳 — 그리지 않음", table_box[2], table_box[3] + 4.0, LABEL_LAYER_ID, _FONT, TABLE_VALUE_COLOR, "right", ) ) if not rows and not sheet.get("missing"): entities.append( _text_entity( f"{drawing_id}:empty", "그릴 구조물이 없습니다 — 목록을 새로 고침", (ax0 + ax1) / 2, (ay0 + ay1) / 2, LABEL_LAYER_ID, _FONT * 2, TABLE_VALUE_COLOR, ) ) title = sheet["title"] entities += frame_entities( drawing_id, area, fit=False, bottom_left=True, fields={"도면명": title, **scale_fields(("", denominator))}, ) return { "format": DRAWING_FORMAT, "entities": entities, "layers": [ _layer(BOX_LAYER_ID, "전개도 박스"), _layer(FILL_LAYER_ID, "재질 무늬"), _layer(LABEL_LAYER_ID, "측점 · 치수"), _layer(TABLE_LAYER_ID, "전개도 표"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], }