Files
Aislo/E05_StabReport/E05_StabReport_Figure.py
T

387 lines
18 KiB
Python

"""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"<polyline points='{d}' fill='none' {extra}/>"
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"<text x='{x:.1f}' y='{y:.1f}' font-size='{size}' text-anchor='{anchor}' {FONT} "
f"{extra}>{escape(text)}</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 = (
"<pattern id='rockHatch' width='7' height='7' patternUnits='userSpaceOnUse' "
"patternTransform='rotate(45)'><rect width='7' height='7' fill='#E2DACB'/>"
"<line x1='0' y1='0' x2='0' y2='7' stroke='#7A6A55' stroke-width='1'/></pattern>"
"<pattern id='fillDots' width='6' height='6' patternUnits='userSpaceOnUse'>"
"<rect width='6' height='6' fill='#FBF5E8'/><circle cx='3' cy='3' r='0.9' fill='#8D6E63'/>"
"</pattern>"
"<marker id='t' viewBox='0 0 4 10' refX='2' refY='5' markerWidth='4' markerHeight='10' "
"orient='auto'><path d='M2 0 L2 10' stroke='#222' stroke-width='1'/></marker>"
)
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"<svg xmlns='http://www.w3.org/2000/svg' width='{SECTION_W}' height='{SECTION_H}' "
f"viewBox='0 0 {SECTION_W} {SECTION_H}'>",
f"<defs>{PATTERNS}",
]
if surface:
out.append(f"<clipPath id='below'><polygon points='{area(surface, bottom)}'/></clipPath>")
if ground:
out.append(f"<clipPath id='above'><polygon points='{area(ground, top)}'/></clipPath>")
out += ["</defs>", f"<rect width='{SECTION_W}' height='{SECTION_H}' fill='#ffffff'/>"]
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"<line x1='{pad_l}' y1='{y:.1f}' x2='{SECTION_W - pad_r}' y2='{y:.1f}' "
"stroke='#E6E6E6' stroke-width='1'/>"
)
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"<rect x='{bx:.1f}' y='{by - 5:.1f}' width='{half:.1f}' height='4' fill='{INK}'/>"
f"<rect x='{bx + half:.1f}' y='{by - 5:.1f}' width='{half:.1f}' height='4' "
f"fill='#ffffff' stroke='{INK}' stroke-width='0.8'/>"
)
out.append(_text(bx + 2 * half + 6, by, f"{bar:g} m · 가로 = 세로 축척", 10, "start", fill=INK))
# 지반 · 지층 — 공사 뒤 지표 아래 = 몸체 · 원지반 위 = 성토(점) · 암(빗금)
if surface:
out.append(f"<polygon points='{area(surface, bottom)}' fill='{STRATA_FILL['soil']}'/>")
if ground:
out.append(
f"<polygon points='{area(surface, bottom)}' fill='{STRATA_FILL['fill']}' "
"clip-path='url(#above)'/>"
)
if rock:
clip = " clip-path='url(#below)'" if surface else ""
out.append(f"<polygon points='{area(rock, bottom)}' fill='{STRATA_FILL['rock']}'{clip}/>")
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"<polygon points='{a - 6:.1f},{b - 9:.1f} {a + 6:.1f},{b - 9:.1f} "
f"{a:.1f},{b:.1f}' fill='none' stroke='#1565C0' stroke-width='1.4'/>"
)
# 임계 활동면
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"<line x1='{a:.1f}' y1='{y:.1f}' x2='{b:.1f}' y2='{y:.1f}' stroke='{INK}' "
"stroke-width='0.9' marker-start='url(#t)' marker-end='url(#t)'/>"
)
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"<line x1='{a:.1f}' y1='{y0:.1f}' x2='{a:.1f}' y2='{y1:.1f}' stroke='{INK}' "
"stroke-width='0.9' marker-start='url(#t)' marker-end='url(#t)'/>"
)
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"<rect x='{lx:.1f}' y='{ly - 9:.1f}' width='22' height='12' "
f"fill='{STRATA_FILL[kind[1:]]}' stroke='#6D4C41' stroke-width='0.8'/>"
)
elif kind == "▽":
out.append(
f"<polygon points='{lx + 5:.1f},{ly - 9:.1f} {lx + 17:.1f},{ly - 9:.1f} "
f"{lx + 11:.1f},{ly + 1:.1f}' fill='none' stroke='#1565C0' stroke-width='1.4'/>"
)
else:
color, dash = kind.split("|")
dash_attr = f" stroke-dasharray='{dash}'" if dash else ""
out.append(
f"<line x1='{lx:.1f}' y1='{ly - 3:.1f}' x2='{lx + 22:.1f}' y2='{ly - 3:.1f}' "
f"stroke='{color}' stroke-width='2.4'{dash_attr}/>"
)
out.append(_text(lx + 27, ly + 1, label, 11, "start", fill=INK))
lx += 30 + _text_w(label, 11)
ly += 17
out.append("</svg>")
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"<rect x='{x:.1f}' y='{y:.1f}' width='{width}' height='{height}' fill='#ffffff' "
f"stroke='{INK}' stroke-width='1'/>",
f"<line x1='{x:.1f}' y1='{y + 22:.1f}' x2='{x + width:.1f}' y2='{y + 22:.1f}' "
f"stroke='{INK}' stroke-width='0.6'/>",
]
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"<svg xmlns='http://www.w3.org/2000/svg' width='{BAND_W}' height='{BAND_H}' "
f"viewBox='0 0 {BAND_W} {BAND_H}'>",
f"<rect width='{BAND_W}' height='{BAND_H}' fill='#ffffff'/>",
_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"<circle cx='{a:.1f}' cy='{b:.1f}' r='3.2' fill='{color}'/>")
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"<line x1='{lx}' y1='{ly - 4}' x2='{lx + 20}' y2='{ly - 4}' stroke='{color}' "
f"stroke-width='{width}' stroke-opacity='0.35'/>"
)
else:
out.append(f"<circle cx='{lx + 10}' cy='{ly - 4}' r='3.2' fill='{color}'/>")
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("</svg>")
return "".join(out)
def _empty(width: int, height: int, title: str) -> str:
return (
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width}' height='{height}'>"
f"<rect width='{width}' height='{height}' fill='#ffffff' stroke='#BDBDBD'/>"
+ _text(width / 2, height / 2, f"{title} — 자료 없음", 13, fill=MUTED)
+ "</svg>"
)