"""E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦. section_svg(…) → 대표 단면(712 × 640) — 원지반 · 공사 뒤 지표 · 지층(무늬) · 성토(점) · 우기 지하수위 · 조건별 임계 활동면(선 모양) · 노폭 · 비탈 높이 치수 · 표고 · 거리 눈금 · 축척 막대 · 안전율 상자 · 지층 범례 band_svg(…) → 평면 위험 띠(684 × 320) — 노선 · 측점 2D 판정 색 · 3D 구간 띠 좌표 = B06 횡단 offset(좌 +) · 화면 왼쪽 = +offset (B06 횡단도 관례 · `B06_Section_UI_Cross_View.ts`). 값 셈 없음 — 해석 결과(E03 · E04 저장본)를 그리기만. """ from __future__ import annotations import math from html import escape from typing import Any SECTION_W, SECTION_H = 712, 640 BAND_W, BAND_H = 684, 320 OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222" CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"} CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} SLIP_DASH = {"wet": "7 4", "seismic": "2 3"} # 건기 · 우기 임계면이 겹쳐도 둘 다 보이게 FONT = "font-family='Malgun Gothic, sans-serif'" def _nice_step(span: float, count: int) -> float: raw = max(span, 1e-6) / max(count, 1) base = 10 ** math.floor(math.log10(raw)) return next(m * base for m in (1, 2, 5, 10) if m * base >= raw) def _poly(points: list[tuple[float, float]], **attrs: str) -> str: if len(points) < 2: return "" d = " ".join(f"{x:.1f},{y:.1f}" for x, y in points) extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items()) return f"" def _text(x: float, y: float, text: str, size: int = 11, anchor: str = "middle", **attrs) -> str: extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items()) return ( f"{escape(text)}" ) def _interp(line: list[tuple[float, float]], x: float) -> float | None: for (x0, z0), (x1, z1) in zip(line, line[1:]): if min(x0, x1) - 1e-9 <= x <= max(x0, x1) + 1e-9: return z0 if x1 == x0 else z0 + (z1 - z0) * (x - x0) / (x1 - x0) return None def _legend_rows(items: list[tuple[str, str]], width: float, size: int) -> list[list[int]]: """범례 항목 [(종류, 글)] → 줄마다 항목 번호(폭을 넘으면 다음 줄).""" rows: list[list[int]] = [[]] used = 0.0 for i, (_, label) in enumerate(items): w = 30 + _text_w(label, size) if rows[-1] and used + w > width: rows.append([]) used = 0.0 rows[-1].append(i) used += w return rows def _text_w(text: str, size: int) -> float: """글 폭 근사(px) — 한글 · 기호 = 글자 크기 · 그 밖 = 0.6 배.""" return sum(size if ord(ch) > 0x2000 else 0.6 * size for ch in text) PATTERNS = ( "" "" "" "" "" "" ) STRATA_FILL = {"soil": "#F3E9D8", "rock": "url(#rockHatch)", "fill": "url(#fillDots)"} def section_svg( *, title: str, ground: list[tuple[float, float]], surface: list[tuple[float, float]], rock: list[tuple[float, float]] | None, slips: list[dict[str, Any]], road: tuple[float, float] | None, heights: list[dict[str, Any]], wet: bool, strata: list[dict[str, str]] | None = None, fs_box: list[dict[str, Any]] | None = None, ) -> str: """대표 단면 SVG(흑백 인쇄에도 읽힘 — 임계면 = 선 모양 · 지층 = 무늬). ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] · road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1, label?}] 비탈 높이 치수 · strata = [{kind: soil | rock | fill, text}] 지층 범례 · fs_box = [{condition, F, required, ok}] 안전율 상자 · title = 빈 글이면 안 씀(보고서 쪽 제목이 있음). """ pad_l, pad_r, pad_t, pad_b = 58, 18, 30 if title else 22, 88 pts = [*ground, *surface, *(p for s in slips for p in s["points"])] if not pts: return _empty(SECTION_W, SECTION_H, title or "대표 단면") xs = [p[0] for p in pts] zs = [p[1] for p in pts] x_lo, x_hi = min(xs), max(xs) z_lo, z_hi = min(zs), max(zs) z_lo -= 0.08 * (z_hi - z_lo + 1) z_hi += 0.12 * (z_hi - z_lo + 1) w, h = SECTION_W - pad_l - pad_r, SECTION_H - pad_t - pad_b scale = min(w / max(x_hi - x_lo, 1e-6), h / max(z_hi - z_lo, 1e-6)) # 가로 · 세로 같은 축척 cx = pad_l + (w - (x_hi - x_lo) * scale) / 2 cz = pad_t + (h - (z_hi - z_lo) * scale) / 2 def sx(x: float) -> float: return cx + (x_hi - x) * scale # +offset = 화면 왼쪽 def sz(z: float) -> float: return cz + (z_hi - z) * scale def screen(line: list[tuple[float, float]]) -> list[tuple[float, float]]: return [(sx(x), sz(z)) for x, z in line] def area(line: list[tuple[float, float]], y: float) -> str: """선과 y 사이를 닫은 다각형 점 글.""" body = screen(line) return " ".join(f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], y), (body[0][0], y)]) bottom, top = sz(z_lo), pad_t out = [ f"", f"{PATTERNS}", ] if surface: out.append(f"") if ground: out.append(f"") out += ["", f""] if title: out.append(_text(SECTION_W / 2, 20, title, 13, font_weight="bold", fill=INK)) # 눈금(표고 · 거리) step_z = _nice_step(z_hi - z_lo, 6) z = math.ceil(z_lo / step_z) * step_z while z <= z_hi: y = sz(z) out.append( f"" ) out.append(_text(pad_l - 6, y + 4, f"{z:g}", 11, "end", fill="#444444")) z += step_z step_x = _nice_step(x_hi - x_lo, 8) x = math.ceil(x_lo / step_x) * step_x base_y = SECTION_H - pad_b + 15 while x <= x_hi: out.append(_text(sx(x), base_y, f"{x:g}", 11, fill="#444444")) x += step_x out.append(_text(pad_l - 6, pad_t - 8, "EL.(m)", 10, "end", fill="#444444")) out.append( _text(SECTION_W - pad_r, base_y + 15, "offset (m) · 좌 +", 10, "end", fill="#444444") ) # 축척 막대(가로 = 세로) bar = _nice_step((x_hi - x_lo) / 5, 1) bx, by, half = pad_l + 4, base_y + 12, bar * scale / 2 out.append( f"" f"" ) out.append(_text(bx + 2 * half + 6, by, f"{bar:g} m · 가로 = 세로 축척", 10, "start", fill=INK)) # 지반 · 지층 — 공사 뒤 지표 아래 = 몸체 · 원지반 위 = 성토(점) · 암(빗금) if surface: out.append(f"") if ground: out.append( f"" ) if rock: clip = " clip-path='url(#below)'" if surface else "" out.append(f"") out.append(_poly(screen(rock), stroke="#6D4C41", stroke_width="1", stroke_dasharray="6 3")) out.append(_poly(screen(ground), stroke="#6D4C41", stroke_width="1.3", stroke_dasharray="3 2")) out.append(_poly(screen(surface), stroke=INK, stroke_width="2.2")) if wet and surface: # 우기 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3) — ▽ 표시 for frac in (0.2, 0.8): px, pz = surface[int(frac * (len(surface) - 1))] a, b = sx(px), sz(pz) - 4 out.append( f"" ) # 임계 활동면 for slip in slips: color = CONDITION_COLORS.get(slip["condition"], NG) dash = SLIP_DASH.get(slip["condition"]) extra = {"stroke_dasharray": dash} if dash else {} out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.6", **extra)) # 치수 — 노폭 · 비탈 높이 if road: r_off, l_off = road zr, zl = _interp(surface, r_off), _interp(surface, l_off) if zr is not None and zl is not None: y = min(sz(zr), sz(zl)) - 18 a, b = sx(l_off), sx(r_off) out.append( f"" ) out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 11, fill=INK)) for dim in heights: if abs(dim["z1"] - dim["z0"]) < 0.05: continue a = sx(dim["x"]) y0, y1 = sz(dim["z0"]), sz(dim["z1"]) out.append( f"" ) label = dim.get("label") or f"H {abs(dim['z1'] - dim['z0']):.2f} m" # 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침) · 그림 밖으로 나가면 반대쪽 left = a < SECTION_W / 2 if not left and a + 4 + _text_w(label, 11) > SECTION_W - 2: left = True if left and a - 4 - _text_w(label, 11) < 2: left = False anchor = "end" if left else "start" out.append(_text(a - 4 if left else a + 4, (y0 + y1) / 2, label, 11, anchor, fill=INK)) if fs_box: out.append(_fs_box(fs_box, surface, sx, sz, pad_l, pad_t, SECTION_W - pad_r)) # 범례 — 선 · 표시 · 지층 무늬(폭을 넘으면 다음 줄) items: list[tuple[str, str]] = [("#6D4C41|3 2", "원지반"), (f"{INK}|", "공사 뒤 지표")] if rock: items.append(("#6D4C41|6 3", "지층 경계")) for slip in slips: name = CONDITION_NAMES.get(slip["condition"], slip["condition"]) f = slip.get("F") color = CONDITION_COLORS.get(slip["condition"], NG) label = f"{name} 임계면 Fs {f:.2f}" if f else name items.append((f"{color}|{SLIP_DASH.get(slip['condition'], '')}", label)) if wet: items.append(("▽", "우기 지하수위 = 지표")) for layer in strata or []: items.append((f"@{layer['kind']}", layer["text"])) ly = SECTION_H - 46 for row in _legend_rows(items, SECTION_W - pad_l - pad_r, 11): lx = float(pad_l) for i in row: kind, label = items[i] if kind.startswith("@"): out.append( f"" ) elif kind == "▽": out.append( f"" ) else: color, dash = kind.split("|") dash_attr = f" stroke-dasharray='{dash}'" if dash else "" out.append( f"" ) out.append(_text(lx + 27, ly + 1, label, 11, "start", fill=INK)) lx += 30 + _text_w(label, 11) ly += 17 out.append("") return "".join(out) def _fs_box(rows, surface, sx, sz, left: float, top: float, right: float) -> str: """안전율 상자 — 하늘 쪽(지표가 낮은 끝) 위 모서리 · 조건 · Fs · 기준 · 판정.""" width, line = 236, 19 height = 24 + line * len(rows) # surface = offset 오름차순 · 화면 왼끝 = 큰 offset = 끝 점 low_left = bool(surface) and sz(surface[-1][1]) > sz(surface[0][1]) x = left + 6 if low_left else right - width - 6 y = top + 6 out = [ f"", f"", ] for cx, head, anchor in ((8, "조건", "start"), (78, "Fs", "end"), (132, "기준", "end")): out.append(_text(x + cx, y + 16, head, 11, anchor, font_weight="bold", fill=INK)) out.append(_text(x + 196, y + 16, "판정", 11, font_weight="bold", fill=INK)) for k, r in enumerate(rows): yy = y + 22 + line * (k + 1) - 5 name = CONDITION_NAMES.get(r["condition"], r["condition"]) f = "-" if r.get("F") is None else f"{r['F']:.2f}" verdict = "-" if r.get("ok") is None else "O.K" if r["ok"] else "N.G" color = INK if r.get("ok") is None else OK if r["ok"] else NG out.append(_text(x + 8, yy, name, 11, "start", fill=INK)) out.append(_text(x + 78, yy, f, 11, "end", font_weight="bold", fill=INK)) out.append(_text(x + 132, yy, f"≥ {r['required']:.2f}", 11, "end", fill=INK)) out.append(_text(x + 196, yy, verdict, 11, font_weight="bold", fill=color)) return "".join(out) def band_svg( *, title: str, line: list[tuple[float, float, float]], stations: list[dict[str, Any]], sections: list[dict[str, Any]], ) -> str: """평면 위험 띠 SVG. line = [(x, y, 누가)] 노선 · stations = [{x, y, ok: bool | None, label}] 2D 판정 · sections = [{start_m, end_m, ok: bool}] 3D 구간(노선 위 굵은 띠). """ pad = 24 xy = [(p[0], p[1]) for p in line] or [(s["x"], s["y"]) for s in stations] if not xy: return _empty(BAND_W, BAND_H, title) xs = [p[0] for p in xy] ys = [p[1] for p in xy] w, h = BAND_W - 2 * pad, BAND_H - 2 * pad - 40 scale = min(w / max(max(xs) - min(xs), 1e-6), h / max(max(ys) - min(ys), 1e-6)) ox = pad + (w - (max(xs) - min(xs)) * scale) / 2 oy = pad + 22 + (h - (max(ys) - min(ys)) * scale) / 2 def sp(x: float, y: float) -> tuple[float, float]: return ox + (x - min(xs)) * scale, oy + (max(ys) - y) * scale # 북 = 위 out = [ f"", f"", _text(BAND_W / 2, 18, title, 13, font_weight="bold", fill=INK), ] out.append(_poly([sp(x, y) for x, y in xy], stroke="#BDBDBD", stroke_width="3")) for sec in sections: part = [sp(x, y) for x, y, ch in line if sec["start_m"] - 1e-6 <= ch <= sec["end_m"] + 1e-6] color = OK if sec["ok"] else NG out.append(_poly(part, stroke=color, stroke_width="12", stroke_opacity="0.35")) for st in stations: a, b = sp(st["x"], st["y"]) color = MUTED if st["ok"] is None else OK if st["ok"] else NG out.append(f"") for st in (stations[0], stations[-1]) if stations else (): a, b = sp(st["x"], st["y"]) out.append(_text(a, b - 7, st["label"] or "", 10, fill=INK)) lx, ly = pad, BAND_H - 14 for color, label, width in ( (OK, "2D O.K", 0), (NG, "2D N.G", 0), (OK, "3D 구간 O.K", 12), (NG, "3D 구간 N.G", 12), ): if width: out.append( f"" ) else: out.append(f"") out.append(_text(lx + 26, ly, label, 10, "start", fill=INK)) lx += 120 if not sections: out.append(_text(BAND_W - pad, ly, "3D 해석 전", 10, "end", fill=MUTED)) out.append("") return "".join(out) def _empty(width: int, height: int, title: str) -> str: return ( f"" f"" + _text(width / 2, height / 2, f"{title} — 자료 없음", 13, fill=MUTED) + "" )