feat(536-1): E06 암반 평사투영 — Markland 판정 · Hoek-Bray 평면 · 쐐기 안전율 · 스테레오넷 · 절리 입력 · 화면
- 엔진(자체 · NumPy · SciPy): 평면 · 쐐기(교선) · 전도 Markland(측방 한계 20° 기본) · 평면 Hoek-Bray(인장균열 없음 · 임계 · 깊이 · 수압 · kh · 수동 볼트) · 쐐기 SWedge 꼴(미끄럼 방향 8 경우 · 두 면 · 한 면 · 수압 중력식 · kh · 볼트) - 스테레오넷 좌표(등각 · 등면적 하반구 · 극점 · 대원 · 교선 · 마찰원 · 일광 엔벨로프 · 위험 영역 = 판정 부등식) - 길: 절리 작업본 E06_StabRock/joints.json(초기값 · 저장 · 초기화) · 해석 = 암 측점 깎기 쪽만 · 파생 저장본 stab_rock · 결과 · 측점 스테레오넷 - 화면: 좌 절리군 표 · 설정 / 가운데 위험 띠 · 스테레오넷 / 우 측점 결과 · 안전율 표 · E 단계 막대에 E06(끝에 덧붙임) - 시험 test_536_1(Hoek Sau Mau Ping 0.8184 · 1.0655 · Sharma 윗면 무게 · Hoek-Bray 쐐기 계수 · Kumsar · Priest 두 예제 · Markland 경계 · 투영 왕복 · 위험 영역 = 판정) · test_528 E 단계 수 4 → 5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,14 @@ const FLOWS: readonly GroupFlow[] = [
|
||||
},
|
||||
{
|
||||
routes: STAB_STEP_ROUTES,
|
||||
labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"],
|
||||
icons: ["🪨", "📐", "🧊", "📄"],
|
||||
labels: [
|
||||
"DE_Step_StabGround",
|
||||
"DE_Step_StabSection",
|
||||
"DE_Step_Stab3D",
|
||||
"DE_Step_StabReport",
|
||||
"DE_Step_StabRock",
|
||||
],
|
||||
icons: ["🪨", "📐", "🧊", "📄", "⛰️"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""E06 암반 비탈면 — Markland 판정 · Hoek-Bray 평면 · 쐐기 안전율 (요구 345 · 351 · PLAN 536-1).
|
||||
|
||||
근거 = 지식DB `original/안정성평가/03`(Markland 부등식 · Hoek-Bray 평면 · 쐐기) · 06(상용 SW) ·
|
||||
Rocscience RocPlane · SWedge 이론서(힘 벡터 · 미끄럼 방향 8 경우 · 수직력 · 지지력은 수동력).
|
||||
코드는 직접 짬(Dips · RocPlane · SWedge 코드 안 씀) · NumPy · SciPy 만.
|
||||
|
||||
좌표 = 동(+x) · 북(+y) · 위(+z). 면 = (경사방향 α · 경사 ψ) · 선 = (추향 · 선주각).
|
||||
단위 = 길이 m · 단위중량 kN/m³ · 점착력 kPa · 각 ° · 힘 kN(평면 = 단위 폭당 kN/m).
|
||||
|
||||
⚠ 지식DB 03 의 평면 볼트 항(수직 T cos(θ+ψp) · 활동 −T sin(θ+ψp))은 θ = 볼트와 면 법선 사이 각일
|
||||
때 맞음 — 여기는 θ = 볼트 경사(수평 아래)로 받아 힘 벡터로 풂(수직 T sin(θ+ψp) · 활동 −T cos(θ+ψp)).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize_scalar
|
||||
|
||||
GAMMA_W = 9.81 # 물 단위중량 kN/m³ (E03 Types 와 같음)
|
||||
LATERAL_PLANAR_DEG = 20.0 # 평면 측방 한계(Dips 기본 · Wyllie & Mah)
|
||||
LATERAL_TOPPLING_DEG = 20.0 # 전도 측방 한계(Dips 기본 · 지식DB 03 은 30 — 화면에서 바꿈)
|
||||
|
||||
|
||||
# ---- 방향 ----------------------------------------------------------------------
|
||||
def ang_diff(a: float, b: float) -> float:
|
||||
"""두 방위각 사이 최소 각거리 0~180."""
|
||||
d = abs((a - b) % 360.0)
|
||||
return min(d, 360.0 - d)
|
||||
|
||||
|
||||
def plane_normal(dip_dir: float, dip: float) -> np.ndarray:
|
||||
"""면의 위쪽 단위 법선."""
|
||||
a, p = math.radians(dip_dir), math.radians(dip)
|
||||
return np.array([math.sin(a) * math.sin(p), math.cos(a) * math.sin(p), math.cos(p)])
|
||||
|
||||
|
||||
def line_vector(trend: float, plunge: float) -> np.ndarray:
|
||||
"""선(추향 · 선주각) → 아래로 향한 단위 벡터."""
|
||||
t, p = math.radians(trend), math.radians(plunge)
|
||||
return np.array([math.sin(t) * math.cos(p), math.cos(t) * math.cos(p), -math.sin(p)])
|
||||
|
||||
|
||||
def vector_line(v: np.ndarray) -> tuple[float, float]:
|
||||
"""벡터 → (추향 · 선주각) 하반구(위로 향하면 뒤집음)."""
|
||||
v = np.asarray(v, float)
|
||||
if v[2] > 0:
|
||||
v = -v
|
||||
v = v / np.linalg.norm(v)
|
||||
trend = math.degrees(math.atan2(v[0], v[1])) % 360.0
|
||||
return trend, math.degrees(math.asin(max(-1.0, min(1.0, -v[2]))))
|
||||
|
||||
|
||||
def pole(dip_dir: float, dip: float) -> tuple[float, float]:
|
||||
"""면의 극점(하반구) = (α + 180 · 90 − ψ)."""
|
||||
return (dip_dir + 180.0) % 360.0, 90.0 - dip
|
||||
|
||||
|
||||
def intersection(a: dict, b: dict) -> tuple[float, float] | None:
|
||||
"""두 면 교선(추향 · 선주각) — 나란하면 None."""
|
||||
v = np.cross(
|
||||
plane_normal(a["dip_direction"], a["dip"]), plane_normal(b["dip_direction"], b["dip"])
|
||||
)
|
||||
if np.linalg.norm(v) < 1e-9:
|
||||
return None
|
||||
return vector_line(v)
|
||||
|
||||
|
||||
def apparent_dip(dip: float, dip_dir: float, trend: float) -> float:
|
||||
"""면 (α · ψ) 의 trend 방향 겉보기 경사 — 반대쪽(90° 넘음)이면 음수."""
|
||||
return math.degrees(
|
||||
math.atan(math.tan(math.radians(dip)) * math.cos(math.radians(trend - dip_dir)))
|
||||
)
|
||||
|
||||
|
||||
def strike_to_dip_direction(strike: float) -> float:
|
||||
"""오른손 법칙 주향 → 경사방향."""
|
||||
return (strike + 90.0) % 360.0
|
||||
|
||||
|
||||
# ---- Markland 판정 -------------------------------------------------------------
|
||||
def kinematic(
|
||||
slope: dict,
|
||||
joints: list[dict],
|
||||
lateral_planar: float = LATERAL_PLANAR_DEG,
|
||||
lateral_toppling: float = LATERAL_TOPPLING_DEG,
|
||||
) -> dict[str, list]:
|
||||
"""slope = {dip_direction, dip} · joints = [{id, dip_direction, dip, phi}, …].
|
||||
|
||||
평면 = Δ(αf, αp) ≤ 측방 · φ < ψp < 겉보기 사면 경사 / 쐐기 = 교선 Δ < 90 · φ < 선주각 <
|
||||
겉보기 사면 경사(φ = 두 면 중 작은 값) /
|
||||
전도 = Δ(αf + 180, αp) ≤ 측방 · ψp > 90 − ψf + φ (Goodman).
|
||||
"""
|
||||
af, pf = float(slope["dip_direction"]), float(slope["dip"])
|
||||
planar, toppling, wedge = [], [], []
|
||||
for j in joints:
|
||||
aj, pj, phi = float(j["dip_direction"]), float(j["dip"]), float(j["phi"])
|
||||
if ang_diff(af, aj) <= lateral_planar and phi < pj < apparent_dip(pf, af, aj):
|
||||
planar.append(j["id"])
|
||||
if ang_diff(af + 180.0, aj) <= lateral_toppling and pj > 90.0 - pf + phi:
|
||||
toppling.append(j["id"])
|
||||
for i, a in enumerate(joints):
|
||||
for b in joints[i + 1 :]:
|
||||
line = intersection(a, b)
|
||||
if line is None:
|
||||
continue
|
||||
trend, plunge = line
|
||||
phi = min(float(a["phi"]), float(b["phi"]))
|
||||
if ang_diff(af, trend) < 90.0 and phi < plunge < apparent_dip(pf, af, trend):
|
||||
wedge.append(
|
||||
{
|
||||
"ids": [a["id"], b["id"]],
|
||||
"trend": round(trend, 2),
|
||||
"plunge": round(plunge, 2),
|
||||
}
|
||||
)
|
||||
return {"planar": planar, "wedge": wedge, "toppling": toppling}
|
||||
|
||||
|
||||
# ---- 평면 파괴 (Hoek-Bray · 2D 단위 폭) -----------------------------------------
|
||||
def _plane_block(H: float, pf: float, pp: float, ps: float, crack_x: float | None):
|
||||
"""사면 끝(0,0) · x = 산 쪽 수평 · z = 위. 덩이 다각형 · 활동면 끝 x · 균열 깊이.
|
||||
|
||||
윗면 = 마루(H cotψf, H)에서 ψs 로 오름. 균열 없음 = 활동면이 윗면과 만나는 점까지."""
|
||||
tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps))
|
||||
xc = H / tf # 마루
|
||||
|
||||
def top(x: float) -> float:
|
||||
return x * tf if x <= xc else H + (x - xc) * ts
|
||||
|
||||
if crack_x is None:
|
||||
if ts >= tp - 1e-12:
|
||||
return None # 활동면이 윗면과 안 만남 = 덩이 안 생김
|
||||
xe = (H - xc * ts) / (tp - ts)
|
||||
poly = [(0.0, 0.0), (xc, H), (xe, xe * tp)]
|
||||
return poly, xe, 0.0
|
||||
if crack_x < xc or top(crack_x) - crack_x * tp <= 1e-9:
|
||||
return None
|
||||
poly = [(0.0, 0.0), (xc, H), (crack_x, top(crack_x)), (crack_x, crack_x * tp)]
|
||||
return poly, crack_x, top(crack_x) - crack_x * tp
|
||||
|
||||
|
||||
def _area(poly) -> float:
|
||||
xs, zs = np.array(poly).T
|
||||
return 0.5 * abs(float(np.dot(xs, np.roll(zs, -1)) - np.dot(zs, np.roll(xs, -1))))
|
||||
|
||||
|
||||
def plane_fs(
|
||||
*,
|
||||
H: float,
|
||||
slope_dip: float,
|
||||
plane_dip: float,
|
||||
c: float,
|
||||
phi: float,
|
||||
gamma: float,
|
||||
upper_dip: float = 0.0,
|
||||
crack: dict | None = None,
|
||||
water_fill: float = 0.0,
|
||||
kh: float = 0.0,
|
||||
bolt_t: float = 0.0,
|
||||
bolt_plunge: float = 0.0,
|
||||
gamma_w: float = GAMMA_W,
|
||||
) -> dict[str, Any] | None:
|
||||
"""평면 파괴 안전율 — 덩이가 안 생기면 None.
|
||||
|
||||
crack = None | {"mode": "depth", "z": m} | {"mode": "critical"}(안전율 최소 자리 찾음) ·
|
||||
물 = 균열 있음: zw = fill·z · U = ½γw zw A · V = ½γw zw² / 균열 없음: 가운데 최대 수두
|
||||
fill·h/2(h = 활동면 오름) · U = ½γw (fill·h/2) A (Hoek · RocPlane) ·
|
||||
kh = 바깥 수평 · 볼트 = 수동력(산 쪽 · 수평 아래 bolt_plunge°)."""
|
||||
if not 0 < plane_dip < slope_dip < 90 or H <= 0 or upper_dip >= slope_dip:
|
||||
return None
|
||||
mode = (crack or {}).get("mode", "none")
|
||||
if mode == "critical":
|
||||
return _critical_crack(locals())
|
||||
tp = math.tan(math.radians(plane_dip))
|
||||
xc = H / math.tan(math.radians(slope_dip))
|
||||
crack_x = None
|
||||
if mode == "depth":
|
||||
z = float(crack["z"])
|
||||
crack_x = _crack_x_for_depth(H, slope_dip, plane_dip, upper_dip, z)
|
||||
if crack_x is None:
|
||||
return None
|
||||
elif mode == "x":
|
||||
crack_x = xc + float(crack["b"])
|
||||
block = _plane_block(H, slope_dip, plane_dip, upper_dip, crack_x)
|
||||
if block is None:
|
||||
return None
|
||||
poly, x_end, z = block
|
||||
psi = math.radians(plane_dip)
|
||||
A = x_end / math.cos(psi)
|
||||
W = gamma * _area(poly)
|
||||
if crack_x is not None:
|
||||
zw = water_fill * z
|
||||
U, V = 0.5 * gamma_w * zw * A, 0.5 * gamma_w * zw * zw
|
||||
else:
|
||||
zw = water_fill * (x_end * tp) / 2.0
|
||||
U, V = 0.5 * gamma_w * zw * A, 0.0
|
||||
d = np.array([-math.cos(psi), -math.sin(psi)]) # 아래로 미끄러짐(바깥 = −x)
|
||||
n = np.array([-math.sin(psi), math.cos(psi)]) # 면에서 덩이 쪽
|
||||
active = np.array([-kh * W - V, -W]) + U * n
|
||||
drive = float(active @ d)
|
||||
if drive <= 0:
|
||||
return None
|
||||
bolt = bolt_t * np.array(
|
||||
[math.cos(math.radians(bolt_plunge)), -math.sin(math.radians(bolt_plunge))]
|
||||
)
|
||||
N = max(0.0, -float((active + bolt) @ n))
|
||||
resist = c * A + N * math.tan(math.radians(phi)) - float(bolt @ d)
|
||||
return {
|
||||
"F": resist / drive,
|
||||
"W": W,
|
||||
"A": A,
|
||||
"U": U,
|
||||
"V": V,
|
||||
"z": z,
|
||||
"zw": zw,
|
||||
"crack_b": None if crack_x is None else crack_x - xc,
|
||||
"N": N,
|
||||
"drive": drive,
|
||||
"resist": resist,
|
||||
"polygon": [[round(x, 3), round(y, 3)] for x, y in poly],
|
||||
}
|
||||
|
||||
|
||||
def _crack_x_for_depth(H, pf, pp, ps, z) -> float | None:
|
||||
"""윗면 아래 균열 깊이 z 가 되는 자리(마루 뒤) — 깊이가 마루 틈보다 크면 None."""
|
||||
tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps))
|
||||
xc = H / tf
|
||||
gap = H - xc * tp # 마루에서 윗면 − 활동면
|
||||
if z <= 0 or z >= gap:
|
||||
return None
|
||||
if ts >= tp: # 틈이 커지기만 함 = 뒤로 갈수록 깊어짐
|
||||
return xc + (z - gap) / (ts - tp) if ts > tp else None
|
||||
return xc + (gap - z) / (tp - ts)
|
||||
|
||||
|
||||
def plane_critical_depth_hoek(H: float, slope_dip: float, plane_dip: float) -> float:
|
||||
"""Hoek-Bray 임계 인장균열 깊이(윗면 수평 · 건조) z = H(1 − √(cotψf tanψp))."""
|
||||
return H * (
|
||||
1.0 - math.sqrt(math.tan(math.radians(plane_dip)) / math.tan(math.radians(slope_dip)))
|
||||
)
|
||||
|
||||
|
||||
def _critical_crack(kw: dict) -> dict | None:
|
||||
"""균열 자리를 마루 뒤로 옮겨 안전율 최소 — 균열 없음보다 크면 균열 없음."""
|
||||
args = {k: kw[k] for k in kw if k not in ("crack", "mode")}
|
||||
tf, tp, ts = (math.tan(math.radians(kw[k])) for k in ("slope_dip", "plane_dip", "upper_dip"))
|
||||
xc = kw["H"] / tf
|
||||
x_far = (kw["H"] - xc * ts) / (tp - ts) if ts < tp else xc + 10 * kw["H"]
|
||||
span = x_far - xc
|
||||
|
||||
def fs_at(b: float) -> float:
|
||||
r = plane_fs(**args, crack={"mode": "x", "b": b})
|
||||
return r["F"] if r else math.inf
|
||||
|
||||
best = minimize_scalar(fs_at, bounds=(span * 1e-3, span * (1 - 1e-3)), method="bounded")
|
||||
found = plane_fs(**args, crack={"mode": "x", "b": float(best.x)})
|
||||
plain = plane_fs(**args, crack=None)
|
||||
if found is None or (plain is not None and plain["F"] <= found["F"]):
|
||||
return plain
|
||||
return found
|
||||
|
||||
|
||||
# ---- 쐐기 파괴 (Hoek-Bray · SWedge 꼴 사면체) ------------------------------------
|
||||
def _meet(planes: list[tuple[np.ndarray, float]]) -> np.ndarray | None:
|
||||
"""세 면 n·x = d 의 교점."""
|
||||
m = np.array([p[0] for p in planes])
|
||||
if abs(np.linalg.det(m)) < 1e-10:
|
||||
return None
|
||||
return np.linalg.solve(m, np.array([p[1] for p in planes]))
|
||||
|
||||
|
||||
def _tri_area(a, b, c) -> float:
|
||||
return 0.5 * float(np.linalg.norm(np.cross(b - a, c - a)))
|
||||
|
||||
|
||||
def wedge_geometry(j1: dict, j2: dict, slope: dict, upper: dict, H: float) -> dict | None:
|
||||
"""사면체 쐐기 — 꼭짓점 O(두 절리 · 사면 = 사면 끝) · A(1 · 사면 · 윗면) · B(2 · 사면 · 윗면) ·
|
||||
C(1 · 2 · 윗면). 사면 높이 H = 사면 위 꼭짓점 A · B 중 높은 쪽의 O 위 높이(SWedge 와 같음 —
|
||||
Priest 예제 부피 45.201 m³ 재현). 교선이 사면 · 윗면으로 안 나오면 None(쐐기 안 생김)."""
|
||||
n1 = plane_normal(j1["dip_direction"], j1["dip"])
|
||||
n2 = plane_normal(j2["dip_direction"], j2["dip"])
|
||||
nf = plane_normal(slope["dip_direction"], slope["dip"])
|
||||
nu = plane_normal(upper["dip_direction"], upper["dip"])
|
||||
# 윗면 = 사면 위로 H 오른 점을 지남(사면 경사방향 반대로 H cotψf 들어간 점)
|
||||
back = -line_vector(slope["dip_direction"], 0.0) * (H / math.tan(math.radians(slope["dip"])))
|
||||
crest = back + np.array([0.0, 0.0, H])
|
||||
du = float(nu @ crest)
|
||||
O = np.zeros(3) # noqa: E741 — 꼭짓점 이름(SWedge 그림과 같음)
|
||||
A = _meet([(n1, 0.0), (nf, 0.0), (nu, du)])
|
||||
B = _meet([(n2, 0.0), (nf, 0.0), (nu, du)])
|
||||
C = _meet([(n1, 0.0), (n2, 0.0), (nu, du)])
|
||||
if A is None or B is None or C is None:
|
||||
return None
|
||||
# C 는 사면 뒤(산 쪽) · 위 — 교선이 사면으로 나와 윗면에 닿음
|
||||
if C[2] <= 1e-9 or float(nf @ C) >= -1e-9 or A[2] <= 1e-9 or B[2] <= 1e-9:
|
||||
return None
|
||||
scale = H / max(A[2], B[2]) # 면이 모두 O 를 지나 닮은꼴 — 높이만 맞춤
|
||||
A, B, C = A * scale, B * scale, C * scale
|
||||
volume = abs(float(np.linalg.det(np.array([A - O, B - O, C - O])))) / 6.0
|
||||
if volume <= 1e-9:
|
||||
return None
|
||||
centroid = (O + A + B + C) / 4.0
|
||||
inward = [n if float(n @ centroid) > 0 else -n for n in (n1, n2)]
|
||||
return {
|
||||
"vertices": {"O": O, "A": A, "B": B, "C": C},
|
||||
"volume": volume,
|
||||
"areas": [_tri_area(O, A, C), _tri_area(O, B, C)],
|
||||
"face_area": _tri_area(O, A, B),
|
||||
"upper_area": _tri_area(A, B, C),
|
||||
"normals": inward,
|
||||
"Hw": float(C[2]),
|
||||
"line_length": float(np.linalg.norm(C)),
|
||||
}
|
||||
|
||||
|
||||
def _unit(v: np.ndarray) -> np.ndarray:
|
||||
n = float(np.linalg.norm(v))
|
||||
return v / n if n > 1e-12 else v * 0.0
|
||||
|
||||
|
||||
def _sliding(A: np.ndarray, n1: np.ndarray, n2: np.ndarray, W: np.ndarray):
|
||||
"""SWedge 미끄럼 방향 — (갈래, 방향). 갈래 = lift · 1 · 2 · 12 · stable."""
|
||||
s1 = _unit(np.cross(np.cross(n1, A), n1))
|
||||
s2 = _unit(np.cross(np.cross(n2, A), n2))
|
||||
c12 = np.cross(n1, n2)
|
||||
s12 = _unit(c12) * np.sign(float(c12 @ A))
|
||||
if A @ n1 > 0 and A @ n2 > 0:
|
||||
return "lift", _unit(A)
|
||||
if A @ n1 <= 0 and s1 @ n2 > 0:
|
||||
return "1", s1
|
||||
if A @ n2 <= 0 and s2 @ n1 > 0:
|
||||
return "2", s2
|
||||
if s1 @ n2 <= 0 and s2 @ n1 <= 0:
|
||||
return "12", s12
|
||||
return "stable", None
|
||||
|
||||
|
||||
def _normals(mode: str, F: np.ndarray, n1: np.ndarray, n2: np.ndarray) -> tuple[float, float]:
|
||||
if mode == "1":
|
||||
return -float(F @ n1), 0.0
|
||||
if mode == "2":
|
||||
return 0.0, -float(F @ n2)
|
||||
if mode == "12":
|
||||
c12, c21 = np.cross(n1, n2), np.cross(n2, n1)
|
||||
return (
|
||||
-float(np.cross(F, n2) @ c12) / float(c12 @ c12),
|
||||
-float(np.cross(F, n1) @ c21) / float(c21 @ c21),
|
||||
)
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def wedge_fs(
|
||||
*,
|
||||
j1: dict,
|
||||
j2: dict,
|
||||
slope: dict,
|
||||
upper: dict,
|
||||
H: float,
|
||||
gamma: float,
|
||||
water: dict | None = None,
|
||||
kh: float = 0.0,
|
||||
seismic_trend: float | None = None,
|
||||
external: list[dict] | None = None,
|
||||
bolt: dict | None = None,
|
||||
gamma_w: float = GAMMA_W,
|
||||
) -> dict[str, Any] | None:
|
||||
"""쐐기 안전율(SWedge 이론서 꼴) — 쐐기가 안 생기면 None.
|
||||
|
||||
j = {dip_direction, dip, c, phi} · water = {"fill": p}(중력식 U = p³γw Hw a/6) |
|
||||
{"pressure": [u1, u2]}(평균 수압 kPa) · kh = 수평(기본 교선 추향) · external = [{force, trend,
|
||||
plunge}] · bolt = {force, trend, plunge}(수동력). FS = max(지지 전 · 지지 뒤 · 들림)."""
|
||||
geo = wedge_geometry(j1, j2, slope, upper, H)
|
||||
if geo is None:
|
||||
return None
|
||||
n1, n2 = geo["normals"]
|
||||
a1, a2 = geo["areas"]
|
||||
weight = gamma * geo["volume"]
|
||||
Wv = np.array([0.0, 0.0, -weight])
|
||||
active = Wv.copy()
|
||||
water = water or {}
|
||||
if "pressure" in water:
|
||||
u1, u2 = (float(u) for u in water["pressure"])
|
||||
else:
|
||||
p = float(water.get("fill", 0.0))
|
||||
u1 = u2 = p**3 * gamma_w * geo["Hw"] / 6.0
|
||||
Uf = [u1 * a1 * n1, u2 * a2 * n2]
|
||||
active += Uf[0] + Uf[1]
|
||||
line = vector_line(np.cross(n1, n2))
|
||||
if kh:
|
||||
trend = line[0] if seismic_trend is None else seismic_trend
|
||||
active += kh * weight * line_vector(trend, 0.0)
|
||||
for item in external or []:
|
||||
active += float(item["force"]) * line_vector(item["trend"], item["plunge"])
|
||||
passive = np.zeros(3)
|
||||
if bolt and bolt.get("force"):
|
||||
passive = float(bolt["force"]) * line_vector(bolt["trend"], bolt["plunge"])
|
||||
mode, s = _sliding(active, n1, n2, Wv)
|
||||
out = {
|
||||
"volume": geo["volume"],
|
||||
"W": weight,
|
||||
"areas": [a1, a2],
|
||||
"face_area": geo["face_area"],
|
||||
"upper_area": geo["upper_area"],
|
||||
"Hw": geo["Hw"],
|
||||
"line_length": geo["line_length"],
|
||||
"line": [round(line[0], 2), round(line[1], 2)],
|
||||
"water_force": [float(np.linalg.norm(Uf[0])), float(np.linalg.norm(Uf[1]))],
|
||||
"mode": mode,
|
||||
"vertices": {k: [round(float(x), 3) for x in v] for k, v in geo["vertices"].items()},
|
||||
}
|
||||
if mode == "stable":
|
||||
return {**out, "F": math.inf, "N": [0.0, 0.0], "drive": 0.0, "resist": 0.0}
|
||||
drive = float(active @ s)
|
||||
if mode == "lift":
|
||||
fs = -float(passive @ s) / drive if drive > 0 else math.inf
|
||||
return {**out, "F": fs, "N": [0.0, 0.0], "drive": drive, "resist": -float(passive @ s)}
|
||||
joints = (j1, j2)
|
||||
|
||||
def resisting(F: np.ndarray) -> tuple[float, list[float]]:
|
||||
N = _normals(mode, F, n1, n2)
|
||||
total = 0.0
|
||||
for k, (n_k, a_k) in enumerate(zip(N, (a1, a2))):
|
||||
if str(k + 1) not in mode or n_k <= 0:
|
||||
continue # 떨어진 면 = 저항 없음(SWedge 와 같음)
|
||||
tau = float(joints[k]["c"]) + n_k / a_k * math.tan(
|
||||
math.radians(float(joints[k]["phi"]))
|
||||
)
|
||||
total += tau * a_k
|
||||
return total, [max(0.0, n) for n in N]
|
||||
|
||||
j_u, n_u = resisting(active)
|
||||
fs_u = j_u / drive if drive > 0 else math.inf
|
||||
fs, n_used, resist = fs_u, n_u, j_u
|
||||
if np.any(passive):
|
||||
j_s, n_s = resisting(active + passive)
|
||||
fs_s = (-float(passive @ s) + j_s) / drive if drive > 0 else math.inf
|
||||
fs_lift = -float(passive @ _unit(active)) / float(np.linalg.norm(active))
|
||||
if fs_s > fs:
|
||||
fs, n_used, resist = fs_s, n_s, -float(passive @ s) + j_s
|
||||
fs = max(fs, fs_lift)
|
||||
return {**out, "F": fs, "N": n_used, "drive": drive, "resist": resist}
|
||||
@@ -0,0 +1,446 @@
|
||||
"""E06 절리 정본 꼴 · 검사 · 측점 해석 묶음 (요구 345 · 351 · PLAN 536-1).
|
||||
|
||||
정본 = `E06_StabRock/joints.json` · 초기값 = `initial_snapshot/E06_StabRock/joints.json`
|
||||
(절리 없음 + 기본 설정 · 처음 GET 때 한 번). 절리군 한 줄 = 구간(from_m ~ to_m) · 경사방향 · 경사 ·
|
||||
φj · cj · 지속성(선택). 측점 = 복사본 암 측점(리핑암 · 발파암)의 깎기 쪽만.
|
||||
|
||||
사면 = 측점 frame 방향 + 깎기 쪽(경사방향 = 노면 쪽) · 경사 = 깎기(암) 경사비 · 높이 = E03 과 같은
|
||||
`side_cases` 높이 · 윗면 = 비탈 끝 바깥 원지반 경사. 물성 · 지진 · 기준안전율 = E02 지반 조건.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import math
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common_util.common_util_station_key import chainage_key
|
||||
from E01_StabRegister import E01_StabRegister_Source as src
|
||||
from E02_StabGround import E02_StabGround_Model as ground_model
|
||||
from E03_StabSection.E03_StabSection_Engine_Model import ground_from_cross, side_cases
|
||||
from E06_StabRock import E06_StabRock_Engine as engine
|
||||
from E06_StabRock.E06_StabRock_Stereonet import PROJECTIONS, stereonet
|
||||
|
||||
STAGE_DIR = "E06_StabRock"
|
||||
JOINTS_NAME = "joints.json"
|
||||
VERSION = 1
|
||||
ENGINE_FILES = ("E06_StabRock_Engine.py", "E06_StabRock_Stereonet.py", "E06_StabRock_Model.py")
|
||||
DEFAULT_GAMMA_R = 25.0 # 암 단위중량 — E02 암 물성이 없을 때(지식DB 07 Hoek & Bray 25~27)
|
||||
MIN_HEIGHT_M = 1.0 # 이보다 낮은 깎기는 암반 블록 검토 안 함
|
||||
CRACK_MODES = ("none", "critical", "depth")
|
||||
CONDITIONS = ("dry", "wet", "seismic")
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
"projection": "equal_angle",
|
||||
"lateral_planar_deg": engine.LATERAL_PLANAR_DEG,
|
||||
"lateral_toppling_deg": engine.LATERAL_TOPPLING_DEG,
|
||||
"gamma_r": None, # None = E02 측점 암 물성 γt
|
||||
"water_fill_wet": 0.5, # 우기 수위 = 비탈 높이 ½(KDS 11 70 05 표 4.3-3 암반)
|
||||
"crack": {"mode": "none", "z": None},
|
||||
"bolt": {"force": 0.0, "plunge": 15.0},
|
||||
}
|
||||
|
||||
# 칸 범위 — 화면 입력 · 서버 검사 같은 값
|
||||
LIMITS: dict[str, list[float]] = {
|
||||
"chainage_m": [0.0, 100000.0],
|
||||
"dip_direction": [0.0, 360.0],
|
||||
"dip": [0.0, 90.0],
|
||||
"phi": [0.0, 60.0],
|
||||
"c": [0.0, 1000.0],
|
||||
"persistence_m": [0.0, 1000.0],
|
||||
"lateral_deg": [5.0, 45.0],
|
||||
"gamma_r": [15.0, 35.0],
|
||||
"water_fill_wet": [0.0, 1.0],
|
||||
"crack_z": [0.1, 100.0],
|
||||
"bolt_force": [0.0, 5000.0],
|
||||
"bolt_plunge": [-30.0, 60.0],
|
||||
}
|
||||
|
||||
# 기본 절리면 값(새 줄) — 지식DB 07 표 7: cj 0~30 · φj 25~35(Barton 기본마찰각) 보수 쪽
|
||||
NEW_SET = {"phi": 30.0, "c": 0.0}
|
||||
|
||||
|
||||
def joints_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / STAGE_DIR / JOINTS_NAME
|
||||
|
||||
|
||||
def initial_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / "initial_snapshot" / STAGE_DIR / JOINTS_NAME
|
||||
|
||||
|
||||
def build_initial() -> dict[str, Any]:
|
||||
return {"version": VERSION, "sets": [], "settings": copy.deepcopy(DEFAULT_SETTINGS)}
|
||||
|
||||
|
||||
def upgrade(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""빠진 설정 칸만 기본값으로 채운 사본."""
|
||||
out = copy.deepcopy(doc)
|
||||
settings = out.setdefault("settings", {})
|
||||
for key, value in DEFAULT_SETTINGS.items():
|
||||
settings.setdefault(key, copy.deepcopy(value))
|
||||
out.setdefault("sets", [])
|
||||
return out
|
||||
|
||||
|
||||
# ---- 검사 ----------------------------------------------------------------------
|
||||
def _num(value: Any) -> bool:
|
||||
return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def _in(value: Any, key: str) -> bool:
|
||||
lo, hi = LIMITS[key]
|
||||
return _num(value) and lo <= value <= hi
|
||||
|
||||
|
||||
def check_joints(doc: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"""틀린 칸 [{path, code}] — code = range · order · name · id_dup · enum · type."""
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
def bad(path: str, code: str = "range") -> None:
|
||||
errors.append({"path": path, "code": code})
|
||||
|
||||
sets = doc.get("sets")
|
||||
if not isinstance(sets, list):
|
||||
return [{"path": "sets", "code": "type"}]
|
||||
seen: set[str] = set()
|
||||
for k, row in enumerate(sets):
|
||||
base = f"sets.{k}"
|
||||
if not isinstance(row, dict):
|
||||
bad(base, "type")
|
||||
continue
|
||||
ident = str(row.get("id") or "")
|
||||
if not ident or ident in seen:
|
||||
bad(f"{base}.id", "id_dup")
|
||||
seen.add(ident)
|
||||
if not str(row.get("name") or "").strip():
|
||||
bad(f"{base}.name", "name")
|
||||
for key, limit in (
|
||||
("from_m", "chainage_m"),
|
||||
("to_m", "chainage_m"),
|
||||
("dip_direction", "dip_direction"),
|
||||
("dip", "dip"),
|
||||
("phi", "phi"),
|
||||
("c", "c"),
|
||||
):
|
||||
if not _in(row.get(key), limit):
|
||||
bad(f"{base}.{key}")
|
||||
if _num(row.get("from_m")) and _num(row.get("to_m")) and row["to_m"] < row["from_m"]:
|
||||
bad(f"{base}.to_m", "order")
|
||||
if row.get("persistence_m") is not None and not _in(row["persistence_m"], "persistence_m"):
|
||||
bad(f"{base}.persistence_m")
|
||||
settings = doc.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
return errors + [{"path": "settings", "code": "type"}]
|
||||
if settings.get("projection") not in PROJECTIONS:
|
||||
bad("settings.projection", "enum")
|
||||
for key in ("lateral_planar_deg", "lateral_toppling_deg"):
|
||||
if not _in(settings.get(key), "lateral_deg"):
|
||||
bad(f"settings.{key}")
|
||||
if settings.get("gamma_r") is not None and not _in(settings["gamma_r"], "gamma_r"):
|
||||
bad("settings.gamma_r")
|
||||
if not _in(settings.get("water_fill_wet"), "water_fill_wet"):
|
||||
bad("settings.water_fill_wet")
|
||||
crack = settings.get("crack") if isinstance(settings.get("crack"), dict) else {}
|
||||
if crack.get("mode") not in CRACK_MODES:
|
||||
bad("settings.crack.mode", "enum")
|
||||
elif crack["mode"] == "depth" and not _in(crack.get("z"), "crack_z"):
|
||||
bad("settings.crack.z")
|
||||
bolt = settings.get("bolt") if isinstance(settings.get("bolt"), dict) else {}
|
||||
if not _in(bolt.get("force"), "bolt_force"):
|
||||
bad("settings.bolt.force")
|
||||
if not _in(bolt.get("plunge"), "bolt_plunge"):
|
||||
bad("settings.bolt.plunge")
|
||||
return errors
|
||||
|
||||
|
||||
def clean(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""정본 칸만 · 숫자는 float."""
|
||||
sets = []
|
||||
for row in doc["sets"]:
|
||||
item = {
|
||||
"id": str(row["id"]),
|
||||
"name": str(row["name"]).strip(),
|
||||
**{
|
||||
key: float(row[key])
|
||||
for key in ("from_m", "to_m", "dip_direction", "dip", "phi", "c")
|
||||
},
|
||||
"persistence_m": None
|
||||
if row.get("persistence_m") is None
|
||||
else float(row["persistence_m"]),
|
||||
}
|
||||
item["dip_direction"] %= 360.0
|
||||
sets.append(item)
|
||||
s = doc["settings"]
|
||||
crack = s["crack"]
|
||||
return {
|
||||
"version": VERSION,
|
||||
"sets": sets,
|
||||
"settings": {
|
||||
"projection": s["projection"],
|
||||
"lateral_planar_deg": float(s["lateral_planar_deg"]),
|
||||
"lateral_toppling_deg": float(s["lateral_toppling_deg"]),
|
||||
"gamma_r": None if s.get("gamma_r") is None else float(s["gamma_r"]),
|
||||
"water_fill_wet": float(s["water_fill_wet"]),
|
||||
"crack": {
|
||||
"mode": crack["mode"],
|
||||
"z": float(crack["z"]) if crack["mode"] == "depth" else None,
|
||||
},
|
||||
"bolt": {"force": float(s["bolt"]["force"]), "plunge": float(s["bolt"]["plunge"])},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---- 측점 사면 ------------------------------------------------------------------
|
||||
def rock_stations(source: Path) -> list[dict[str, Any]]:
|
||||
"""암 측점(리핑암 · 발파암)."""
|
||||
return [s for s in src.stations(source) if s.get("ground_type") in ground_model.ROCK_LABELS]
|
||||
|
||||
|
||||
def _upper_dip(ground: list, start: float, sign: float, H: float, face_dip: float) -> float:
|
||||
"""비탈 끝 바깥 max(H, 5 m) 원지반 평균 경사(오르막만 · 사면 경사 − 1° 아래)."""
|
||||
gx = np.array([p[0] for p in ground])
|
||||
gz = np.array([p[1] for p in ground])
|
||||
end = start + sign * max(H, 5.0)
|
||||
end = min(max(end, gx[0]), gx[-1])
|
||||
run = abs(end - start)
|
||||
if run < 0.5:
|
||||
return 0.0
|
||||
rise = float(np.interp(end, gx, gz) - np.interp(start, gx, gz))
|
||||
return float(min(max(0.0, math.degrees(math.atan2(rise, run))), face_dip - 1.0))
|
||||
|
||||
|
||||
def station_slopes(cross: dict, design: dict) -> list[dict[str, Any]]:
|
||||
"""측점의 깎기 쪽마다 사면 — {side, dip_direction, dip, height_m, upper_dip}."""
|
||||
ground = ground_from_cross(cross)
|
||||
line = sorted(
|
||||
(float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
|
||||
)
|
||||
ratio = design.get("cut_slope_ratio")
|
||||
if len(ground) < 2 or len(line) < 2 or not ratio:
|
||||
return []
|
||||
face_dip = math.degrees(math.atan2(1.0, float(ratio)))
|
||||
left = np.array([float(v) for v in cross["frame"]["left_xy"]])
|
||||
out = []
|
||||
for case in side_cases(design, ground, line):
|
||||
if case["role"] != "cut" or case["height_m"] < MIN_HEIGHT_M:
|
||||
continue
|
||||
side = case["side"]
|
||||
sign = 1.0 if side == "left" else -1.0
|
||||
# 비탈 끝 = 탐색 끝에서 되짚음(side_cases: reach = 비탈 끝 + sign · max(5, 2H))
|
||||
day = case["limits"][1 if sign > 0 else 0] - sign * max(5.0, 2.0 * case["height_m"])
|
||||
face = -sign * left # 노면 쪽으로 기움
|
||||
dip_dir = math.degrees(math.atan2(face[0], face[1])) % 360.0
|
||||
out.append(
|
||||
{
|
||||
"side": side,
|
||||
"dip_direction": round(dip_dir, 2),
|
||||
"dip": round(face_dip, 2),
|
||||
"height_m": round(case["height_m"], 3),
|
||||
"upper_dip": round(_upper_dip(ground, day, sign, case["height_m"], face_dip), 2),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ---- 해석 ----------------------------------------------------------------------
|
||||
@lru_cache(maxsize=1)
|
||||
def engine_hash() -> str:
|
||||
digest = hashlib.sha1()
|
||||
for name in ENGINE_FILES:
|
||||
digest.update((Path(__file__).parent / name).read_bytes())
|
||||
return digest.hexdigest()[:12]
|
||||
|
||||
|
||||
def _ground_inputs(ground: dict[str, Any]) -> dict[str, Any]:
|
||||
"""E02 에서 E06 이 쓰는 칸 — 지문에도 이것만."""
|
||||
return {
|
||||
k: ground.get(k)
|
||||
for k in ("materials", "label_map", "stations", "zones", "safety_factors", "seismic")
|
||||
}
|
||||
|
||||
|
||||
def _gamma_for(resolved: dict | None, materials: dict, override: float | None) -> float:
|
||||
if override:
|
||||
return float(override)
|
||||
mat = materials.get((resolved or {}).get("lower") or (resolved or {}).get("upper"))
|
||||
return float(mat["gamma_t"]) if mat else DEFAULT_GAMMA_R
|
||||
|
||||
|
||||
def _fmt(value: float | None) -> float | None:
|
||||
if value is None or not math.isfinite(value):
|
||||
return None
|
||||
return round(value, 4)
|
||||
|
||||
|
||||
def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma: float, conds):
|
||||
"""위험 모드만 안전율 — 조건 = [(이름, 수위 비, kh)]."""
|
||||
upper = {"dip_direction": slope["dip_direction"], "dip": slope["upper_dip"]}
|
||||
by_id = {j["id"]: j for j in joints}
|
||||
bolt = settings["bolt"]
|
||||
crack = settings["crack"] if settings["crack"]["mode"] != "none" else None
|
||||
out = []
|
||||
for jid in kin["planar"]:
|
||||
j = by_id[jid]
|
||||
for name, fill, kh in conds:
|
||||
r = engine.plane_fs(
|
||||
H=slope["height_m"],
|
||||
slope_dip=slope["dip"],
|
||||
plane_dip=j["dip"],
|
||||
c=j["c"],
|
||||
phi=j["phi"],
|
||||
gamma=gamma,
|
||||
upper_dip=slope["upper_dip"],
|
||||
crack=crack,
|
||||
water_fill=fill,
|
||||
kh=kh,
|
||||
bolt_t=bolt["force"],
|
||||
bolt_plunge=bolt["plunge"],
|
||||
)
|
||||
short = (
|
||||
r is not None and j.get("persistence_m") is not None and j["persistence_m"] < r["A"]
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"mode": "planar",
|
||||
"ids": [jid],
|
||||
"condition": name,
|
||||
"F": _fmt(r["F"]) if r else None,
|
||||
"formed": r is not None,
|
||||
"persistence_short": short,
|
||||
"W": _fmt(r["W"]) if r else None,
|
||||
"A": _fmt(r["A"]) if r else None,
|
||||
"z": _fmt(r["z"]) if r else None,
|
||||
}
|
||||
)
|
||||
for item in kin["wedge"]:
|
||||
a, b = (by_id[k] for k in item["ids"])
|
||||
for name, fill, kh in conds:
|
||||
r = engine.wedge_fs(
|
||||
j1=a,
|
||||
j2=b,
|
||||
slope=slope,
|
||||
upper=upper,
|
||||
H=slope["height_m"],
|
||||
gamma=gamma,
|
||||
water={"fill": fill},
|
||||
kh=kh,
|
||||
bolt={
|
||||
"force": bolt["force"],
|
||||
"trend": (slope["dip_direction"] + 180.0) % 360.0,
|
||||
"plunge": bolt["plunge"],
|
||||
},
|
||||
)
|
||||
length = r["line_length"] if r else None
|
||||
short = any(
|
||||
j.get("persistence_m") is not None
|
||||
and length is not None
|
||||
and j["persistence_m"] < length
|
||||
for j in (a, b)
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"mode": "wedge",
|
||||
"ids": list(item["ids"]),
|
||||
"condition": name,
|
||||
"F": _fmt(r["F"]) if r else None,
|
||||
"formed": r is not None,
|
||||
"persistence_short": short,
|
||||
"sliding": r["mode"] if r else None,
|
||||
"W": _fmt(r["W"]) if r else None,
|
||||
"volume": _fmt(r["volume"]) if r else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def analyze(source: Path, joints_doc: dict, ground: dict) -> list[dict[str, Any]]:
|
||||
"""암 측점 × 깎기 쪽 — 사면 · 절리 · Markland · 안전율 · 판정."""
|
||||
settings = joints_doc["settings"]
|
||||
sets = joints_doc["sets"]
|
||||
materials = {m["id"]: m for m in ground.get("materials") or []}
|
||||
resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)}
|
||||
seismic = ground.get("seismic") or {}
|
||||
factors = ground.get("safety_factors") or {}
|
||||
conds = [("dry", 0.0, 0.0), ("wet", float(settings["water_fill_wet"]), 0.0)]
|
||||
if seismic.get("enabled") and seismic.get("kh"):
|
||||
conds.append(("seismic", 0.0, float(seismic["kh"])))
|
||||
out = []
|
||||
for station in rock_stations(source):
|
||||
ch = float(station["chainage_m"])
|
||||
key = chainage_key(ch)
|
||||
joints = [j for j in sets if j["from_m"] <= ch <= j["to_m"]]
|
||||
cross = src.cross_section(source, ch)
|
||||
gamma = _gamma_for(resolved.get(key), materials, settings.get("gamma_r"))
|
||||
for slope in station_slopes(cross, cross["design"]):
|
||||
kin = engine.kinematic(
|
||||
slope, joints, settings["lateral_planar_deg"], settings["lateral_toppling_deg"]
|
||||
)
|
||||
cases = _cases_for(slope, joints, kin, settings, gamma, conds)
|
||||
for case in cases:
|
||||
need = (factors.get(case["condition"]) or {}).get("cut")
|
||||
case["required"] = need
|
||||
case["ok"] = None if case["F"] is None or need is None else case["F"] >= need
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"chainage_m": ch,
|
||||
"label": station.get("label"),
|
||||
"ground_type": station.get("ground_type"),
|
||||
"slope": slope,
|
||||
"gamma_r": gamma,
|
||||
"joints": [
|
||||
{k: j[k] for k in ("id", "name", "dip_direction", "dip", "phi", "c")}
|
||||
for j in joints
|
||||
],
|
||||
"kinematic": kin,
|
||||
"cases": cases,
|
||||
**_verdict(kin, cases),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _verdict(kin: dict, cases: list[dict]) -> dict[str, Any]:
|
||||
"""판정 — NG(기준 미달 하나라도) · check(전도만 · 블록 안 생김) · OK · none(위험 모드 없음)."""
|
||||
values = [c["F"] for c in cases if c["F"] is not None]
|
||||
min_f = min(values) if values else None
|
||||
if any(c["ok"] is False for c in cases):
|
||||
verdict = "NG"
|
||||
elif kin["toppling"] or (cases and not values):
|
||||
verdict = "check"
|
||||
elif cases:
|
||||
verdict = "OK"
|
||||
else:
|
||||
verdict = "none"
|
||||
return {
|
||||
"counts": {
|
||||
"planar": len(kin["planar"]),
|
||||
"wedge": len(kin["wedge"]),
|
||||
"toppling": len(kin["toppling"]),
|
||||
},
|
||||
"min_F": min_f,
|
||||
"verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
def station_net(entry: dict, settings: dict) -> dict[str, Any]:
|
||||
"""한 측점 쪽 스테레오넷(해석 때 절리 그대로) — 두 투영 모두(화면이 고름)."""
|
||||
joints = entry["joints"]
|
||||
phi = min((j["phi"] for j in joints), default=NEW_SET["phi"])
|
||||
nets = {
|
||||
kind: stereonet(
|
||||
entry["slope"],
|
||||
joints,
|
||||
phi,
|
||||
kind,
|
||||
settings["lateral_planar_deg"],
|
||||
settings["lateral_toppling_deg"],
|
||||
)
|
||||
for kind in PROJECTIONS
|
||||
}
|
||||
return {**nets, "phi": phi}
|
||||
@@ -0,0 +1,204 @@
|
||||
"""E06 암반 평사투영 해석 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1).
|
||||
|
||||
- `GET /api/projects/{id}/stab/rock` — 절리 작업본(없으면 초기값) · 범위 · 암 측점 · 고칠 수 있는지
|
||||
- `PUT …/stab/rock` — [저장] 검사 뒤 작업본 `E06_StabRock/joints.json`
|
||||
- `POST …/stab/rock/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님)
|
||||
- `POST …/stab/rock/analyze` — [해석] 암 측점 × 깎기 쪽 · 지문이 같으면 안 셈(`fresh`)
|
||||
- `GET …/stab/rock/result` — 측점별 모드 · 안전율 · 판정(+ 지문이 지금과 다른지)
|
||||
- `GET …/stab/rock/result?station=&side=` — 그 측점 쪽 + 스테레오넷(등각 · 등면적)
|
||||
|
||||
입력 = E01 복사본 + E02 지반 조건(물성 · 지진 · 기준안전율) + 절리 작업본(저장 안 한 캐시는 안 씀).
|
||||
계산 자리 = ③ 서버 단독. 결과 = 파생 저장본 `_derived/stab_rock/<지문>`.
|
||||
공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_derived_store import (
|
||||
derived_dir,
|
||||
derived_key,
|
||||
read_derived,
|
||||
write_derived,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from E01_StabRegister import E01_StabRegister_Source as src
|
||||
from E02_StabGround import E02_StabGround_Router as e02
|
||||
from E06_StabRock import E06_StabRock_Model as model
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["E06 Stab Rock"])
|
||||
|
||||
KIND = "stab_rock"
|
||||
|
||||
|
||||
class JointsRequest(BaseModel):
|
||||
joints: dict[str, Any]
|
||||
|
||||
|
||||
def ensure_initial(project_root: Path) -> dict[str, Any]:
|
||||
"""초기값이 없을 때만 한 번 — 절리 없음 + 기본 설정."""
|
||||
path = model.initial_path(project_root)
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
doc = model.build_initial()
|
||||
atomic_write_json(path, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def _working(project_root: Path) -> dict[str, Any] | None:
|
||||
path = model.joints_path(project_root)
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
|
||||
|
||||
|
||||
def _joints(project_root: Path) -> dict[str, Any]:
|
||||
return model.upgrade(_working(project_root) or ensure_initial(project_root))
|
||||
|
||||
|
||||
def _source(project_root: Path) -> Path:
|
||||
root = src.source_dir(project_root)
|
||||
if not (root / src.MANIFEST_NAME).is_file():
|
||||
raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.")
|
||||
return root
|
||||
|
||||
|
||||
def _fingerprint(project_root: Path, source: Path) -> tuple[str, dict, dict]:
|
||||
joints = _joints(project_root)
|
||||
ground = e02._read_working(project_root) or e02.ensure_initial(project_root)
|
||||
fp = derived_key(
|
||||
model.engine_hash(),
|
||||
src.manifest(source)["source_fingerprint"],
|
||||
{
|
||||
"sets": joints["sets"],
|
||||
"settings": {k: v for k, v in joints["settings"].items() if k != "projection"},
|
||||
},
|
||||
model._ground_inputs(ground),
|
||||
)
|
||||
return fp, joints, ground
|
||||
|
||||
|
||||
def _latest(project_root: Path) -> dict[str, Any] | None:
|
||||
"""가장 최근 저장본(지문이 달라도 — 화면이 「다시 해석」 띠를 띄움)."""
|
||||
folder = derived_dir(project_root, KIND)
|
||||
files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime_ns, reverse=True)
|
||||
for path in files:
|
||||
saved = read_derived(project_root, KIND, path.stem)
|
||||
if saved:
|
||||
return saved
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/rock")
|
||||
async def get_rock(
|
||||
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
root, project = await e02._project(project_id)
|
||||
initial = ensure_initial(root)
|
||||
working = _working(root)
|
||||
try:
|
||||
stations = [
|
||||
{"chainage_m": s["chainage_m"], "label": s["label"], "ground_type": s["ground_type"]}
|
||||
for s in model.rock_stations(_source(root))
|
||||
]
|
||||
except HTTPException:
|
||||
stations = []
|
||||
return {
|
||||
"joints": model.upgrade(working or initial),
|
||||
"saved": working is not None,
|
||||
"defaults": {
|
||||
"settings": model.DEFAULT_SETTINGS,
|
||||
"new_set": model.NEW_SET,
|
||||
"limits": model.LIMITS,
|
||||
},
|
||||
"stations": stations,
|
||||
"editable": e02._editable(session, project),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{project_id}/stab/rock")
|
||||
async def put_rock(project_id: UUID, payload: JointsRequest) -> dict[str, Any]:
|
||||
root, _ = await e02._project(project_id)
|
||||
errors = model.check_joints(payload.joints)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail={"errors": errors})
|
||||
doc = model.clean(payload.joints)
|
||||
atomic_write_json(model.joints_path(root), doc)
|
||||
return {"joints": doc}
|
||||
|
||||
|
||||
@router.post("/{project_id}/stab/rock/reset")
|
||||
async def reset_rock(project_id: UUID) -> dict[str, Any]:
|
||||
root, _ = await e02._project(project_id)
|
||||
initial = ensure_initial(root)
|
||||
atomic_write_json(model.joints_path(root), initial)
|
||||
return {"joints": model.upgrade(initial)}
|
||||
|
||||
|
||||
def _summary(stations: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
verdicts: dict[str, int] = {}
|
||||
for item in stations:
|
||||
verdicts[item["verdict"]] = verdicts.get(item["verdict"], 0) + 1
|
||||
return {
|
||||
"slopes": len(stations),
|
||||
"verdicts": verdicts,
|
||||
"modes": {
|
||||
mode: sum(item["counts"][mode] for item in stations)
|
||||
for mode in ("planar", "wedge", "toppling")
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _run(root: Path) -> dict[str, Any]:
|
||||
source = _source(root)
|
||||
fp, joints, ground = _fingerprint(root, source)
|
||||
if read_derived(root, KIND, fp):
|
||||
return {"status": "fresh", "fingerprint": fp}
|
||||
stations = model.analyze(source, joints, ground)
|
||||
value = {
|
||||
"fingerprint": fp,
|
||||
"settings": joints["settings"],
|
||||
"summary": _summary(stations),
|
||||
"stations": stations,
|
||||
}
|
||||
write_derived(root, KIND, fp, value)
|
||||
return {"status": "done", "fingerprint": fp, "summary": value["summary"]}
|
||||
|
||||
|
||||
@router.post("/{project_id}/stab/rock/analyze")
|
||||
async def analyze_rock(project_id: UUID) -> dict[str, Any]:
|
||||
"""[해석] — 암 측점 수십 개 · 1 초 안쪽이라 바로 셈(진행률 없음)."""
|
||||
root, _ = await e02._project(project_id)
|
||||
return await asyncio.to_thread(_run, root)
|
||||
|
||||
|
||||
def _result(root: Path, station: str | None, side: str | None) -> dict[str, Any]:
|
||||
source = _source(root)
|
||||
fp, _, _ = _fingerprint(root, source)
|
||||
saved = read_derived(root, KIND, fp) or _latest(root)
|
||||
if not saved:
|
||||
return {"status": "empty", "stale": False, "result": None}
|
||||
value = saved["value"]
|
||||
stale = value["fingerprint"] != fp
|
||||
if station is None:
|
||||
return {"status": "success", "stale": stale, "result": value}
|
||||
for entry in value["stations"]:
|
||||
if entry["key"] == station and (side is None or entry["slope"]["side"] == side):
|
||||
net = model.station_net(entry, value["settings"])
|
||||
return {"status": "success", "stale": stale, "entry": entry, "stereonet": net}
|
||||
raise HTTPException(status_code=404, detail="그 측점의 암반 해석 결과가 없습니다.")
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/rock/result")
|
||||
async def result_rock(
|
||||
project_id: UUID, station: str | None = None, side: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
root, _ = await e02._project(project_id)
|
||||
return await asyncio.to_thread(_result, root, station, side)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""E06 스테레오넷 투영 좌표 — 하반구 · 등각(Wulff) · 등면적(Schmidt) (PLAN 536-1).
|
||||
|
||||
화면(SVG)은 이 좌표를 그리기만 함 — 반지름 1 원 · x = 동 · y = 북(화면은 y 를 뒤집음).
|
||||
극점 · 대원 · 교선 · 마찰원 · 일광 엔벨로프 · 위험 영역(Markland 부등식과 같은 경계 —
|
||||
`E06_StabRock_Engine.kinematic`)을 냄. 투영식 = 표준 구면 투영(mplstereonet 등은 대조만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from E06_StabRock.E06_StabRock_Engine import (
|
||||
LATERAL_PLANAR_DEG,
|
||||
LATERAL_TOPPLING_DEG,
|
||||
apparent_dip,
|
||||
intersection,
|
||||
pole,
|
||||
)
|
||||
|
||||
PROJECTIONS = ("equal_angle", "equal_area")
|
||||
STEP_DEG = 2.0 # 곡선 표본 간격
|
||||
|
||||
|
||||
def radius(plunge: float, kind: str) -> float:
|
||||
"""선주각 → 중심에서 거리(0 = 연직 · 1 = 수평)."""
|
||||
half = math.radians(90.0 - plunge) / 2.0
|
||||
return math.tan(half) if kind == "equal_angle" else math.sqrt(2.0) * math.sin(half)
|
||||
|
||||
|
||||
def project(trend: float, plunge: float, kind: str) -> list[float]:
|
||||
r = radius(plunge, kind)
|
||||
t = math.radians(trend)
|
||||
return [round(r * math.sin(t), 5), round(r * math.cos(t), 5)]
|
||||
|
||||
|
||||
def inverse(x: float, y: float, kind: str) -> tuple[float, float]:
|
||||
"""투영 좌표 → (추향 · 선주각) — 왕복 시험 · 화면 마우스 자리 읽기."""
|
||||
r = math.hypot(x, y)
|
||||
half = math.atan(r) if kind == "equal_angle" else math.asin(min(1.0, r / math.sqrt(2.0)))
|
||||
return math.degrees(math.atan2(x, y)) % 360.0, 90.0 - math.degrees(2.0 * half)
|
||||
|
||||
|
||||
def _samples(lo: float, hi: float) -> list[float]:
|
||||
n = max(2, int(math.ceil((hi - lo) / STEP_DEG)) + 1)
|
||||
return [lo + (hi - lo) * k / (n - 1) for k in range(n)]
|
||||
|
||||
|
||||
def great_circle(dip_dir: float, dip: float, kind: str) -> list[list[float]]:
|
||||
"""면의 대원 — 주향 한끝 → 경사 방향 → 다른 끝."""
|
||||
return [
|
||||
project(dip_dir + b, max(0.0, apparent_dip(dip, dip_dir, dip_dir + b)), kind)
|
||||
for b in _samples(-90.0, 90.0)
|
||||
]
|
||||
|
||||
|
||||
def _zone(trend_lo, trend_hi, plunge_lo, plunge_hi, kind) -> list[list[float]] | None:
|
||||
"""(추향 · 선주각) 띠 → 닫힌 다각형 — plunge_lo/hi 는 추향의 함수 · 비면 None."""
|
||||
ts = _samples(trend_lo, trend_hi)
|
||||
lows = [plunge_lo(t) for t in ts]
|
||||
highs = [plunge_hi(t) for t in ts]
|
||||
keep = [k for k in range(len(ts)) if highs[k] > lows[k]]
|
||||
if len(keep) < 2:
|
||||
return None
|
||||
outer = [project(ts[k], lows[k], kind) for k in keep]
|
||||
inner = [project(ts[k], highs[k], kind) for k in reversed(keep)]
|
||||
return outer + inner
|
||||
|
||||
|
||||
def stereonet(
|
||||
slope: dict,
|
||||
joints: list[dict],
|
||||
phi: float,
|
||||
kind: str = "equal_angle",
|
||||
lateral_planar: float = LATERAL_PLANAR_DEG,
|
||||
lateral_toppling: float = LATERAL_TOPPLING_DEG,
|
||||
) -> dict[str, Any]:
|
||||
"""slope = {dip_direction, dip} · joints = [{id, dip_direction, dip}] · phi = 영역 마찰각."""
|
||||
af, pf = float(slope["dip_direction"]), float(slope["dip"])
|
||||
|
||||
def face_app(t: float) -> float:
|
||||
return apparent_dip(pf, af, t)
|
||||
|
||||
planes = []
|
||||
for j in joints:
|
||||
tr, pl = pole(j["dip_direction"], j["dip"])
|
||||
planes.append(
|
||||
{
|
||||
"id": j["id"],
|
||||
"pole": project(tr, pl, kind),
|
||||
"great_circle": great_circle(j["dip_direction"], j["dip"], kind),
|
||||
}
|
||||
)
|
||||
lines = []
|
||||
for i, a in enumerate(joints):
|
||||
for b in joints[i + 1 :]:
|
||||
line = intersection(a, b)
|
||||
if line is not None:
|
||||
lines.append({"ids": [a["id"], b["id"]], "point": project(*line, kind)})
|
||||
# 일광 엔벨로프(극점 꼴) = 사면 겉보기 경사로 기운 면들의 극점
|
||||
envelope = [
|
||||
project(af + b + 180.0, 90.0 - face_app(af + b), kind) for b in _samples(-90.0, 90.0)
|
||||
]
|
||||
zones = {
|
||||
# 평면(극점) — 극점 추향 αf + 180 ± 측방 · 선주각 90 − ψapp ~ 90 − φ
|
||||
"planar": _zone(
|
||||
af + 180.0 - lateral_planar,
|
||||
af + 180.0 + lateral_planar,
|
||||
lambda t: 90.0 - face_app(t - 180.0),
|
||||
lambda t: 90.0 - phi,
|
||||
kind,
|
||||
),
|
||||
# 쐐기(교선) — 추향 αf ± 90 · 선주각 φ ~ ψapp
|
||||
"wedge": _zone(af - 90.0, af + 90.0, lambda t: phi, face_app, kind),
|
||||
# 전도(극점) — 극점 추향 αf ± 측방 · 선주각 0 ~ ψf − φ
|
||||
"toppling": _zone(
|
||||
af - lateral_toppling,
|
||||
af + lateral_toppling,
|
||||
lambda t: 0.0,
|
||||
lambda t: pf - phi,
|
||||
kind,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"projection": kind,
|
||||
"slope": {
|
||||
"pole": project(*pole(af, pf), kind),
|
||||
"great_circle": great_circle(af, pf, kind),
|
||||
},
|
||||
"planes": planes,
|
||||
"intersections": lines,
|
||||
# 마찰원 — 극점 꼴 = 중심에서 φ(선주각 90 − φ) · 교선 꼴 = 선주각 φ
|
||||
"friction_pole_r": round(radius(90.0 - phi, kind), 5),
|
||||
"friction_line_r": round(radius(phi, kind), 5),
|
||||
"daylight_envelope": envelope,
|
||||
"zones": zones,
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Joints.ts
|
||||
* E06 좌측 — 절리군 표(구간 · 경사방향 · 경사 · φj · cj · 지속성 · [추가] [지움]) + 해석 설정
|
||||
* (측방 한계 · 암 단위중량 · 우기 수위 · 인장균열 · 볼트). 칸을 바꾸면 doc 을 고치고 onChange —
|
||||
* 캐시 · 저장은 페이지 몫. 범위 = 서버 LIMITS(벗어나면 칸 빨강 · 마우스 글).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
|
||||
export interface JointSet {
|
||||
id: string;
|
||||
name: string;
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
dip_direction: number;
|
||||
dip: number;
|
||||
phi: number;
|
||||
c: number;
|
||||
persistence_m: number | null;
|
||||
}
|
||||
|
||||
export type Projection = "equal_angle" | "equal_area";
|
||||
|
||||
export interface RockSettings {
|
||||
projection: Projection;
|
||||
lateral_planar_deg: number;
|
||||
lateral_toppling_deg: number;
|
||||
gamma_r: number | null;
|
||||
water_fill_wet: number;
|
||||
crack: { mode: "none" | "critical" | "depth"; z: number | null };
|
||||
bolt: { force: number; plunge: number };
|
||||
}
|
||||
|
||||
export interface JointsDoc {
|
||||
version: number;
|
||||
sets: JointSet[];
|
||||
settings: RockSettings;
|
||||
}
|
||||
|
||||
export interface RockDefaults {
|
||||
settings: RockSettings;
|
||||
new_set: { phi: number; c: number };
|
||||
limits: Record<string, [number, number]>;
|
||||
}
|
||||
|
||||
export interface JointsView {
|
||||
doc: JointsDoc;
|
||||
defaults: RockDefaults;
|
||||
editable: boolean;
|
||||
endM: number; // 마지막 암 측점(새 줄 구간 끝)
|
||||
change: () => void; // 값만 바뀜
|
||||
rebuild: () => void; // 줄 수가 바뀜
|
||||
}
|
||||
|
||||
/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */
|
||||
function numberInput(
|
||||
view: JointsView,
|
||||
value: number | null,
|
||||
limit: string,
|
||||
set: (v: number | null) => void,
|
||||
opts: { optional?: boolean; step?: string; path: string },
|
||||
): HTMLInputElement {
|
||||
const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity];
|
||||
const input = el("input", {
|
||||
className: "e06-input",
|
||||
attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path },
|
||||
}) as HTMLInputElement;
|
||||
input.value = value === null ? "" : String(value);
|
||||
input.disabled = !view.editable;
|
||||
const check = (): boolean => {
|
||||
const raw = input.value.trim();
|
||||
const num = Number(raw);
|
||||
const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi;
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`;
|
||||
return ok;
|
||||
};
|
||||
input.addEventListener("input", () => {
|
||||
const raw = input.value.trim();
|
||||
set(raw === "" ? null : Number(raw));
|
||||
check();
|
||||
view.change();
|
||||
});
|
||||
check();
|
||||
return input;
|
||||
}
|
||||
|
||||
function nextId(sets: JointSet[]): string {
|
||||
let k = sets.length + 1;
|
||||
while (sets.some((s) => s.id === `J${k}`)) k += 1;
|
||||
return `J${k}`;
|
||||
}
|
||||
|
||||
const COLUMNS: LocaleKey[] = [
|
||||
"E06_Col_Name",
|
||||
"E06_Col_From",
|
||||
"E06_Col_To",
|
||||
"E06_Col_DipDir",
|
||||
"E06_Col_Dip",
|
||||
"E06_Col_Phi",
|
||||
"E06_Col_C",
|
||||
"E06_Col_Persist",
|
||||
];
|
||||
|
||||
function jointTable(view: JointsView): HTMLElement {
|
||||
const wrap = el("div", { className: "e06-table-wrap" });
|
||||
const table = el("table", { className: "e06-table" });
|
||||
const head = el("tr");
|
||||
for (const key of COLUMNS) head.append(el("th", { text: t(key) }));
|
||||
head.append(el("th"));
|
||||
table.append(el("thead"), el("tbody"));
|
||||
table.tHead!.append(head);
|
||||
view.doc.sets.forEach((row, k) => {
|
||||
const tr = el("tr", { attrs: { "data-id": row.id } });
|
||||
const name = el("input", {
|
||||
className: "e06-input e06-input--name",
|
||||
attrs: { type: "text", "data-path": `sets.${k}.name` },
|
||||
}) as HTMLInputElement;
|
||||
name.value = row.name;
|
||||
name.disabled = !view.editable;
|
||||
name.addEventListener("input", () => {
|
||||
row.name = name.value;
|
||||
name.classList.toggle("is-invalid", !name.value.trim());
|
||||
view.change();
|
||||
});
|
||||
tr.append(el("td", { children: [name] }));
|
||||
const fields: [keyof JointSet, string, boolean][] = [
|
||||
["from_m", "chainage_m", false],
|
||||
["to_m", "chainage_m", false],
|
||||
["dip_direction", "dip_direction", false],
|
||||
["dip", "dip", false],
|
||||
["phi", "phi", false],
|
||||
["c", "c", false],
|
||||
["persistence_m", "persistence_m", true],
|
||||
];
|
||||
for (const [key, limit, optional] of fields) {
|
||||
const input = numberInput(
|
||||
view,
|
||||
row[key] as number | null,
|
||||
limit,
|
||||
(v) => ((row as unknown as Record<string, number | null>)[key] = v),
|
||||
{ optional, path: `sets.${k}.${key}` },
|
||||
);
|
||||
tr.append(el("td", { children: [input] }));
|
||||
}
|
||||
const remove = createButton({
|
||||
label: "×",
|
||||
variant: "danger-ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
view.doc.sets.splice(k, 1);
|
||||
view.rebuild();
|
||||
},
|
||||
});
|
||||
remove.title = t("E06_Remove");
|
||||
remove.disabled = !view.editable;
|
||||
tr.append(el("td", { children: [remove] }));
|
||||
table.tBodies[0].append(tr);
|
||||
});
|
||||
wrap.append(table);
|
||||
if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") }));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement {
|
||||
const row = el("label", { className: "e06-setting" });
|
||||
row.append(el("span", { text: t(label) }), control);
|
||||
return row;
|
||||
}
|
||||
|
||||
function select<T extends string>(
|
||||
view: JointsView,
|
||||
value: T,
|
||||
options: [T, LocaleKey][],
|
||||
set: (v: T) => void,
|
||||
path: string,
|
||||
): HTMLSelectElement {
|
||||
const box = el("select", {
|
||||
className: "e06-input",
|
||||
attrs: { "data-path": path },
|
||||
}) as HTMLSelectElement;
|
||||
for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } }));
|
||||
box.value = value;
|
||||
box.disabled = !view.editable;
|
||||
box.addEventListener("change", () => {
|
||||
set(box.value as T);
|
||||
view.rebuild();
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
export function buildJointCards(view: JointsView): HTMLElement[] {
|
||||
const s = view.doc.settings;
|
||||
const setsCard = createPanelCard(t("E06_Card_Joints"), false);
|
||||
const add = createButton({
|
||||
label: t("E06_Add"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const id = nextId(view.doc.sets);
|
||||
view.doc.sets.push({
|
||||
id,
|
||||
name: id,
|
||||
from_m: 0,
|
||||
to_m: Math.ceil(view.endM),
|
||||
dip_direction: 0,
|
||||
dip: 45,
|
||||
phi: view.defaults.new_set.phi,
|
||||
c: view.defaults.new_set.c,
|
||||
persistence_m: null,
|
||||
});
|
||||
view.rebuild();
|
||||
},
|
||||
});
|
||||
add.disabled = !view.editable;
|
||||
setsCard.append(
|
||||
el("p", { className: "e06-hint", text: t("E06_JointsHint") }),
|
||||
jointTable(view),
|
||||
el("div", { className: "e06-row-actions", children: [add] }),
|
||||
);
|
||||
|
||||
const setCard = createPanelCard(t("E06_Card_Settings"), true);
|
||||
const crack = s.crack;
|
||||
setCard.append(
|
||||
settingRow(
|
||||
"E06_Set_LateralPlanar",
|
||||
numberInput(
|
||||
view,
|
||||
s.lateral_planar_deg,
|
||||
"lateral_deg",
|
||||
(v) => (s.lateral_planar_deg = v ?? NaN),
|
||||
{
|
||||
path: "settings.lateral_planar_deg",
|
||||
},
|
||||
),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_LateralTopple",
|
||||
numberInput(
|
||||
view,
|
||||
s.lateral_toppling_deg,
|
||||
"lateral_deg",
|
||||
(v) => (s.lateral_toppling_deg = v ?? NaN),
|
||||
{ path: "settings.lateral_toppling_deg" },
|
||||
),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_Gamma",
|
||||
numberInput(view, s.gamma_r, "gamma_r", (v) => (s.gamma_r = v), {
|
||||
optional: true,
|
||||
path: "settings.gamma_r",
|
||||
}),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_WaterWet",
|
||||
numberInput(view, s.water_fill_wet, "water_fill_wet", (v) => (s.water_fill_wet = v ?? NaN), {
|
||||
step: "0.05",
|
||||
path: "settings.water_fill_wet",
|
||||
}),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_Crack",
|
||||
select(
|
||||
view,
|
||||
crack.mode,
|
||||
[
|
||||
["none", "E06_Crack_None"],
|
||||
["critical", "E06_Crack_Critical"],
|
||||
["depth", "E06_Crack_Depth"],
|
||||
],
|
||||
(v) => {
|
||||
crack.mode = v;
|
||||
crack.z = v === "depth" ? (crack.z ?? 1) : null;
|
||||
},
|
||||
"settings.crack.mode",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (crack.mode === "depth")
|
||||
setCard.append(
|
||||
settingRow(
|
||||
"E06_Set_CrackZ",
|
||||
numberInput(view, crack.z, "crack_z", (v) => (crack.z = v), { path: "settings.crack.z" }),
|
||||
),
|
||||
);
|
||||
setCard.append(
|
||||
settingRow(
|
||||
"E06_Set_BoltForce",
|
||||
numberInput(view, s.bolt.force, "bolt_force", (v) => (s.bolt.force = v ?? NaN), {
|
||||
path: "settings.bolt.force",
|
||||
}),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_BoltPlunge",
|
||||
numberInput(view, s.bolt.plunge, "bolt_plunge", (v) => (s.bolt.plunge = v ?? NaN), {
|
||||
path: "settings.bolt.plunge",
|
||||
}),
|
||||
),
|
||||
el("p", { className: "e06-hint", text: t("E06_SettingsHint") }),
|
||||
);
|
||||
return [setsCard, setCard];
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Page.ts
|
||||
* E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow).
|
||||
* 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석]
|
||||
* 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도)
|
||||
* 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표
|
||||
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset ·
|
||||
* [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./E06_StabRock_UI_Style.css";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import {
|
||||
buildJointCards,
|
||||
type JointsDoc,
|
||||
type JointsView,
|
||||
type Projection,
|
||||
type RockDefaults,
|
||||
} from "./E06_StabRock_UI_Joints";
|
||||
import {
|
||||
buildBand,
|
||||
buildDetail,
|
||||
buildList,
|
||||
entryId,
|
||||
type RockResult,
|
||||
type StationEntry,
|
||||
} from "./E06_StabRock_UI_Result";
|
||||
import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet";
|
||||
|
||||
interface RockResponse {
|
||||
joints: JointsDoc;
|
||||
saved?: boolean;
|
||||
defaults?: RockDefaults;
|
||||
stations?: { chainage_m: number; label: string; ground_type: string }[];
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
interface ResultResponse {
|
||||
status: string;
|
||||
stale: boolean;
|
||||
result: RockResult | null;
|
||||
}
|
||||
|
||||
interface StationResponse {
|
||||
entry: StationEntry;
|
||||
stereonet: Record<Projection, NetData> & { phi: number };
|
||||
}
|
||||
|
||||
const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`;
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
|
||||
if (!response.ok) {
|
||||
const detail = body.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
const errors = (detail as { errors?: unknown[] } | undefined)?.errors;
|
||||
throw new Error(errors ? `${t("E06_Invalid")} ${errors.length}` : `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function toggle<T extends string>(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) {
|
||||
const group = el("div", { className: "e06-toggle" });
|
||||
const buttons = options.map(([key, v]) => {
|
||||
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
|
||||
button.dataset.value = v;
|
||||
button.classList.toggle("is-on", v === value);
|
||||
button.addEventListener("click", () => {
|
||||
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
|
||||
onPick(v);
|
||||
});
|
||||
return button;
|
||||
});
|
||||
group.append(...buttons);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function renderE06StabRock(root: HTMLElement): void {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const panel = el("div", { className: "ui-sidebar-panel e06-panel" });
|
||||
const main = el("div", { className: "e06-page" });
|
||||
mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel });
|
||||
if (!projectId)
|
||||
return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") }));
|
||||
|
||||
// ---- 좌 ----
|
||||
const cards = el("div", { className: "e06-cards" });
|
||||
const status = el("p", { className: "e06-status" });
|
||||
const saveButton = createButton({
|
||||
label: t("Common_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: () => void save(),
|
||||
});
|
||||
const resetButton = createButton({
|
||||
label: t("Common_Btn_Reset"),
|
||||
variant: "danger-ghost",
|
||||
onClick: () => void reset(),
|
||||
});
|
||||
const analyzeButton = createButton({
|
||||
label: t("E06_Analyze"),
|
||||
variant: "filled",
|
||||
onClick: () => void analyze(),
|
||||
});
|
||||
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true;
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(resetButton, saveButton, analyzeButton);
|
||||
panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions);
|
||||
attachCollapsible(cards);
|
||||
|
||||
// ---- 가운데 · 우 ----
|
||||
const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") });
|
||||
staleBar.hidden = true;
|
||||
const bandSlot = el("div");
|
||||
const title = el("strong", { className: "e06-title" });
|
||||
const netSlot = el("div", { className: "e06-net-slot" });
|
||||
const legendSlot = el("div");
|
||||
let projection: Projection = "equal_angle";
|
||||
let netMode: NetMode = "all";
|
||||
const toolbar = el("div", { className: "e06-toolbar" });
|
||||
const projectionToggle = el("div");
|
||||
toolbar.append(
|
||||
title,
|
||||
projectionToggle,
|
||||
toggle<NetMode>(
|
||||
[
|
||||
["E06_View_All", "all"],
|
||||
["E06_Mode_planar", "planar"],
|
||||
["E06_Mode_wedge", "wedge"],
|
||||
["E06_Mode_toppling", "toppling"],
|
||||
],
|
||||
netMode,
|
||||
(v) => {
|
||||
netMode = v;
|
||||
paintNet();
|
||||
},
|
||||
),
|
||||
);
|
||||
const stage = el("div", { className: "e06-stage" });
|
||||
stage.append(netSlot, legendSlot);
|
||||
const center = el("div", { className: "e06-center" });
|
||||
center.append(staleBar, bandSlot, toolbar, stage);
|
||||
const listSlot = el("div", { className: "e06-list-slot" });
|
||||
const detailSlot = el("div");
|
||||
const sideBody = el("div", { className: "e06-side" });
|
||||
sideBody.append(listSlot, detailSlot);
|
||||
const side = createSidePanels({
|
||||
prefKey: "e06-stab-rock-result",
|
||||
panels: [{ id: "result", title: t("E06_Result_Title"), body: sideBody }],
|
||||
defaultOpen: "result",
|
||||
defaultWidth: 380,
|
||||
follow: false,
|
||||
});
|
||||
const row = el("div", { className: "ui-side-panels-row e06-row" });
|
||||
row.append(center, side.root);
|
||||
main.append(row);
|
||||
|
||||
// ---- 상태 ----
|
||||
let view: JointsView | null = null;
|
||||
let dirty = false;
|
||||
let result: RockResult | null = null;
|
||||
let active: string | null = null;
|
||||
let shown: StationResponse | null = null;
|
||||
|
||||
const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length;
|
||||
const paintStatus = (): void => {
|
||||
const lines: string[] = [];
|
||||
if (view && !view.editable) lines.push(t("E06_ReadOnly"));
|
||||
if (dirty) lines.push(t("E06_Unsaved"));
|
||||
const bad = invalidCount();
|
||||
if (bad) lines.push(`${t("E06_Invalid")} ${bad}`);
|
||||
status.textContent = lines.join("\n");
|
||||
status.classList.toggle("is-error", bad > 0);
|
||||
};
|
||||
|
||||
const renderCards = (): void => {
|
||||
if (!view) return;
|
||||
// 다시 그려도 접힘은 그대로
|
||||
const folded = [...cards.children].map((c) => c.classList.contains("is-collapsed"));
|
||||
const built = buildJointCards(view);
|
||||
built.forEach(
|
||||
(c, i) => folded[i] !== undefined && c.classList.toggle("is-collapsed", folded[i]),
|
||||
);
|
||||
cards.replaceChildren(...built);
|
||||
paintStatus();
|
||||
};
|
||||
|
||||
const remember = (): void => {
|
||||
if (!view) return;
|
||||
dirty = true;
|
||||
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc));
|
||||
};
|
||||
|
||||
function paintProjection(): void {
|
||||
projectionToggle.replaceChildren(
|
||||
toggle<Projection>(
|
||||
[
|
||||
["E06_Proj_Angle", "equal_angle"],
|
||||
["E06_Proj_Area", "equal_area"],
|
||||
],
|
||||
projection,
|
||||
(v) => {
|
||||
projection = v; // 보기 고름 — 정본 아님(해석 지문에도 안 들어감)
|
||||
paintNet();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function paintNet(): void {
|
||||
legendSlot.replaceChildren(netLegend(netMode));
|
||||
if (!shown) {
|
||||
title.textContent = "";
|
||||
netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") }));
|
||||
return;
|
||||
}
|
||||
const e = shown.entry;
|
||||
title.textContent = `${e.label ?? e.key} · ${t(e.slope.side === "left" ? "E06_Left" : "E06_Right")} · φ ${shown.stereonet.phi}°`;
|
||||
netSlot.replaceChildren(
|
||||
drawStereonet(shown.stereonet[projection], netMode, {
|
||||
planar: e.kinematic.planar,
|
||||
toppling: e.kinematic.toppling,
|
||||
wedge: e.kinematic.wedge.map((w) => w.ids.join("|")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function paintResult(): void {
|
||||
const stations = result?.stations ?? [];
|
||||
bandSlot.replaceChildren(buildBand(stations, active, (id) => void select(id)));
|
||||
listSlot.replaceChildren(
|
||||
stations.length
|
||||
? buildList(stations, active, (id) => void select(id))
|
||||
: el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }),
|
||||
);
|
||||
detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span"));
|
||||
paintNet();
|
||||
}
|
||||
|
||||
async function select(id: string): Promise<void> {
|
||||
active = id;
|
||||
const [key, sideName] = id.split("|");
|
||||
try {
|
||||
shown = await api<StationResponse>(
|
||||
`/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`,
|
||||
);
|
||||
} catch (error) {
|
||||
shown = null;
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
}
|
||||
paintResult();
|
||||
}
|
||||
|
||||
async function loadResult(): Promise<void> {
|
||||
const body = await api<ResultResponse>(`/projects/${projectId}/stab/rock/result`);
|
||||
result = body.result;
|
||||
staleBar.hidden = !body.stale;
|
||||
const stations = result?.stations ?? [];
|
||||
const keep = stations.find((e) => entryId(e) === active);
|
||||
const first = keep ?? stations.find((e) => e.verdict === "NG") ?? stations[0];
|
||||
if (first) await select(entryId(first));
|
||||
else {
|
||||
active = null;
|
||||
shown = null;
|
||||
paintResult();
|
||||
}
|
||||
}
|
||||
|
||||
const adopt = (body: RockResponse, keepCache: boolean): void => {
|
||||
if (!view) return;
|
||||
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
|
||||
if (!cached) sessionStorage.removeItem(cacheKey(projectId));
|
||||
view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints;
|
||||
dirty = Boolean(cached);
|
||||
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable;
|
||||
renderCards();
|
||||
};
|
||||
|
||||
async function putJoints(): Promise<boolean> {
|
||||
if (!view?.editable) return false;
|
||||
if (invalidCount()) {
|
||||
showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error");
|
||||
return false;
|
||||
}
|
||||
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ joints: view.doc }),
|
||||
});
|
||||
adopt(body, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
if (await putJoints()) {
|
||||
showToast(t("E06_Saved"), "success");
|
||||
await loadResult();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
if (!view?.editable) return;
|
||||
const ok = await showConfirmDialog(
|
||||
t("E06_ResetConfirm"),
|
||||
t("Common_Btn_Reset"),
|
||||
t("Common_Btn_Cancel"),
|
||||
);
|
||||
if (!ok) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock/reset`, {
|
||||
method: "POST",
|
||||
});
|
||||
adopt(body, false);
|
||||
showToast(t("E06_ResetDone"), "success");
|
||||
await loadResult();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function analyze(): Promise<void> {
|
||||
if (!view?.editable) return;
|
||||
analyzeButton.disabled = true;
|
||||
status.textContent = t("E06_Running");
|
||||
try {
|
||||
if (dirty && !(await putJoints())) return;
|
||||
const body = await api<{ status: string; summary?: RockResult["summary"] }>(
|
||||
`/projects/${projectId}/stab/rock/analyze`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
await loadResult();
|
||||
showToast(t(body.status === "fresh" ? "E06_Fresh" : "E06_Done"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
analyzeButton.disabled = !view.editable;
|
||||
paintStatus();
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`);
|
||||
const stations = body.stations ?? [];
|
||||
projection = body.joints.settings.projection;
|
||||
paintProjection();
|
||||
view = {
|
||||
doc: body.joints,
|
||||
defaults: body.defaults as RockDefaults,
|
||||
editable: body.editable ?? false,
|
||||
endM: stations.length ? stations[stations.length - 1].chainage_m : 0,
|
||||
change: () => {
|
||||
remember();
|
||||
paintStatus();
|
||||
},
|
||||
rebuild: () => {
|
||||
remember();
|
||||
renderCards();
|
||||
},
|
||||
};
|
||||
adopt(body, true);
|
||||
await loadResult();
|
||||
} catch (error) {
|
||||
main.replaceChildren(
|
||||
el("p", {
|
||||
className: "e06-empty",
|
||||
text: error instanceof Error ? error.message : t("Common_Status_Error"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Result.ts
|
||||
* E06 결과 — 위험 띠(누가 축 · 측점 판정 색 · 누르면 그 측점) · 우측 측점 목록(쪽 · 모드 수 ·
|
||||
* 최소 안전율 · 판정) · 고른 측점 사면 · 모드별 안전율 표. 값은 서버 저장본 그대로(계산 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
|
||||
export type Verdict = "NG" | "check" | "OK" | "none";
|
||||
|
||||
export interface RockCase {
|
||||
mode: "planar" | "wedge";
|
||||
ids: string[];
|
||||
condition: "dry" | "wet" | "seismic";
|
||||
F: number | null;
|
||||
formed: boolean;
|
||||
persistence_short: boolean;
|
||||
required: number | null;
|
||||
ok: boolean | null;
|
||||
sliding?: string | null;
|
||||
}
|
||||
|
||||
export interface StationEntry {
|
||||
key: string;
|
||||
chainage_m: number;
|
||||
label: string | null;
|
||||
ground_type: string;
|
||||
slope: { side: string; dip_direction: number; dip: number; height_m: number; upper_dip: number };
|
||||
gamma_r: number;
|
||||
joints: {
|
||||
id: string;
|
||||
name: string;
|
||||
dip_direction: number;
|
||||
dip: number;
|
||||
phi: number;
|
||||
c: number;
|
||||
}[];
|
||||
kinematic: {
|
||||
planar: string[];
|
||||
toppling: string[];
|
||||
wedge: { ids: [string, string]; trend: number; plunge: number }[];
|
||||
};
|
||||
cases: RockCase[];
|
||||
counts: { planar: number; wedge: number; toppling: number };
|
||||
min_F: number | null;
|
||||
verdict: Verdict;
|
||||
}
|
||||
|
||||
export interface RockResult {
|
||||
fingerprint: string;
|
||||
summary: {
|
||||
slopes: number;
|
||||
verdicts: Partial<Record<Verdict, number>>;
|
||||
modes: Record<string, number>;
|
||||
};
|
||||
stations: StationEntry[];
|
||||
}
|
||||
|
||||
const ORDER: Verdict[] = ["NG", "check", "OK", "none"];
|
||||
export const entryId = (e: StationEntry): string => `${e.key}|${e.slope.side}`;
|
||||
const fmt = (v: number | null | undefined, d = 2): string =>
|
||||
v === null || v === undefined ? "–" : v.toFixed(d);
|
||||
|
||||
function badge(verdict: Verdict): HTMLElement {
|
||||
return el("span", {
|
||||
className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`,
|
||||
text: t(`E06_V_${verdict}` as LocaleKey),
|
||||
});
|
||||
}
|
||||
|
||||
/** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */
|
||||
export function buildBand(
|
||||
stations: StationEntry[],
|
||||
active: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
): HTMLElement {
|
||||
const box = el("div", { className: "e06-band", attrs: { "data-role": "band" } });
|
||||
const worst = new Map<number, StationEntry>();
|
||||
for (const e of stations) {
|
||||
const prev = worst.get(e.chainage_m);
|
||||
if (!prev || ORDER.indexOf(e.verdict) < ORDER.indexOf(prev.verdict)) worst.set(e.chainage_m, e);
|
||||
}
|
||||
const list = [...worst.values()].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const legend = el("div", { className: "e06-band__legend" });
|
||||
legend.append(el("strong", { text: t("E06_Band") }));
|
||||
for (const v of ORDER) {
|
||||
const n = list.filter((e) => e.verdict === v).length;
|
||||
if (n) legend.append(badge(v), el("span", { className: "e06-band__count", text: String(n) }));
|
||||
}
|
||||
const strip = el("div", { className: "e06-band__strip" });
|
||||
if (list.length) {
|
||||
const lo = list[0].chainage_m;
|
||||
const hi = Math.max(list[list.length - 1].chainage_m, lo + 1);
|
||||
list.forEach((e, i) => {
|
||||
const a = i ? (list[i - 1].chainage_m + e.chainage_m) / 2 : lo;
|
||||
const b = i < list.length - 1 ? (e.chainage_m + list[i + 1].chainage_m) / 2 : hi;
|
||||
const seg = el("button", {
|
||||
className: `e06-band__seg e06-band__seg--${e.verdict.toLowerCase()}${
|
||||
active?.startsWith(`${e.key}|`) ? " is-active" : ""
|
||||
}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-key": e.key,
|
||||
title: `${e.label ?? e.key} · ${t(`E06_V_${e.verdict}` as LocaleKey)}`,
|
||||
},
|
||||
});
|
||||
seg.style.left = `${((a - lo) / (hi - lo)) * 100}%`;
|
||||
seg.style.width = `${Math.max(0.4, ((b - a) / (hi - lo)) * 100)}%`;
|
||||
seg.addEventListener("click", () => onSelect(entryId(e)));
|
||||
strip.append(seg);
|
||||
});
|
||||
const ticks = el("div", { className: "e06-band__ticks" });
|
||||
ticks.append(
|
||||
el("span", { text: `${lo.toFixed(0)} m` }),
|
||||
el("span", { text: `${hi.toFixed(0)} m` }),
|
||||
);
|
||||
box.append(legend, strip, ticks);
|
||||
} else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") }));
|
||||
return box;
|
||||
}
|
||||
|
||||
export function buildList(
|
||||
stations: StationEntry[],
|
||||
active: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
): HTMLElement {
|
||||
const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } });
|
||||
const head = el("tr");
|
||||
for (const key of [
|
||||
"E06_L_Station",
|
||||
"E06_L_Side",
|
||||
"E06_L_Modes",
|
||||
"E06_L_MinF",
|
||||
"E06_L_Verdict",
|
||||
] as LocaleKey[])
|
||||
head.append(el("th", { text: t(key) }));
|
||||
table.append(el("thead", { children: [head] }), el("tbody"));
|
||||
for (const e of stations) {
|
||||
const id = entryId(e);
|
||||
const tr = el("tr", {
|
||||
className: id === active ? "is-active" : "",
|
||||
attrs: { "data-id": id, "data-verdict": e.verdict },
|
||||
});
|
||||
tr.append(
|
||||
el("td", { text: e.label ?? e.key }),
|
||||
el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }),
|
||||
el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }),
|
||||
el("td", { text: fmt(e.min_F) }),
|
||||
el("td", { children: [badge(e.verdict)] }),
|
||||
);
|
||||
tr.addEventListener("click", () => onSelect(id));
|
||||
table.tBodies[0].append(tr);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
export function buildDetail(e: StationEntry): HTMLElement {
|
||||
const box = el("div", { className: "e06-detail", attrs: { "data-role": "detail" } });
|
||||
const s = e.slope;
|
||||
box.append(
|
||||
el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }),
|
||||
el("p", {
|
||||
className: "e06-hint",
|
||||
text: t("E06_SlopeLine")
|
||||
.replace("{dd}", s.dip_direction.toFixed(0))
|
||||
.replace("{dip}", s.dip.toFixed(1))
|
||||
.replace("{h}", s.height_m.toFixed(2))
|
||||
.replace("{up}", s.upper_dip.toFixed(1))
|
||||
.replace("{g}", e.gamma_r.toFixed(1)),
|
||||
}),
|
||||
);
|
||||
const kin = e.kinematic;
|
||||
const kinText = [
|
||||
`${t("E06_Mode_planar")} ${kin.planar.join(", ") || "–"}`,
|
||||
`${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`,
|
||||
`${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`,
|
||||
];
|
||||
box.append(el("p", { className: "e06-kin", text: kinText.join("\n") }));
|
||||
if (!e.cases.length) {
|
||||
box.append(
|
||||
el("p", {
|
||||
className: "e06-hint",
|
||||
text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"),
|
||||
}),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
const table = el("table", { className: "e06-cases" });
|
||||
const head = el("tr");
|
||||
for (const key of [
|
||||
"E06_C_Mode",
|
||||
"E06_C_Joints",
|
||||
"E06_C_Cond",
|
||||
"E06_C_F",
|
||||
"E06_C_Req",
|
||||
"E06_L_Verdict",
|
||||
] as LocaleKey[])
|
||||
head.append(el("th", { text: t(key) }));
|
||||
table.append(el("thead", { children: [head] }), el("tbody"));
|
||||
for (const c of e.cases) {
|
||||
const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK";
|
||||
const note = !c.formed ? t("E06_NotFormed") : c.persistence_short ? t("E06_Persist") : "";
|
||||
const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } });
|
||||
tr.append(
|
||||
el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }),
|
||||
el("td", { text: c.ids.join("·") }),
|
||||
el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }),
|
||||
el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }),
|
||||
el("td", { text: fmt(c.required) }),
|
||||
el("td", { children: [badge(verdict)] }),
|
||||
);
|
||||
table.tBodies[0].append(tr);
|
||||
}
|
||||
box.append(table);
|
||||
if (kin.toppling.length)
|
||||
box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") }));
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Stereonet.ts
|
||||
* E06 가운데 스테레오넷(SVG · 하반구) — 서버가 내려 준 투영 좌표(반지름 1 · x = 동 · y = 북)를
|
||||
* 그리기만 함(계산 = E06_StabRock_Stereonet.py). 보기 = 전체 · 평면 · 쐐기 · 전도.
|
||||
* 평면 · 전도 = 극점 + 극점 마찰원(φ) · 쐐기 = 교선 + 교선 마찰원 · 위험 영역 = 모드 색 ·
|
||||
* 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const SIZE = 440;
|
||||
const R = 190;
|
||||
const C = SIZE / 2;
|
||||
|
||||
export type NetMode = "all" | "planar" | "wedge" | "toppling";
|
||||
type XY = [number, number];
|
||||
|
||||
export interface NetData {
|
||||
projection: string;
|
||||
slope: { pole: XY; great_circle: XY[] };
|
||||
planes: { id: string; pole: XY; great_circle: XY[] }[];
|
||||
intersections: { ids: [string, string]; point: XY }[];
|
||||
friction_pole_r: number;
|
||||
friction_line_r: number;
|
||||
daylight_envelope: XY[];
|
||||
zones: Record<"planar" | "wedge" | "toppling", XY[] | null>;
|
||||
}
|
||||
|
||||
export interface NetMarks {
|
||||
planar: string[];
|
||||
toppling: string[];
|
||||
wedge: string[]; // "J1|J2"
|
||||
}
|
||||
|
||||
function svg<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number> = {},
|
||||
text?: string,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const node = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
const sx = (p: XY): number => C + R * p[0];
|
||||
const sy = (p: XY): number => C - R * p[1];
|
||||
const path = (pts: XY[], close = false): string =>
|
||||
pts.map((p, i) => `${i ? "L" : "M"}${sx(p).toFixed(1)},${sy(p).toFixed(1)}`).join("") +
|
||||
(close ? "Z" : "");
|
||||
|
||||
/** 절리 색 번호(0~5) — 표 · 그림 같은 차례 */
|
||||
export const jointTone = (index: number): number => index % 6;
|
||||
|
||||
export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVGSVGElement {
|
||||
const root = svg("svg", {
|
||||
viewBox: `0 0 ${SIZE} ${SIZE}`,
|
||||
class: "e06-net",
|
||||
"data-role": "stereonet",
|
||||
"data-projection": net.projection,
|
||||
"data-mode": mode,
|
||||
});
|
||||
const clip = svg("clipPath", { id: "e06-net-clip" });
|
||||
clip.append(svg("circle", { cx: C, cy: C, r: R }));
|
||||
const defs = svg("defs");
|
||||
defs.append(clip);
|
||||
root.append(defs, svg("circle", { cx: C, cy: C, r: R, class: "e06-net__rim" }));
|
||||
const body = svg("g", { "clip-path": "url(#e06-net-clip)" });
|
||||
root.append(body);
|
||||
|
||||
const show = (m: Exclude<NetMode, "all">): boolean => mode === "all" || mode === m;
|
||||
for (const m of ["planar", "wedge", "toppling"] as const) {
|
||||
const zone = net.zones[m];
|
||||
if (zone && show(m))
|
||||
body.append(
|
||||
svg("path", {
|
||||
d: path(zone, true),
|
||||
class: `e06-net__zone e06-net__zone--${m}`,
|
||||
"data-zone": m,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 마찰원
|
||||
if (show("planar") || show("toppling"))
|
||||
body.append(
|
||||
svg("circle", {
|
||||
cx: C,
|
||||
cy: C,
|
||||
r: R * net.friction_pole_r,
|
||||
class: "e06-net__friction",
|
||||
"data-role": "friction-pole",
|
||||
}),
|
||||
);
|
||||
if (show("wedge"))
|
||||
body.append(
|
||||
svg("circle", {
|
||||
cx: C,
|
||||
cy: C,
|
||||
r: R * net.friction_line_r,
|
||||
class: "e06-net__friction e06-net__friction--line",
|
||||
"data-role": "friction-line",
|
||||
}),
|
||||
);
|
||||
if (show("planar"))
|
||||
body.append(
|
||||
svg("path", {
|
||||
d: path(net.daylight_envelope),
|
||||
class: "e06-net__envelope",
|
||||
"data-role": "daylight",
|
||||
}),
|
||||
);
|
||||
// 대원
|
||||
net.planes.forEach((p, i) =>
|
||||
body.append(
|
||||
svg("path", {
|
||||
d: path(p.great_circle),
|
||||
class: `e06-net__gc e06-tone-${jointTone(i)}`,
|
||||
"data-id": p.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
body.append(
|
||||
svg("path", { d: path(net.slope.great_circle), class: "e06-net__slope", "data-role": "slope" }),
|
||||
);
|
||||
|
||||
// 극점(평면 · 전도 보기) · 교선(쐐기 보기)
|
||||
if (mode !== "wedge")
|
||||
net.planes.forEach((p, i) => {
|
||||
const risky = marks.planar.includes(p.id) || marks.toppling.includes(p.id);
|
||||
const g = svg("g", {
|
||||
class: `e06-net__pole e06-tone-${jointTone(i)}${risky ? " is-risk" : ""}`,
|
||||
"data-pole": p.id,
|
||||
});
|
||||
g.append(svg("circle", { cx: sx(p.pole), cy: sy(p.pole), r: 5 }));
|
||||
g.append(svg("text", { x: sx(p.pole) + 7, y: sy(p.pole) - 6 }, p.id));
|
||||
root.append(g);
|
||||
});
|
||||
if (mode === "all" || mode === "wedge")
|
||||
for (const line of net.intersections) {
|
||||
const key = line.ids.join("|");
|
||||
const risky = marks.wedge.includes(key);
|
||||
const [x, y] = [sx(line.point), sy(line.point)];
|
||||
const g = svg("g", { class: `e06-net__line${risky ? " is-risk" : ""}`, "data-line": key });
|
||||
g.append(svg("path", { d: `M${x},${y - 6}L${x + 5.5},${y + 4}L${x - 5.5},${y + 4}Z` }));
|
||||
g.append(svg("text", { x: x + 7, y: y + 12 }, line.ids.join("·")));
|
||||
root.append(g);
|
||||
}
|
||||
// 사면 극점 · 방위
|
||||
root.append(
|
||||
svg("rect", {
|
||||
x: sx(net.slope.pole) - 4,
|
||||
y: sy(net.slope.pole) - 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
class: "e06-net__slope-pole",
|
||||
}),
|
||||
);
|
||||
for (const [label, x, y] of [
|
||||
["N", C, C - R - 8],
|
||||
["E", C + R + 10, C + 4],
|
||||
["S", C, C + R + 18],
|
||||
["W", C - R - 10, C + 4],
|
||||
] as const)
|
||||
root.append(svg("text", { x, y, class: "e06-net__dir", "text-anchor": "middle" }, label));
|
||||
root.append(svg("line", { x1: C - 6, y1: C, x2: C + 6, y2: C, class: "e06-net__tick" }));
|
||||
root.append(svg("line", { x1: C, y1: C - 6, x2: C, y2: C + 6, class: "e06-net__tick" }));
|
||||
return root;
|
||||
}
|
||||
|
||||
export function netLegend(mode: NetMode): HTMLElement {
|
||||
const box = el("div", { className: "e06-legend" });
|
||||
const items: [string, LocaleKey][] = [
|
||||
["slope", "E06_Lg_Slope"],
|
||||
["friction", "E06_Lg_Friction"],
|
||||
];
|
||||
if (mode === "all" || mode === "planar")
|
||||
items.push(["planar", "E06_Lg_Planar"], ["daylight", "E06_Lg_Daylight"]);
|
||||
if (mode === "all" || mode === "wedge")
|
||||
items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]);
|
||||
if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]);
|
||||
if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]);
|
||||
for (const [cls, key] of items) {
|
||||
const row = el("span", { className: "e06-legend__row" });
|
||||
row.append(
|
||||
el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }),
|
||||
el("span", { text: t(key) }),
|
||||
);
|
||||
box.append(row);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Style.css
|
||||
* E06 암반 해석 — 좌측 절리 표 · 설정 / 가운데 위험 띠 · 스테레오넷 / 우측 측점 결과
|
||||
* 색은 테마 토큰(밝음 · 어두움 같이) · 판정 · 모드 · 절리 색만 고정색(E03 판정색과 같음)
|
||||
* ========================================================================== */
|
||||
|
||||
.e06-page {
|
||||
--e06-ng: #d03b3b;
|
||||
--e06-ok: #0ca30c;
|
||||
--e06-check: #e0a106;
|
||||
--e06-none: #9a9a95;
|
||||
--e06-planar: #d03b3b;
|
||||
--e06-wedge: #f1963b;
|
||||
--e06-toppling: #9b6dde;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.e06-row {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.e06-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.e06-empty,
|
||||
.e06-hint,
|
||||
.e06-status,
|
||||
.e06-kin {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.e06-empty {
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.e06-status.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.e06-kin {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-panel .ui-sidebar-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.e06-stale {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-stale[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- 좌측 표 · 설정 ---- */
|
||||
.e06-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.e06-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.e06-table th {
|
||||
padding: 2px 3px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e06-table td {
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
.e06-input {
|
||||
width: 4.2em;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.e06-input--name {
|
||||
width: 3.6em;
|
||||
}
|
||||
|
||||
select.e06-input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.e06-input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface));
|
||||
}
|
||||
|
||||
.e06-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.e06-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
margin: 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-setting .e06-input {
|
||||
width: 5.5em;
|
||||
}
|
||||
|
||||
/* ---- 가운데 ---- */
|
||||
.e06-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.e06-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-toggle {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.e06-toggle .ui-btn.is-on {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.e06-stage {
|
||||
display: flex;
|
||||
gap: var(--spacing-12);
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--spacing-8);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.e06-net-slot {
|
||||
flex: 1 1 360px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.e06-net {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.e06-net__rim {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-text);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.e06-net__zone {
|
||||
stroke: none;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.e06-net__zone--planar {
|
||||
fill: var(--e06-planar);
|
||||
}
|
||||
|
||||
.e06-net__zone--wedge {
|
||||
fill: var(--e06-wedge);
|
||||
}
|
||||
|
||||
.e06-net__zone--toppling {
|
||||
fill: var(--e06-toppling);
|
||||
}
|
||||
|
||||
.e06-net__friction {
|
||||
fill: none;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
|
||||
.e06-net__friction--line {
|
||||
stroke-dasharray: 2 3;
|
||||
}
|
||||
|
||||
.e06-net__envelope {
|
||||
fill: none;
|
||||
stroke: var(--e06-planar);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 6 3;
|
||||
}
|
||||
|
||||
.e06-net__gc {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.e06-net__slope {
|
||||
fill: none;
|
||||
stroke: var(--color-text);
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
|
||||
.e06-net__slope-pole {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-net__pole circle {
|
||||
fill: currentColor;
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.e06-net__pole.is-risk circle,
|
||||
.e06-net__line.is-risk path {
|
||||
stroke: var(--e06-ng);
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
.e06-net__pole text,
|
||||
.e06-net__line text,
|
||||
.e06-net__dir {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-net__line path {
|
||||
fill: var(--e06-wedge);
|
||||
stroke: var(--color-surface);
|
||||
}
|
||||
|
||||
.e06-net__tick {
|
||||
stroke: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e06-tone-0 {
|
||||
color: #2f6fd6;
|
||||
}
|
||||
.e06-tone-1 {
|
||||
color: #1f9d6a;
|
||||
}
|
||||
.e06-tone-2 {
|
||||
color: #c2410c;
|
||||
}
|
||||
.e06-tone-3 {
|
||||
color: #7c4dcc;
|
||||
}
|
||||
.e06-tone-4 {
|
||||
color: #b8860b;
|
||||
}
|
||||
.e06-tone-5 {
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.e06-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-legend__row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.e06-legend__mark {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.e06-legend__mark--slope {
|
||||
height: 3px;
|
||||
background: var(--color-text);
|
||||
}
|
||||
.e06-legend__mark--friction {
|
||||
height: 0;
|
||||
border-top: 1px dashed var(--color-text-secondary);
|
||||
}
|
||||
.e06-legend__mark--daylight {
|
||||
height: 0;
|
||||
border-top: 1px dashed var(--e06-planar);
|
||||
}
|
||||
.e06-legend__mark--planar {
|
||||
background: color-mix(in srgb, var(--e06-planar) 35%, transparent);
|
||||
}
|
||||
.e06-legend__mark--wedge {
|
||||
background: color-mix(in srgb, var(--e06-wedge) 35%, transparent);
|
||||
}
|
||||
.e06-legend__mark--toppling {
|
||||
background: color-mix(in srgb, var(--e06-toppling) 35%, transparent);
|
||||
}
|
||||
.e06-legend__mark--line {
|
||||
width: 10px;
|
||||
background: var(--e06-wedge);
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
.e06-legend__mark--pole {
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
background: #2f6fd6;
|
||||
}
|
||||
|
||||
/* ---- 위험 띠 ---- */
|
||||
.e06-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.e06-band__legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-band__count {
|
||||
margin-right: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e06-band__strip {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.e06-band__seg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e06-band__seg--ng {
|
||||
background: var(--e06-ng);
|
||||
}
|
||||
.e06-band__seg--ok {
|
||||
background: var(--e06-ok);
|
||||
}
|
||||
.e06-band__seg--check {
|
||||
background: var(--e06-check);
|
||||
}
|
||||
.e06-band__seg--none {
|
||||
background: var(--e06-none);
|
||||
}
|
||||
|
||||
.e06-band__seg.is-active {
|
||||
outline: 2px solid var(--color-text);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.e06-band__ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ---- 우측 ---- */
|
||||
.e06-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.e06-list-slot {
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.e06-list,
|
||||
.e06-cases {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-list th,
|
||||
.e06-cases th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 3px 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.e06-list td,
|
||||
.e06-cases td {
|
||||
padding: 3px 4px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.e06-list tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e06-list tbody tr:hover {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.e06-list tr.is-active {
|
||||
background: color-mix(in srgb, var(--color-primary) 16%, transparent);
|
||||
}
|
||||
|
||||
.e06-detail h4 {
|
||||
margin: 0 0 4px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-verdict {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e06-verdict::before {
|
||||
content: "";
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--e06-none);
|
||||
}
|
||||
|
||||
.e06-verdict--ng::before {
|
||||
background: var(--e06-ng);
|
||||
border-radius: 2px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.e06-verdict--ok::before {
|
||||
background: var(--e06-ok);
|
||||
}
|
||||
.e06-verdict--check::before {
|
||||
background: var(--e06-check);
|
||||
border-radius: 0;
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_Route.ts
|
||||
* 길 선언(PLAN 536-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E06 암반 평사투영 페이지
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.E06_STAB_ROCK,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () => (await import("../E06_StabRock_UI_Page")).renderE06StabRock,
|
||||
};
|
||||
@@ -20,7 +20,13 @@ from config.config_db import get_db_pool
|
||||
|
||||
KIND_STAGE_DIRS = {
|
||||
"PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"),
|
||||
"STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"),
|
||||
"STABILITY": (
|
||||
"E02_StabGround",
|
||||
"E03_StabSection",
|
||||
"E04_Stab3D",
|
||||
"E05_StabReport",
|
||||
"E06_StabRock",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ STAGE_KEYS = [
|
||||
]
|
||||
|
||||
|
||||
# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05.
|
||||
# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E06.
|
||||
STAGE_KEYS_BY_KIND = {
|
||||
"ROAD": STAGE_KEYS,
|
||||
"PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"],
|
||||
"STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"],
|
||||
"STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT", "STAB_ROCK"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ export const ROUTES = {
|
||||
E03_STAB_SECTION: "e03-stab-section",
|
||||
E04_STAB_3D: "e04-stab-3d",
|
||||
E05_STAB_REPORT: "e05-stab-report",
|
||||
E06_STAB_ROCK: "e06-stab-rock",
|
||||
} as const;
|
||||
|
||||
/** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */
|
||||
@@ -139,12 +140,13 @@ export const PLAN_STEP_ROUTES = [
|
||||
ROUTES.D04_PLAN_CONFIRM,
|
||||
] as const;
|
||||
|
||||
/** E 안정성평가 단계 주소(E02 → E05 · 생성 E01 은 단계 밖) */
|
||||
/** E 안정성평가 단계 주소(E02 → E06 · 생성 E01 은 단계 밖) */
|
||||
export const STAB_STEP_ROUTES = [
|
||||
ROUTES.E02_STAB_GROUND,
|
||||
ROUTES.E03_STAB_SECTION,
|
||||
ROUTES.E04_STAB_3D,
|
||||
ROUTES.E05_STAB_REPORT,
|
||||
ROUTES.E06_STAB_ROCK,
|
||||
] as const;
|
||||
|
||||
export type RouteKey = keyof typeof ROUTES;
|
||||
|
||||
@@ -220,6 +220,7 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset(
|
||||
"E03_StabSection",
|
||||
"E04_Stab3D",
|
||||
"E05_StabReport",
|
||||
"E06_StabRock",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_route
|
||||
from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router
|
||||
from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router
|
||||
from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router
|
||||
from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router
|
||||
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
|
||||
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
|
||||
@@ -494,6 +495,7 @@ app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
|
||||
app.include_router(e04_stab_3d_router, dependencies=protected_with_company)
|
||||
app.include_router(e05_stab_report_router, dependencies=protected_with_company)
|
||||
app.include_router(e03_stab_section_router, dependencies=protected_with_company)
|
||||
app.include_router(e06_stab_rock_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_corridor_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_terrain_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -257,7 +257,7 @@ def test_register_ignores_sent_company():
|
||||
assert "company_name" not in data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 4)])
|
||||
@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 5)])
|
||||
def test_stage_seed_by_kind(kind, count):
|
||||
log = []
|
||||
asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind))
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""PLAN 536-1 — E06 암반 평사투영 · Markland · Hoek-Bray 평면 · 쐐기 (요구 345 · 351).
|
||||
|
||||
기준 예제(문헌 값 · Rocscience 검증서가 옮긴 값):
|
||||
- 평면 = Hoek(Sau Mau Ping, 『Practical Rock Engineering』 7장 · RocPlane 검증 #1) 균열 없음 0.8184254 ·
|
||||
임계 균열 + 수위 0.9z 1.0654738 / Sharma 외(1995) 윗면 0~30° 무게(RocPlane 검증 #2)
|
||||
- 쐐기 = Hoek & Bray 쐐기 계수(대칭 쐐기 마찰만 · 해석해) · Kovári & Fritz · Kumsar 외(2000) 실험 ·
|
||||
Priest(1993) 두 예제(SWedge 검증 #1~#7 값)
|
||||
- Markland 경계 · 투영 왕복 · 위험 영역 = 판정 부등식
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from E06_StabRock import E06_StabRock_Engine as eng
|
||||
from E06_StabRock import E06_StabRock_Model as model
|
||||
from E06_StabRock import E06_StabRock_Stereonet as net
|
||||
|
||||
TOL = 0.02 # 완료 기준 ±2 %
|
||||
|
||||
|
||||
def close(value, expect, tol=TOL):
|
||||
assert value == pytest.approx(expect, rel=tol), (value, expect)
|
||||
|
||||
|
||||
# ---- 평면 (Hoek-Bray) -------------------------------------------------------------
|
||||
SAU_MAU = dict(
|
||||
H=60, slope_dip=50, plane_dip=35, c=100, phi=35, gamma=27, kh=0.08, gamma_w=10
|
||||
) # MN → kN · 0.1 MPa = 100 kPa
|
||||
|
||||
|
||||
def test_plane_hoek_no_crack():
|
||||
r = eng.plane_fs(**SAU_MAU, water_fill=1.0)
|
||||
close(r["F"], 0.8184254, 1e-4)
|
||||
close(r["W"], 28627.8, 1e-4)
|
||||
close(r["A"], 104.6068, 1e-5)
|
||||
close(r["U"], 15691.0, 1e-4)
|
||||
|
||||
|
||||
def test_plane_hoek_tension_crack():
|
||||
z = eng.plane_critical_depth_hoek(60, 50, 35)
|
||||
close(z, 14.0092, 1e-5)
|
||||
r = eng.plane_fs(**SAU_MAU, water_fill=0.9, crack={"mode": "depth", "z": z})
|
||||
close(r["F"], 1.0654738, 1e-4)
|
||||
close(r["W"], 24843.9, 1e-4)
|
||||
close(r["A"], 80.1826, 1e-5)
|
||||
close(r["crack_b"], 15.3357, 1e-4) # 마루 뒤 거리(RocPlane 검증 #1)
|
||||
|
||||
|
||||
def test_plane_critical_crack_search_matches_hoek():
|
||||
"""건조 · 윗면 수평 — 수치 탐색 임계 자리 = Hoek-Bray 식 z · b."""
|
||||
r = eng.plane_fs(
|
||||
H=60, slope_dip=50, plane_dip=35, c=20, phi=35, gamma=27, crack={"mode": "critical"}
|
||||
)
|
||||
close(r["z"], eng.plane_critical_depth_hoek(60, 50, 35), 1e-4)
|
||||
close(r["crack_b"], 15.3357, 1e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"upper,weight,fs,fill",
|
||||
[
|
||||
(0, 2392.38, 1.58612, 1.00),
|
||||
(10, 2446.29, 1.58148, 0.84),
|
||||
(20, 2503.66, 1.58382, 0.71),
|
||||
(30, 2568.90, 1.57849, 0.61),
|
||||
],
|
||||
)
|
||||
def test_plane_upper_slope_sharma(upper, weight, fs, fill):
|
||||
"""윗면 기운 사면 · 연직 균열 마루 뒤 15.336 m · 균열 수위(RocPlane 정수 %)."""
|
||||
kw = dict(
|
||||
H=60, slope_dip=50, plane_dip=35, c=12, phi=45, gamma=2.6, gamma_w=1.0, upper_dip=upper
|
||||
)
|
||||
r = eng.plane_fs(**kw, crack={"mode": "x", "b": 15.33576}, water_fill=fill)
|
||||
close(r["W"], weight, 1e-4)
|
||||
close(r["F"], fs)
|
||||
|
||||
|
||||
def test_plane_bolt_vector():
|
||||
"""수동 볼트 — 저항 += T cos(θ+ψp) + T sin(θ+ψp) tanφ (힘 벡터 풀이)."""
|
||||
base = dict(H=10, slope_dip=60, plane_dip=35, c=10, phi=30, gamma=25)
|
||||
r0 = eng.plane_fs(**base)
|
||||
r1 = eng.plane_fs(**base, bolt_t=100, bolt_plunge=15)
|
||||
a = math.radians(50)
|
||||
expect = (
|
||||
r0["resist"] + 100 * math.cos(a) + 100 * math.sin(a) * math.tan(math.radians(30))
|
||||
) / r0["drive"]
|
||||
close(r1["F"], expect, 1e-9)
|
||||
|
||||
|
||||
def test_plane_not_formed():
|
||||
assert eng.plane_fs(H=10, slope_dip=40, plane_dip=45, c=0, phi=30, gamma=25) is None
|
||||
# 윗면이 활동면보다 가파르면 덩이 안 생김(균열 없음)
|
||||
assert (
|
||||
eng.plane_fs(H=10, slope_dip=60, plane_dip=30, c=0, phi=20, gamma=25, upper_dip=35) is None
|
||||
)
|
||||
|
||||
|
||||
# ---- 쐐기 (Hoek-Bray · SWedge 꼴) ------------------------------------------------
|
||||
FLAT = {"dip_direction": 180, "dip": 0}
|
||||
|
||||
|
||||
def _wedge(j1, j2, slope, phi, H=10, upper=FLAT, **kw):
|
||||
return eng.wedge_fs(
|
||||
j1={"dip_direction": j1[0], "dip": j1[1], "c": 0, "phi": phi},
|
||||
j2={"dip_direction": j2[0], "dip": j2[1], "c": 0, "phi": phi},
|
||||
slope={"dip_direction": slope[0], "dip": slope[1]},
|
||||
upper=upper,
|
||||
H=H,
|
||||
gamma=26,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"j1,j2", [((141, 45), (219, 45)), ((119, 50), (241, 50)), ((150, 60), (230, 55))]
|
||||
)
|
||||
def test_wedge_hoek_bray_wedge_factor(j1, j2):
|
||||
"""마찰만 · 같은 φ — Hoek & Bray FS = K tanφ / tanψi · K = sinβ / sin(ξ/2)
|
||||
(ξ = 두 면 사이 각(교선에 수직 단면) · β = 그 단면에서 쐐기 이등분선의 기울기)."""
|
||||
phi = 35
|
||||
r = _wedge(j1, j2, (180, 70), phi)
|
||||
n1 = eng.plane_normal(*j1)
|
||||
n2 = eng.plane_normal(*j2)
|
||||
i = eng.line_vector(*r["line"])
|
||||
# 교선에 수직인 면 안에서 두 면 안쪽 법선(위쪽) 사이 각 → 쐐기 사이 각 ξ = 180 − 그 각
|
||||
xi = math.pi - math.acos(float(n1 @ n2))
|
||||
bis = (n1 + n2) / np.linalg.norm(n1 + n2) # 쐐기 이등분선(교선에 수직 단면 안)
|
||||
h = np.cross(i, [0.0, 0.0, 1.0])
|
||||
h /= np.linalg.norm(h) # 그 단면의 수평
|
||||
beta = math.acos(abs(float(bis @ h)))
|
||||
K = math.sin(beta) / math.sin(xi / 2)
|
||||
expect = K * math.tan(math.radians(phi)) / math.tan(math.radians(r["line"][1]))
|
||||
close(r["F"], expect, 1e-3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"j1,j2,slope,phi,plunge,fs",
|
||||
[
|
||||
((141, 45), (219, 45), (180, 70), 35, 37.85, 1.0), # Kovári & Fritz · SWedge #1
|
||||
((119, 50), (241, 50), (180, 70), 35, 30.02, 1.6325), # SWedge #2
|
||||
((195, 45), (105, 70), (160, 70), 30, None, 0.73), # Kumsar Case 3(Ankara) · SWedge 0.7123
|
||||
((33.5, 75), (248, 75), (337.5, 75), 40.8, None, 2.02), # Kumsar Case 4(Dinar)
|
||||
],
|
||||
)
|
||||
def test_wedge_kumsar_kovari(j1, j2, slope, phi, plunge, fs):
|
||||
r = _wedge(j1, j2, slope, phi)
|
||||
assert r["mode"] == "12"
|
||||
close(r["F"], fs, 0.03 if fs == 0.73 else 0.01) # 실험값 0.73 = 판독(SWedge 0.7123)
|
||||
if plunge:
|
||||
close(r["line"][1], plunge, 1e-3)
|
||||
|
||||
|
||||
def test_wedge_kumsar_seismic():
|
||||
r = _wedge((33.5, 75), (248, 75), (337.5, 75), 40.8, kh=0.3303)
|
||||
close(r["F"], 0.99) # Kumsar 0.99 · SWedge 0.9872
|
||||
|
||||
|
||||
def test_wedge_priest_double_plane():
|
||||
"""Priest(1993) 예제 1 — 두 면 미끄럼 · 수압 5 · 15 kPa · 철탑 180 kN(168/70)."""
|
||||
r = eng.wedge_fs(
|
||||
j1={"dip_direction": 203, "dip": 47, "c": 10, "phi": 40},
|
||||
j2={"dip_direction": 287, "dip": 52, "c": 20, "phi": 35},
|
||||
slope={"dip_direction": 230, "dip": 60},
|
||||
upper={"dip_direction": 225, "dip": 5},
|
||||
H=6.7978,
|
||||
gamma=26,
|
||||
water={"pressure": [5, 15]},
|
||||
external=[{"force": 180, "trend": 168, "plunge": 70}],
|
||||
)
|
||||
assert r["mode"] == "12"
|
||||
close(r["F"], 1.4966, 1e-3) # Priest ≈ 1.5
|
||||
close(r["volume"], 45.201, 1e-3)
|
||||
close(r["areas"][0], 41.147, 1e-3)
|
||||
close(r["areas"][1], 20.428, 1e-3)
|
||||
close(r["face_area"], 38.955, 1e-3)
|
||||
close(r["N"][0], 407, 3e-3)
|
||||
close(r["N"][1], 251, 3e-3)
|
||||
|
||||
|
||||
def test_wedge_priest_single_plane():
|
||||
"""Priest(1993) 예제 2 — 면 2 하나로 미끄럼(면 1 떨어짐) · 수압 25 · 15 kPa."""
|
||||
r = eng.wedge_fs(
|
||||
j1={"dip_direction": 65, "dip": 74, "c": 15, "phi": 32},
|
||||
j2={"dip_direction": 186, "dip": 41, "c": 5, "phi": 40},
|
||||
slope={"dip_direction": 134, "dip": 65},
|
||||
upper={"dip_direction": 122, "dip": 11},
|
||||
H=6.8471,
|
||||
gamma=25,
|
||||
water={"pressure": [25, 15]},
|
||||
)
|
||||
assert r["mode"] == "2"
|
||||
close(r["F"], 0.8493, 1e-3) # Priest ≈ 0.86 · SWedge 0.8493
|
||||
close(r["volume"], 81.741, 1e-3)
|
||||
assert r["N"][0] == 0.0
|
||||
|
||||
|
||||
def test_wedge_gravity_water_and_bolt():
|
||||
"""중력 수압 U = p³ γw Hw a / 6(SWedge · Hoek-Bray) · 볼트는 안전율을 낮추지 않음."""
|
||||
dry = _wedge((141, 45), (219, 45), (180, 70), 35)
|
||||
wet = _wedge((141, 45), (219, 45), (180, 70), 35, water={"fill": 1.0})
|
||||
expect = eng.GAMMA_W * wet["Hw"] / 6 * wet["areas"][0]
|
||||
close(wet["water_force"][0], expect, 1e-9)
|
||||
assert wet["F"] < dry["F"]
|
||||
bolted = _wedge(
|
||||
(141, 45), (219, 45), (180, 70), 35, bolt={"force": 50, "trend": 0, "plunge": 15}
|
||||
)
|
||||
assert bolted["F"] > dry["F"]
|
||||
|
||||
|
||||
def test_wedge_not_formed():
|
||||
# 교선이 사면보다 가파름 → 사면으로 안 나옴
|
||||
assert _wedge((141, 75), (219, 75), (180, 50), 35) is None
|
||||
|
||||
|
||||
# ---- Markland 경계 -------------------------------------------------------------
|
||||
SLOPE = {"dip_direction": 90.0, "dip": 70.0}
|
||||
|
||||
|
||||
def _j(dd, dip, phi=30.0, jid="J"):
|
||||
return {"id": jid, "dip_direction": dd, "dip": dip, "phi": phi}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dd,dip,hit",
|
||||
[
|
||||
(109.9, 45, True),
|
||||
(110.1, 45, False),
|
||||
(70.1, 45, True),
|
||||
(90, 30.1, True),
|
||||
(90, 29.9, False),
|
||||
(90, 69.9, True),
|
||||
(90, 70.1, False),
|
||||
],
|
||||
)
|
||||
def test_markland_planar(dd, dip, hit):
|
||||
assert (eng.kinematic(SLOPE, [_j(dd, dip)])["planar"] == ["J"]) is hit
|
||||
|
||||
|
||||
def test_markland_planar_apparent_daylight():
|
||||
"""측방 15° — 겉보기 사면 경사(tanψf cos15°)를 넘는 절리는 안 나옴."""
|
||||
app = eng.apparent_dip(70, 90, 105)
|
||||
assert eng.kinematic(SLOPE, [_j(105, app - 0.1)])["planar"] == ["J"]
|
||||
assert eng.kinematic(SLOPE, [_j(105, app + 0.1)])["planar"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dd,dip,hit", [(270, 50.1, True), (270, 49.9, False), (289.9, 80, True), (290.1, 80, False)]
|
||||
)
|
||||
def test_markland_toppling(dd, dip, hit):
|
||||
"""ψp > 90 − ψf + φ = 50 · 역방향 ±20."""
|
||||
assert (eng.kinematic(SLOPE, [_j(dd, dip)])["toppling"] == ["J"]) is hit
|
||||
|
||||
|
||||
def test_markland_wedge_plunge_bounds():
|
||||
a, b = _j(50, 60, jid="A"), _j(130, 60, jid="B")
|
||||
trend, plunge = eng.intersection(a, b)
|
||||
assert trend == pytest.approx(90.0, abs=1e-6)
|
||||
hit = eng.kinematic(SLOPE, [a, b])["wedge"]
|
||||
assert hit and hit[0]["ids"] == ["A", "B"]
|
||||
# φ 를 선주각 위로 올리면 사라짐
|
||||
hi = [dict(a, phi=plunge + 0.1), dict(b, phi=plunge + 0.1)]
|
||||
assert eng.kinematic(SLOPE, hi)["wedge"] == []
|
||||
# 사면을 교선보다 완만하게 하면 안 나옴
|
||||
assert eng.kinematic({"dip_direction": 90, "dip": plunge - 0.1}, [a, b])["wedge"] == []
|
||||
|
||||
|
||||
# ---- 투영 ----------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("kind", net.PROJECTIONS)
|
||||
def test_projection_round_trip(kind):
|
||||
rng = np.random.default_rng(536)
|
||||
for trend, plunge in zip(rng.uniform(0, 360, 200), rng.uniform(0, 89.9, 200)):
|
||||
x, y = net.project(trend, plunge, kind)
|
||||
t2, p2 = net.inverse(x, y, kind)
|
||||
assert p2 == pytest.approx(plunge, abs=2e-3)
|
||||
# 좌표는 소수 다섯째 자리 — 중심 가까이는 추향 오차가 1e-5 / r rad 까지
|
||||
assert eng.ang_diff(t2, trend) <= math.degrees(1e-5 / math.hypot(x, y)) + 1e-6
|
||||
|
||||
|
||||
def test_projection_known_radii():
|
||||
assert net.radius(0, "equal_angle") == pytest.approx(1.0)
|
||||
assert net.radius(0, "equal_area") == pytest.approx(1.0)
|
||||
assert net.radius(90, "equal_angle") == pytest.approx(0.0)
|
||||
assert net.radius(45, "equal_angle") == pytest.approx(math.tan(math.radians(22.5)))
|
||||
assert net.radius(45, "equal_area") == pytest.approx(
|
||||
math.sqrt(2) * math.sin(math.radians(22.5))
|
||||
)
|
||||
# 동쪽 수평선 = (1, 0) · 북쪽 = (0, 1)
|
||||
assert net.project(90, 0, "equal_angle") == pytest.approx([1.0, 0.0], abs=1e-5)
|
||||
assert net.project(0, 0, "equal_area") == pytest.approx([0.0, 1.0], abs=1e-5)
|
||||
|
||||
|
||||
def _inside(pt, poly):
|
||||
x, y = pt
|
||||
hit = False
|
||||
for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
|
||||
if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
|
||||
hit = not hit
|
||||
return hit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", net.PROJECTIONS)
|
||||
def test_zones_match_kinematic(kind):
|
||||
"""위험 영역 다각형 안 극점 · 교선 = Markland 판정(경계 1° 띠 밖 표본)."""
|
||||
rng = np.random.default_rng(7)
|
||||
phi = 30.0
|
||||
data = net.stereonet(SLOPE, [], phi, kind)
|
||||
checked = 0
|
||||
for dd, dip in zip(rng.uniform(0, 360, 1500), rng.uniform(1, 89, 1500)):
|
||||
kin = eng.kinematic(SLOPE, [_j(dd, dip, phi)])
|
||||
near = (
|
||||
abs(eng.ang_diff(dd, 90) - 20) < 1
|
||||
or abs(eng.ang_diff(dd, 270) - 20) < 1
|
||||
or abs(dip - phi) < 1
|
||||
or abs(dip - eng.apparent_dip(70, 90, dd)) < 1
|
||||
or abs(dip - 50) < 1
|
||||
)
|
||||
if near:
|
||||
continue
|
||||
pole = net.project(*eng.pole(dd, dip), kind)
|
||||
assert _inside(pole, data["zones"]["planar"]) == bool(kin["planar"]), (dd, dip)
|
||||
assert _inside(pole, data["zones"]["toppling"]) == bool(kin["toppling"]), (dd, dip)
|
||||
checked += 1
|
||||
assert checked > 1000
|
||||
|
||||
|
||||
# ---- 정본 · 측점 사면 ------------------------------------------------------------
|
||||
def _doc():
|
||||
doc = model.build_initial()
|
||||
doc["sets"] = [
|
||||
{
|
||||
"id": "J1",
|
||||
"name": "J1",
|
||||
"from_m": 0,
|
||||
"to_m": 100,
|
||||
"dip_direction": 90,
|
||||
"dip": 45,
|
||||
"phi": 30,
|
||||
"c": 5,
|
||||
"persistence_m": None,
|
||||
}
|
||||
]
|
||||
return doc
|
||||
|
||||
|
||||
def test_check_and_clean():
|
||||
assert model.check_joints(_doc()) == []
|
||||
bad = _doc()
|
||||
bad["sets"][0].update(dip=95, to_m=-1, name=" ")
|
||||
bad["sets"].append(dict(bad["sets"][0]))
|
||||
bad["settings"]["crack"] = {"mode": "depth", "z": None}
|
||||
paths = {e["path"] for e in model.check_joints(bad)}
|
||||
assert {"sets.0.dip", "sets.0.to_m", "sets.0.name", "sets.1.id", "settings.crack.z"} <= paths
|
||||
clean = model.clean(dict(_doc(), extra=1))
|
||||
assert set(clean) == {"version", "sets", "settings"}
|
||||
assert clean["sets"][0]["dip"] == 45.0
|
||||
|
||||
|
||||
def _cross(side_cut: str):
|
||||
"""좌(+offset) 또는 우 깎기 — 노선 북쪽 진행(left = 서쪽 −x) · 원지반 깎기 쪽으로 0.6 오름 ·
|
||||
노면 99 · 깎기 1:0.4 · 쌓기 1:1.5."""
|
||||
sign = 1.0 if side_cut == "left" else -1.0
|
||||
offs = np.arange(-20, 20.5, 0.5)
|
||||
ground = [[float(o), 100 + 0.6 * sign * o] for o in offs]
|
||||
design = []
|
||||
for o, z in ground:
|
||||
d = sign * o
|
||||
if abs(o) <= 2:
|
||||
z = 99.0
|
||||
elif d > 2:
|
||||
z = min(z, 99 + 2.5 * (d - 2))
|
||||
else:
|
||||
z = max(z, 99 - (abs(d) - 2) / 1.5)
|
||||
design.append([o, z])
|
||||
return (
|
||||
{
|
||||
"frame": {"left_xy": [-1.0, 0.0]},
|
||||
"samples": [{"offset_m": o, "elevation_m": z} for o, z in ground],
|
||||
},
|
||||
{
|
||||
"section_mode": f"{side_cut}_cut",
|
||||
"cut_slope_ratio": 0.4,
|
||||
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
|
||||
"design_line": [{"offset_m": o, "elevation_m": z} for o, z in design],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side,dd", [("left", 90.0), ("right", 270.0)])
|
||||
def test_station_slope_orientation(side, dd):
|
||||
"""좌 = 서쪽 산 · 사면이 동(노면)으로 기욺 = 경사방향 90 · 우는 270 · 경사 = atan(1/0.4)."""
|
||||
cross, design = _cross(side)
|
||||
slopes = model.station_slopes(cross, design)
|
||||
assert len(slopes) == 1 and slopes[0]["side"] == side
|
||||
assert slopes[0]["dip_direction"] == pytest.approx(dd)
|
||||
assert slopes[0]["dip"] == pytest.approx(math.degrees(math.atan(2.5)), abs=0.01)
|
||||
assert slopes[0]["height_m"] > 1
|
||||
assert slopes[0]["upper_dip"] == pytest.approx(math.degrees(math.atan(0.6)), abs=0.5)
|
||||
|
||||
|
||||
def test_analyze_station(monkeypatch):
|
||||
cross, design = _cross("left")
|
||||
monkeypatch.setattr(
|
||||
model,
|
||||
"rock_stations",
|
||||
lambda _s: [{"chainage_m": 20.0, "label": "STA", "ground_type": "ripping_rock"}],
|
||||
)
|
||||
monkeypatch.setattr(model.src, "cross_section", lambda _s, _c: {**cross, "design": design})
|
||||
ground = {
|
||||
"materials": [],
|
||||
"stations": [],
|
||||
"safety_factors": {"dry": {"cut": 1.5}, "wet": {"cut": 1.2}},
|
||||
"seismic": {"enabled": False},
|
||||
}
|
||||
out = model.analyze(None, _doc(), ground)
|
||||
assert len(out) == 1
|
||||
e = out[0]
|
||||
assert e["kinematic"]["planar"] == ["J1"]
|
||||
assert {c["condition"] for c in e["cases"]} == {"dry", "wet"}
|
||||
dry = next(c for c in e["cases"] if c["condition"] == "dry")
|
||||
assert dry["required"] == 1.5 and dry["ok"] == (dry["F"] >= 1.5)
|
||||
assert e["verdict"] in ("OK", "NG")
|
||||
assert e["gamma_r"] == model.DEFAULT_GAMMA_R
|
||||
nets = model.station_net(e, _doc()["settings"])
|
||||
assert set(nets) == {"equal_angle", "equal_area", "phi"}
|
||||
assert nets["equal_area"]["zones"]["planar"]
|
||||
@@ -35,6 +35,7 @@ import { ui_locales_m2 } from "./ui_template_locale_m2";
|
||||
import { ui_locales_de } from "./ui_template_locale_de";
|
||||
import { ui_locales_e } from "./ui_template_locale_e";
|
||||
import { ui_locales_e05 } from "./ui_template_locale_e05";
|
||||
import { ui_locales_e06 } from "./ui_template_locale_e06";
|
||||
|
||||
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
|
||||
export const LANGUAGES = ["ko", "en"] as const;
|
||||
@@ -77,6 +78,7 @@ export const ui_locales = {
|
||||
...ui_locales_de,
|
||||
...ui_locales_e,
|
||||
...ui_locales_e05,
|
||||
...ui_locales_e06,
|
||||
} as const;
|
||||
|
||||
export type LocaleKey = keyof typeof ui_locales;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ui_locales_de = {
|
||||
DE_Step_StabSection: ["2D 해석", "2D Analysis"],
|
||||
DE_Step_Stab3D: ["3D 해석", "3D Analysis"],
|
||||
DE_Step_StabReport: ["보고서", "Report"],
|
||||
DE_Step_StabRock: ["암반 해석", "Rock Analysis"],
|
||||
DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."],
|
||||
|
||||
/* --- D04 확정 · 업체 · 내보내기 · B03 예정노선 가져오기(531-3) --- */
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_e06.ts
|
||||
* 다국어 사전 — E06 암반 평사투영 해석(요구 345 · 351 · PLAN 536-1)
|
||||
*
|
||||
* 신규 문구는 해당 섹션 최하단에 추가.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LocaleEntry } from "./ui_template_locale_common";
|
||||
|
||||
export const ui_locales_e06 = {
|
||||
/* --- E06 좌측 --- */
|
||||
E06_Note: [
|
||||
"암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [저장] → [해석]. 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.",
|
||||
"Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, [Save] → [Analyze]. Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).",
|
||||
],
|
||||
E06_Card_Joints: ["절리군", "Joint sets"],
|
||||
E06_JointsHint: [
|
||||
"현장 조사값 — 경사방향 · 경사(°) · 절리면 φj(°) · cj(kPa) · 지속성(m · 비우면 무한). 구간(m) 안 측점에만 씁니다.",
|
||||
"Field data — dip direction · dip (°) · joint φj (°) · cj (kPa) · persistence (m, blank = unlimited). Applies to stations within the range (m).",
|
||||
],
|
||||
E06_Col_Name: ["이름", "Name"],
|
||||
E06_Col_From: ["시작", "From"],
|
||||
E06_Col_To: ["끝", "To"],
|
||||
E06_Col_DipDir: ["경사방향", "Dip dir."],
|
||||
E06_Col_Dip: ["경사", "Dip"],
|
||||
E06_Col_Phi: ["φj", "φj"],
|
||||
E06_Col_C: ["cj", "cj"],
|
||||
E06_Col_Persist: ["지속성", "Persist."],
|
||||
E06_Add: ["+ 절리군", "+ Joint set"],
|
||||
E06_Remove: ["지움", "Remove"],
|
||||
E06_NoSets: [
|
||||
"절리군이 없습니다 — [+ 절리군] 으로 넣으세요.",
|
||||
"No joint sets — add one with [+ Joint set].",
|
||||
],
|
||||
E06_Card_Settings: ["해석 설정", "Analysis settings"],
|
||||
E06_Set_LateralPlanar: ["평면 측방 한계 (°)", "Planar lateral limit (°)"],
|
||||
E06_Set_LateralTopple: ["전도 측방 한계 (°)", "Toppling lateral limit (°)"],
|
||||
E06_Set_Gamma: ["암 단위중량 (kN/m³ · 비우면 E02)", "Rock unit weight (kN/m³, blank = E02)"],
|
||||
E06_Set_WaterWet: ["우기 수위 비 (0~1)", "Wet water ratio (0–1)"],
|
||||
E06_Set_Crack: ["인장균열", "Tension crack"],
|
||||
E06_Crack_None: ["없음", "None"],
|
||||
E06_Crack_Critical: ["임계(최소 안전율 자리)", "Critical (minimum FS)"],
|
||||
E06_Crack_Depth: ["깊이 지정", "Given depth"],
|
||||
E06_Set_CrackZ: ["균열 깊이 (m)", "Crack depth (m)"],
|
||||
E06_Set_BoltForce: ["볼트 힘 (평면 kN/m · 쐐기 kN)", "Bolt force (plane kN/m · wedge kN)"],
|
||||
E06_Set_BoltPlunge: ["볼트 경사 (수평 아래 °)", "Bolt plunge (below horizontal °)"],
|
||||
E06_SettingsHint: [
|
||||
"측방 한계 기본 20° (Dips 기본 · 지식DB 03 전도는 30°) · 우기 = 비탈 높이 ½ 수위(KDS 11 70 05) · 볼트 = 수동 지지력.",
|
||||
"Lateral limit default 20° (Dips default; knowledge DB uses 30° for toppling) · wet = water at half height (KDS 11 70 05) · bolts = passive support.",
|
||||
],
|
||||
E06_Analyze: ["해석", "Analyze"],
|
||||
E06_Running: ["해석 중…", "Analyzing…"],
|
||||
E06_Done: ["암반 해석을 마쳤습니다.", "Rock analysis done."],
|
||||
E06_Fresh: ["입력이 같아 지난 결과를 씁니다.", "Inputs unchanged — previous result kept."],
|
||||
E06_Saved: ["저장했습니다.", "Saved."],
|
||||
E06_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
|
||||
E06_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
|
||||
E06_Invalid: ["틀린 칸", "Invalid fields"],
|
||||
E06_Err_Range: ["범위 밖", "Out of range"],
|
||||
E06_ResetConfirm: [
|
||||
"절리군 · 설정을 초기값(절리 없음)으로 되돌릴까요?",
|
||||
"Reset joint sets and settings to the initial state (no joints)?",
|
||||
],
|
||||
E06_ResetDone: ["초기값으로 되돌렸습니다.", "Reset to initial values."],
|
||||
E06_NoProject: ["열린 프로젝트가 없습니다.", "No project is open."],
|
||||
|
||||
/* --- E06 가운데 · 우측 --- */
|
||||
E06_Stale: [
|
||||
"절리 · 지반 조건이 해석 뒤 바뀌었습니다 — [해석] 을 다시 누르세요.",
|
||||
"Joints or ground changed since the analysis — press [Analyze] again.",
|
||||
],
|
||||
E06_Band: ["위험 띠", "Risk band"],
|
||||
E06_View_All: ["전체", "All"],
|
||||
E06_Mode_planar: ["평면", "Planar"],
|
||||
E06_Mode_wedge: ["쐐기", "Wedge"],
|
||||
E06_Mode_toppling: ["전도", "Toppling"],
|
||||
E06_Proj_Angle: ["등각", "Equal angle"],
|
||||
E06_Proj_Area: ["등면적", "Equal area"],
|
||||
E06_PickStation: ["측점을 고르세요.", "Pick a station."],
|
||||
E06_Lg_Slope: ["사면 대원 · 극점", "Slope plane · pole"],
|
||||
E06_Lg_Friction: ["마찰원 φ", "Friction circle φ"],
|
||||
E06_Lg_Planar: ["평면 위험(극점)", "Planar zone (poles)"],
|
||||
E06_Lg_Daylight: ["일광 엔벨로프", "Daylight envelope"],
|
||||
E06_Lg_Wedge: ["쐐기 위험(교선)", "Wedge zone (intersections)"],
|
||||
E06_Lg_Line: ["교선", "Intersection"],
|
||||
E06_Lg_Toppling: ["전도 위험(극점)", "Toppling zone (poles)"],
|
||||
E06_Lg_Pole: ["절리 극점", "Joint pole"],
|
||||
E06_Result_Title: ["측점 결과", "Station results"],
|
||||
E06_NoResult: ["해석 결과가 없습니다 — [해석] 을 누르세요.", "No results — press [Analyze]."],
|
||||
E06_NoRock: ["암 측점 깎기 비탈이 없습니다.", "No rock cut slopes."],
|
||||
E06_L_Station: ["측점", "Station"],
|
||||
E06_L_Side: ["쪽", "Side"],
|
||||
E06_L_Modes: ["평 · 쐐 · 전", "P · W · T"],
|
||||
E06_L_MinF: ["최소 Fs", "Min Fs"],
|
||||
E06_L_Verdict: ["판정", "Verdict"],
|
||||
E06_Left: ["좌", "L"],
|
||||
E06_Right: ["우", "R"],
|
||||
E06_V_NG: ["N.G", "N.G"],
|
||||
E06_V_check: ["검토", "Check"],
|
||||
E06_V_OK: ["O.K", "O.K"],
|
||||
E06_V_none: ["위험 없음", "No risk"],
|
||||
E06_SlopeLine: [
|
||||
"사면 {dd}° / {dip}° · 높이 {h} m · 윗면 {up}° · 암 γ {g} kN/m³",
|
||||
"Slope {dd}° / {dip}° · height {h} m · upper {up}° · rock γ {g} kN/m³",
|
||||
],
|
||||
E06_NoRisk: ["위험 모드 없음(Markland).", "No kinematic failure mode (Markland)."],
|
||||
E06_NoJointsHere: ["이 측점 구간에 절리군이 없습니다.", "No joint sets cover this station."],
|
||||
E06_C_Mode: ["모드", "Mode"],
|
||||
E06_C_Joints: ["절리", "Joints"],
|
||||
E06_C_Cond: ["조건", "Case"],
|
||||
E06_C_F: ["Fs", "Fs"],
|
||||
E06_C_Req: ["기준", "Req."],
|
||||
E06_Cond_dry: ["건기", "Dry"],
|
||||
E06_Cond_wet: ["우기", "Wet"],
|
||||
E06_Cond_seismic: ["지진", "Seismic"],
|
||||
E06_NotFormed: ["(블록 안 생김)", "(no block)"],
|
||||
E06_Persist: ["(지속성 < 활동면)", "(persistence < plane)"],
|
||||
E06_ToppleNote: [
|
||||
"전도 = 기하 판정만(블록 전도 안전율은 Goodman-Bray 정밀 검토 몫).",
|
||||
"Toppling = kinematic only (block toppling FS needs a Goodman–Bray check).",
|
||||
],
|
||||
} as const satisfies Record<string, LocaleEntry>;
|
||||
Reference in New Issue
Block a user