- 새 E05_StabReport_Maps.py — E04 구간 격자 · E07 등급 격자는 PNG(셀 = 화소)로 SVG 안에 · E06 판정 나쁜 측점 평사투영 · 3D요약 · 광역요약 · 암반검토 표 밑에 「n. 제목」 줄 + 그림 한 행 - GET 보고서: 고칠 수 있는 회사면 낡은 작업본을 store.refresh 로 갱신(refreshed · overwritten) · 보기만 회사는 띠 그대로 · 화면 알림 - 시험 534_3: 3D · 광역 견본에 격자 · 그림 닻 · xlsx 그림 수 · PDF 3D 2쪽 · 저절로 갱신 · 스테레오넷 측점 고름 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
330 lines
14 KiB
Python
330 lines
14 KiB
Python
"""E05 보고서 그림 셋 — 3D 안전율 지도(E04) · 스테레오넷(E06) · 광역 위험 지도(E07)
|
|
(요구 390 4 · PLAN 565-2).
|
|
|
|
map3d_svg(docs, line, ends, condition) 3D 구간 격자(셀 최소 Fs) · 노선 · 첫 · 끝 측점
|
|
stereonet_svg(net, title) E06 측점 하나 쪽 평사투영 — 위험 영역 · 대원 · 극점 · 교선
|
|
area_svg(doc, line, ends) E07 광역 등급(SI) 격자 · 노선 (그림 셋 다 684 × 460)
|
|
figure_jobs(by, inputs, root) 시트(s6 · s7 · s11) 표 밑에 그림 붙임 · 일감 [(열쇠, SVG)]
|
|
|
|
격자는 PNG(data: 주소)로 SVG 안에 — 셀 하나 = 화소 하나(값 셈 없음 · 저장본 그대로 칠함).
|
|
색 = 화면 범례와 같은 값(E04 `logic/E04_Stab3D_Raster.ts` FS_STEPS ·
|
|
E07 `logic/E07_StabArea_Raster.ts` CLASS_COLORS) — 한쪽을 고치면 다른 쪽도.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
from E04_Stab3D import E04_Stab3D_Run as run3d
|
|
from E05_StabReport.E05_StabReport_Cells import _label_at, _labels, _set
|
|
from E05_StabReport.E05_StabReport_Figure import INK, MUTED, _empty, _poly, _text
|
|
from E06_StabRock import E06_StabRock_Model as rock_model
|
|
from E07_StabArea.E07_StabArea_Router import load as load_area
|
|
|
|
W, H = 684, 460
|
|
PAD, TOP, LEGEND = 24, 30, 46
|
|
MAP3D_KEY, STEREONET_KEY, AREA_KEY = "fig/3d_map.png", "fig/stereonet.png", "fig/area_map.png"
|
|
FS_STEPS = ( # (미만, 색) — E04 화면 FS_STEPS
|
|
(1.0, "#a50026"),
|
|
(1.2, "#d73027"),
|
|
(1.3, "#f46d43"),
|
|
(1.5, "#fdae61"),
|
|
(2.0, "#fee08b"),
|
|
(3.0, "#a6d96a"),
|
|
(float("inf"), "#1a9850"),
|
|
)
|
|
CLASS_COLORS = ("#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027")
|
|
CLASS_NAMES = ("안정", "보통 안정", "준안정", "하한 불안정", "상한 불안정", "불안정")
|
|
CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
|
|
ROCK_ORDER = {"NG": 0, "check": 1, "OK": 2, "none": 3}
|
|
ZONE_COLORS = {"planar": "#d73027", "wedge": "#fc8d59", "toppling": "#7b1fa2"}
|
|
ZONE_NAMES = {"planar": "평면", "wedge": "쐐기", "toppling": "전도"}
|
|
JOINT_COLORS = ("#1565c0", "#2e7d32", "#ef6c00", "#6a1b9a", "#00838f", "#5d4037")
|
|
|
|
|
|
def _rgb(hex_color: str) -> tuple[int, int, int]:
|
|
n = int(hex_color[1:], 16)
|
|
return (n >> 16) & 255, (n >> 8) & 255, n & 255
|
|
|
|
|
|
def _png_uri(rgba: np.ndarray) -> str:
|
|
buf = io.BytesIO()
|
|
Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
|
|
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
|
|
|
|
|
|
def _paint(values: np.ndarray, colors: list[tuple[int, int, int]]) -> np.ndarray:
|
|
"""색 번호 격자(-1 = 비움 · 줄 0 = 남쪽) → RGBA(줄 0 = 북쪽)."""
|
|
rgba = np.zeros((*values.shape, 4), np.uint8)
|
|
for k, color in enumerate(colors):
|
|
rgba[values == k] = (*color, 255)
|
|
return rgba[::-1]
|
|
|
|
|
|
def _frame(boxes: list[tuple[float, float, float, float]]):
|
|
"""모델 상자들(x0, y0, x1, y1) → 화면 변환(북 = 위 · 같은 축척)."""
|
|
x0 = min(b[0] for b in boxes)
|
|
y0 = min(b[1] for b in boxes)
|
|
x1 = max(b[2] for b in boxes)
|
|
y1 = max(b[3] for b in boxes)
|
|
w, h = W - 2 * PAD, H - TOP - LEGEND - PAD / 2
|
|
scale = min(w / max(x1 - x0, 1e-6), h / max(y1 - y0, 1e-6))
|
|
ox = PAD + (w - (x1 - x0) * scale) / 2
|
|
oy = TOP + (h - (y1 - y0) * scale) / 2
|
|
|
|
def sp(x: float, y: float) -> tuple[float, float]:
|
|
return ox + (x - x0) * scale, oy + (y1 - y) * scale
|
|
|
|
return sp, scale
|
|
|
|
|
|
def _grid_box(grid: dict[str, Any]) -> tuple[float, float, float, float]:
|
|
half = grid["dx"] / 2
|
|
return (
|
|
grid["x0"] - half,
|
|
grid["y0"] - half,
|
|
grid["x0"] + (grid["nx"] - 0.5) * grid["dx"],
|
|
grid["y0"] + (grid["ny"] - 0.5) * grid["dx"],
|
|
)
|
|
|
|
|
|
def _image(grid: dict[str, Any], uri: str, sp, scale: float) -> str:
|
|
x0, _, _, y1 = _grid_box(grid)
|
|
a, b = sp(x0, y1)
|
|
return (
|
|
f"<image x='{a:.1f}' y='{b:.1f}' width='{grid['nx'] * grid['dx'] * scale:.1f}' "
|
|
f"height='{grid['ny'] * grid['dx'] * scale:.1f}' preserveAspectRatio='none' "
|
|
f"style='image-rendering:pixelated' href='{uri}'/>"
|
|
)
|
|
|
|
|
|
def _head(title: str) -> list[str]:
|
|
return [
|
|
f"<svg xmlns='http://www.w3.org/2000/svg' width='{W}' height='{H}' viewBox='0 0 {W} {H}'>",
|
|
f"<rect width='{W}' height='{H}' fill='#ffffff'/>",
|
|
_text(W / 2, 18, title, 13, font_weight="bold", fill=INK),
|
|
]
|
|
|
|
|
|
def _route(line: list[tuple[float, float]], ends: list[tuple[float, float, str]], sp) -> list[str]:
|
|
out = [_poly([sp(x, y) for x, y in line], stroke=INK, stroke_width="1.6")]
|
|
for x, y, label in ends:
|
|
a, b = sp(x, y)
|
|
out.append(f"<circle cx='{a:.1f}' cy='{b:.1f}' r='3' fill='{INK}'/>")
|
|
out.append(
|
|
_text(
|
|
a,
|
|
b - 7,
|
|
label,
|
|
10,
|
|
fill=INK,
|
|
stroke="#ffffff",
|
|
stroke_width="3",
|
|
paint_order="stroke",
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def _legend(items: list[tuple[str, str]], kind: str = "box") -> list[str]:
|
|
out, lx, ly = [], PAD, H - 18
|
|
for color, label in items:
|
|
if kind == "line":
|
|
out.append(
|
|
f"<line x1='{lx}' y1='{ly - 4}' x2='{lx + 16}' y2='{ly - 4}' stroke='{color}' "
|
|
"stroke-width='2'/>"
|
|
)
|
|
else:
|
|
out.append(f"<rect x='{lx}' y='{ly - 10}' width='14' height='10' fill='{color}'/>")
|
|
out.append(_text(lx + 19, ly, label, 10, "start", fill=INK))
|
|
lx += 26 + 6.5 * len(label) + 4 * sum(ord(ch) > 0x2000 for ch in label)
|
|
return out
|
|
|
|
|
|
def map3d_svg(docs: list[dict[str, Any]], line, ends, condition: str) -> str:
|
|
title = f"3D 안전율 지도 — {CONDITION_NAMES.get(condition, condition)} · 셀별 최소 Fs"
|
|
if not docs:
|
|
return _empty(W, H, title)
|
|
boxes = [_grid_box(d["grid"]) for d in docs] + [(x, y, x, y) for x, y in line]
|
|
sp, scale = _frame(boxes)
|
|
out = _head(title)
|
|
colors = [_rgb(c) for _, c in FS_STEPS]
|
|
for doc in docs:
|
|
g = doc["grid"]
|
|
f = np.array([np.nan if v is None else v for v in doc["f"]], float).reshape(
|
|
g["ny"], g["nx"]
|
|
)
|
|
steps = np.searchsorted([b for b, _ in FS_STEPS[:-1]], f, side="right")
|
|
steps[~np.isfinite(f)] = -1
|
|
out.append(_image(g, _png_uri(_paint(steps, colors)), sp, scale))
|
|
out += _route(line, ends, sp)
|
|
labels = ["< 1.0", "1.0~1.2", "1.2~1.3", "1.3~1.5", "1.5~2.0", "2.0~3.0", "≥ 3.0"]
|
|
out += _legend([(c, t) for (_, c), t in zip(FS_STEPS, labels)])
|
|
out.append("</svg>")
|
|
return "".join(out)
|
|
|
|
|
|
def area_svg(doc: dict[str, Any] | None, line, ends) -> str:
|
|
title = "광역 자연사면 위험 지도 — 안정 등급(SI)"
|
|
if not doc:
|
|
return _empty(W, H, title)
|
|
g = doc["grid"]
|
|
cls = np.frombuffer(base64.b64decode(doc["cls"]), np.uint8).reshape(g["ny"], g["nx"])
|
|
sp, scale = _frame([_grid_box(g)] + [(x, y, x, y) for x, y in line])
|
|
out = _head(title)
|
|
rgba = _paint(cls.astype(int) - 1, [_rgb(c) for c in CLASS_COLORS])
|
|
out.append(_image(g, _png_uri(rgba), sp, scale))
|
|
out += _route(line, ends, sp)
|
|
out += _legend(list(zip(CLASS_COLORS, CLASS_NAMES)))
|
|
out.append("</svg>")
|
|
return "".join(out)
|
|
|
|
|
|
def stereonet_svg(net: dict[str, Any], title: str) -> str:
|
|
"""하반구 투영 — 반지름 1 원 · x = 동 · y = 북."""
|
|
r0 = (H - TOP - LEGEND - 10) / 2
|
|
cx, cy = W / 2, TOP + 6 + r0
|
|
|
|
def sp(x: float, y: float) -> tuple[float, float]:
|
|
return cx + x * r0, cy - y * r0
|
|
|
|
def path(points: list[list[float]], closed: bool = False) -> str:
|
|
d = " ".join(f"{a:.1f},{b:.1f}" for a, b in (sp(*p) for p in points))
|
|
return f"M{d}{' Z' if closed else ''}"
|
|
|
|
out = _head(title)
|
|
out.append(f"<circle cx='{cx}' cy='{cy}' r='{r0:.1f}' fill='#ffffff' stroke='{INK}'/>")
|
|
for mode, zone in (net.get("zones") or {}).items():
|
|
if zone:
|
|
color = ZONE_COLORS[mode]
|
|
out.append(
|
|
f"<path d='{path(zone, True)}' fill='{color}' fill-opacity='0.18' "
|
|
f"stroke='{color}' stroke-opacity='0.5'/>"
|
|
)
|
|
for r, dash in ((net["friction_pole_r"], "5 3"), (net["friction_line_r"], "2 3")):
|
|
out.append(
|
|
f"<circle cx='{cx}' cy='{cy}' r='{r * r0:.1f}' fill='none' stroke='{MUTED}' "
|
|
f"stroke-dasharray='{dash}'/>"
|
|
)
|
|
out.append(
|
|
f"<path d='{path(net['slope']['great_circle'])}' fill='none' stroke='{INK}' "
|
|
"stroke-width='2.4'/>"
|
|
)
|
|
legend = [(INK, "비탈면")]
|
|
for k, plane in enumerate(net.get("planes") or []):
|
|
color = JOINT_COLORS[k % len(JOINT_COLORS)]
|
|
out.append(
|
|
f"<path d='{path(plane['great_circle'])}' fill='none' stroke='{color}' "
|
|
"stroke-width='1.4'/>"
|
|
)
|
|
a, b = sp(*plane["pole"])
|
|
out.append(f"<circle cx='{a:.1f}' cy='{b:.1f}' r='3.5' fill='{color}'/>")
|
|
legend.append((color, plane.get("name") or plane["id"]))
|
|
for item in net.get("intersections") or []:
|
|
a, b = sp(*item["point"])
|
|
out.append(
|
|
f"<rect x='{a - 3:.1f}' y='{b - 3:.1f}' width='6' height='6' fill='none' "
|
|
f"stroke='{INK}'/>"
|
|
)
|
|
out.append(_text(cx, cy - r0 - 4, "N", 11, font_weight="bold", fill=INK))
|
|
out += _legend(legend, "line")
|
|
zx = W - PAD
|
|
for mode in ("toppling", "wedge", "planar"):
|
|
label = f"{ZONE_NAMES[mode]} 위험 영역"
|
|
out.append(_text(zx, H - 34, label, 10, "end", fill=ZONE_COLORS[mode]))
|
|
zx -= 14 + 11 * len(label)
|
|
out.append("</svg>")
|
|
return "".join(out)
|
|
|
|
|
|
# ── 시트에 붙임 ──────────────────────────────────────────────────────────────
|
|
def _last_row(sheet: dict[str, Any]) -> int:
|
|
rows = [int("".join(ch for ch in a1 if ch.isdigit())) for a1 in sheet["칸"]]
|
|
for fig in sheet.get("그림") or []:
|
|
rows.append(int("".join(ch for ch in fig["닻"].split(":")[-1] if ch.isdigit())))
|
|
return max(rows, default=0)
|
|
|
|
|
|
def _attach(sheet: dict[str, Any], key: str, caption: str) -> None:
|
|
"""표 밑 한 줄 띄고 「n. 제목」 줄(서식 3 · A:H 병합) + 그림 한 줄(그림 높이 ·
|
|
한 행이라 쪽에서 안 끊김)."""
|
|
n = 1 + sum(
|
|
1
|
|
for c in sheet["칸"].values()
|
|
if c.get("서식") == 3 and str(c.get("값") or "")[:1].isdigit()
|
|
)
|
|
r = _last_row(sheet) + 2
|
|
_set(sheet, f"A{r}", f"{n}. {caption}", 3)
|
|
sheet.setdefault("병합", []).append(f"A{r}:H{r}")
|
|
rows = sheet.setdefault("행", {})
|
|
rows[str(r)] = {"높이": 22}
|
|
rows[str(r + 1)] = {"높이": round(H * 0.75)}
|
|
sheet.setdefault("그림", []).append({"열쇠": key, "닻": f"A{r + 1}:H{r + 1}", "이름": caption})
|
|
|
|
|
|
def _line_ends(inputs: dict[str, Any]):
|
|
line = [(float(p["model_x"]), float(p["model_y"])) for p in inputs["route_points"]]
|
|
labels = _labels(inputs["stations"])
|
|
ends = []
|
|
for p in (inputs["route_points"][0], inputs["route_points"][-1]) if line else ():
|
|
ends.append(
|
|
(float(p["model_x"]), float(p["model_y"]), _label_at(labels, float(p["chainage_m"])))
|
|
)
|
|
return line, ends
|
|
|
|
|
|
def _rock_pick(rock: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""스테레오넷을 그릴 측점 — 판정 나쁜 쪽 · 같으면 최소 Fs 낮은 쪽."""
|
|
entries = [e for e in rock.get("stations") or [] if e.get("joints")]
|
|
return min(
|
|
entries,
|
|
key=lambda e: (
|
|
ROCK_ORDER.get(e.get("verdict"), 9),
|
|
e.get("min_F") is None,
|
|
e.get("min_F") or 0,
|
|
),
|
|
default=None,
|
|
)
|
|
|
|
|
|
def figure_jobs(by: dict[str, dict], inputs: dict[str, Any], root: Path) -> list[tuple[str, str]]:
|
|
"""결과가 있는 그림만 시트에 붙이고 그림 일감 — 3D(s6) · 광역(s7) · 암반(s11)."""
|
|
jobs: list[tuple[str, str]] = []
|
|
line, ends = _line_ends(inputs)
|
|
secs = inputs["sections3d"]
|
|
if secs:
|
|
conds = {s.get("condition") or "dry" for s in secs}
|
|
cond = "dry" if "dry" in conds else sorted(conds)[0]
|
|
docs = [run3d.load(root, s["key"]) for s in secs if (s.get("condition") or "dry") == cond]
|
|
docs = [d for d in docs if d and d.get("grid") and d.get("f")]
|
|
if docs:
|
|
_attach(by["s6"], MAP3D_KEY, "3D 안전율 지도")
|
|
jobs.append((MAP3D_KEY, map3d_svg(docs, line, ends, cond)))
|
|
if inputs.get("area"):
|
|
doc = load_area(root)
|
|
if doc and doc.get("grid") and doc.get("cls"):
|
|
_attach(by["s7"], AREA_KEY, "광역 위험 지도")
|
|
jobs.append((AREA_KEY, area_svg(doc, line, ends)))
|
|
rock = inputs.get("rock")
|
|
entry = _rock_pick(rock) if rock else None
|
|
if entry:
|
|
settings = rock["settings"]
|
|
kind = settings.get("projection") or "equal_angle"
|
|
net = rock_model.station_net(entry, settings)[kind]
|
|
names = {j["id"]: j.get("name") or j["id"] for j in entry["joints"]}
|
|
for plane in net.get("planes") or []:
|
|
plane["name"] = names.get(plane["id"], plane["id"])
|
|
label = _label_at(_labels(inputs["stations"]), float(entry["chainage_m"]))
|
|
side = {"left": "좌", "right": "우"}.get(entry["slope"].get("side"), "")
|
|
proj = "등각(Wulff)" if kind == "equal_angle" else "등면적(Schmidt)"
|
|
caption = f"평사투영 — {label} {side} · 하반구 {proj}"
|
|
_attach(by["s11"], STEREONET_KEY, caption)
|
|
jobs.append((STEREONET_KEY, stereonet_svg(net, caption)))
|
|
return jobs
|