feat(E03): 판정 면 칸(원호 · 비원호 · 면 확인 · 모형 끝 면) + 탐색 한계 고르기 (PLAN 567-1)

- 목록 판정 면 칸 · 결과 방법 줄 · 면 확인 · 모형 끝 면 배지
- 해석 설정 탐색 한계(모형 끝 / 비탈 끝 + 2H / 비탈 끝 + 정한 m) — [해석] 이 저장 + 그 값으로 셈
- Router 설정 저장본 · 지문 · 일꾼 kwargs · 단면 · 가정 · 확률 길

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:38:55 +09:00
co-authored by Claude Sonnet 5.5
parent 97bdc0a92d
commit 79d33601f2
10 changed files with 279 additions and 13 deletions
+42 -2
View File
@@ -55,6 +55,7 @@ from E03_StabSection import E03_StabSection_View as view
from E03_StabSection import E03_StabSection_Walls as walls_mod
from E03_StabSection import E03_StabSection_Worker as worker
from E03_StabSection.E03_StabSection_Engine_Model import (
SEARCH_REACH,
ground_from_cross,
section_model_from_b06,
widen_ground,
@@ -67,6 +68,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
KIND_STATION = worker.KIND_STATION
KIND_SUMMARY = "stab2d_summary"
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
SETTINGS = Path("E03_StabSection") / "settings.json"
ENGINE_FILES = (
"E03_StabSection_Types.py",
"E03_StabSection_Engine_Model.py",
@@ -91,6 +93,31 @@ class RepresentativeRequest(BaseModel):
overrides: dict[str, bool | None]
class AnalyzeRequest(BaseModel):
#: 2D 탐색 한계 — "model_end" · "2h" · 수(비탈 끝 + m) · None = 지금 저장값 그대로
search_reach: str | float | None = None
def _clean_reach(value: Any) -> str | float:
"""탐색 한계 값 검사 — 모르는 값은 기본."""
if value in ("model_end", "2h"):
return value
try:
reach = float(value)
except (TypeError, ValueError):
return SEARCH_REACH
return reach if math.isfinite(reach) and reach > 0 else SEARCH_REACH
def search_reach_of(root: Path) -> str | float:
"""작업본 해석 설정의 탐색 한계 — 없으면 서버 기본(`SEARCH_REACH`)."""
try:
data = json.loads((root / SETTINGS).read_text(encoding="utf-8"))
except (OSError, ValueError):
return SEARCH_REACH
return _clean_reach(data.get("search_reach"))
@lru_cache(maxsize=1)
def engine_hash() -> str:
"""엔진 코드 지문 — 코드가 바뀌면 모든 측점을 다시 셈."""
@@ -140,6 +167,9 @@ def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]:
"road_surcharge_kpa": float(ground.get("road_surcharge_kpa") or 0.0),
"groundwater": ground.get("groundwater"),
}
reach = search_reach_of(root)
if reach != SEARCH_REACH:
shared["search_reach"] = reach # 기본값이면 빼서 옛 지문 그대로
resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)}
try:
found = _walls(str(source), copy_fp)
@@ -255,6 +285,7 @@ def run_all(root: Path, plan: dict[str, Any], report: Any) -> int:
"conditions": list(shared["conditions"]),
"seismic_kh": shared["seismic_kh"],
"road_surcharge_kpa": shared["road_surcharge_kpa"],
"search_reach": shared.get("search_reach", SEARCH_REACH),
},
"items": job_items,
}
@@ -314,9 +345,15 @@ def _overrides(root: Path) -> dict[str, bool]:
@router.post("/{project_id}/stab/analyze2d", response_model=None)
async def analyze2d(project_id: UUID) -> dict[str, Any]:
"""전 측점 해석(뒤에서) — 지문이 같으면 다시 안 셈(`fresh`) · 진행은 `GET …/stab/progress`."""
async def analyze2d(project_id: UUID, req: AnalyzeRequest | None = None) -> dict[str, Any]:
"""전 측점 해석(뒤에서) — 지문이 같으면 다시 안 셈(`fresh`) · 진행은 `GET …/stab/progress`.
본문 `search_reach` 가 있으면 해석 설정에 저장한 뒤 그 값으로 셈."""
root, _ = await e02._project(project_id)
if req is not None and req.search_reach is not None:
reach = _clean_reach(req.search_reach)
if reach != search_reach_of(root):
atomic_write_json(root / SETTINGS, {"search_reach": reach})
plan = plan_run(root, _ground(root))
if read_derived(root, KIND_SUMMARY, plan["fp"]):
return {"status": "fresh", "fingerprint": plan["fp"]}
@@ -375,6 +412,7 @@ async def result2d(
"running": running,
"conditions": plan["shared"]["conditions"],
"factors": plan["factors"],
"search_reach": plan["shared"].get("search_reach", SEARCH_REACH),
"stations": rows,
"missing_stations": missing,
}
@@ -387,6 +425,7 @@ async def result2d(
"editable": editable,
"running": running,
**{k: v for k, v in summary.items() if k != "stations"},
"search_reach": plan["shared"].get("search_reach", SEARCH_REACH),
"stations": rows,
"missing_stations": missing,
}
@@ -413,6 +452,7 @@ async def section(project_id: UUID, station_key: str) -> dict[str, Any]:
"ground": [tuple(p) for p in value["inputs"]["ground"]],
"road_surcharge_kpa": plan["shared"]["road_surcharge_kpa"],
"walls": walls_mod.walls_of(entry),
"search_reach": plan["shared"].get("search_reach", SEARCH_REACH),
}
dry = section_model_from_b06(cross, design, **kw)
water, rules = {}, {}
@@ -110,6 +110,7 @@ def _model(ctx: dict[str, Any], condition: str) -> dict[str, Any]:
seismic_kh=kw["seismic_kh"] if condition == "seismic" else 0.0,
road_surcharge_kpa=kw["road_surcharge_kpa"],
walls=kw.get("walls"),
search_reach=kw["search_reach"],
)
model["groundwater"] = norm_groundwater(kw["groundwater_for"](condition, model))
return model
@@ -148,6 +148,7 @@ def _context(root: Path, station_key: str) -> dict[str, Any]:
"conditions": list(shared["conditions"]),
"seismic_kh": shared["seismic_kh"],
"road_surcharge_kpa": shared["road_surcharge_kpa"],
"search_reach": shared.get("search_reach", e03.SEARCH_REACH),
"layers": value["inputs"]["layers"],
"ground": [tuple(p) for p in value["inputs"]["ground"]],
}
@@ -173,7 +174,10 @@ def _wall_side(wall: dict[str, Any]) -> str:
def _section(ctx: dict[str, Any], design: dict[str, Any], row: dict, out: dict) -> dict:
"""바뀐 단면 화면 꼴 — `GET …/section` 과 같은 꼴(지하수선 = E02 고른 갈래)."""
kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa")}
kw = {
k: ctx["kw"][k]
for k in ("materials", "layers", "ground", "road_surcharge_kpa", "search_reach")
}
kw["walls"] = ctx["kw"].get("walls")
dry = section_model_from_b06(ctx["cross"], design, **kw)
water = {}
@@ -12,6 +12,7 @@ import { t, type LocaleKey } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { e03Table } from "./E03_StabSection_UI_Table";
import {
basisOf,
listRows,
roleText,
verdictOf,
@@ -51,6 +52,7 @@ export function buildList(
{ label: t("E03_Col_Role") },
...draw.conditions.map((c) => ({ label: t(`E03_Cond_${c}` as LocaleKey), num: true })),
{ label: t("E03_Verdict") },
{ label: t("E03_Col_Basis"), title: t("E03_Basis_Hint") },
{ label: t("E03_Col_Rep"), title: t("E03_Rep_Hint") },
];
const cells = rows.map((row) => {
@@ -94,6 +96,7 @@ export function buildList(
row.sides.length ? roleText(row, { cut: t("E03_CutShort"), fill: t("E03_FillShort") }) : "-",
...draw.conditions.map((c) => fmt(row.min[c])),
verdict,
basisBadges(row, draw.condition),
star,
];
});
@@ -115,6 +118,35 @@ export function buildList(
return box;
}
/** 판정 면 칸 — 원호 · 비원호 + 「면 확인」 · 「모형 끝 면」(그 조건 기준) */
export function basisBadges(row: StationRow, condition: Condition): HTMLElement {
const box = el("span", { className: "e03-basis" });
const b = basisOf(row, condition);
if (b.method) {
const kind = createBadge(
t(b.circular ? "E03_Basis_Circular" : "E03_Basis_NonCircular"),
b.circular ? "neutral" : "info",
{ size: 18 },
);
kind.title = t(`E03_Method_${b.method}` as LocaleKey);
kind.dataset.role = "basis-kind";
box.append(kind);
}
if (b.surfaceCheck) {
const flag = createBadge(t("E03_SurfaceCheck"), "warning", { size: 18 });
flag.title = t("E03_SurfaceCheck_Hint");
flag.dataset.role = "basis-check";
box.append(flag);
}
if (b.edge) {
const flag = createBadge(t("E03_EdgeSurface"), "warning", { size: 18 });
flag.title = t("E03_EdgeSurface_Hint");
flag.dataset.role = "basis-edge";
box.append(flag);
}
return box;
}
function hookEmpty(text: string): HTMLElement {
const empty = createEmpty({ text, small: true });
empty.classList.add("e03-empty");
+52 -6
View File
@@ -13,6 +13,7 @@ import "./E03_StabSection_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import {
createButton,
createInputField,
createSelectField,
el,
hideLoadingOverlay,
@@ -60,6 +61,8 @@ interface Result2d {
conditions: Condition[];
computed_at?: string;
summary?: Record<string, unknown>;
/** 2D 탐색 한계 — "model_end" · "2h" · 수(비탈 끝 + m) */
search_reach?: string | number;
stations: StationRow[];
/** 횡단 없어 해석에서 빠진 측점 누가거리(E01 복사본) */
missing_stations?: number[];
@@ -139,6 +142,31 @@ export function renderE03StabSection(root: HTMLElement): void {
paintList();
},
});
const reachField = createSelectField({
label: t("E03_Reach"),
options: [
{ value: "model_end", text: t("E03_Reach_model_end") },
{ value: "2h", text: t("E03_Reach_2h") },
{ value: "m", text: t("E03_Reach_m") },
],
compact: true,
onChange: () => paintReach(),
});
reachField.select.dataset.role = "reach";
reachField.select.title = t("E03_Reach_Hint");
const reachMeters = createInputField({ type: "number", min: 0.1, compact: true, unit: "m" });
reachMeters.input.dataset.role = "reach-m";
reachMeters.input.title = t("E03_Reach_Hint");
const paintReach = (): void => {
reachMeters.root.hidden = reachField.select.value !== "m";
};
/** 서버 저장값을 칸에 맞춤 */
const showReach = (reach: string | number | undefined): void => {
const custom = typeof reach === "number";
reachField.select.value = custom ? "m" : reach === "2h" ? "2h" : "model_end";
if (custom) reachMeters.input.value = String(reach);
paintReach();
};
const ngBox = check(t("E03_NgOnly"), false, (v) => {
ngOnly = v;
paintList();
@@ -162,7 +190,7 @@ export function renderE03StabSection(root: HTMLElement): void {
controls.append(condField.root, sortField.root);
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(view3dButton, analyzeButton);
panel.append(controls, ngBox, status, listHost, actions);
panel.append(controls, reachField.root, reachMeters.root, ngBox, status, listHost, actions);
// ---- 본문 ----
const missingHost = el("div");
@@ -211,6 +239,7 @@ export function renderE03StabSection(root: HTMLElement): void {
if (result && !result.editable) lines.push(t("E03_ReadOnly"));
status.textContent = lines.join("\n");
analyzeButton.disabled = !result?.editable;
reachField.select.disabled = reachMeters.input.disabled = !result?.editable;
view3dButton.disabled = result?.status !== "ok";
};
@@ -338,10 +367,16 @@ export function renderE03StabSection(root: HTMLElement): void {
return;
}
resultHost.append(
buildResult(view, analysis, picked, (i) => {
picked = i;
paintSection();
}),
buildResult(
view,
analysis,
picked,
(i) => {
picked = i;
paintSection();
},
analysis ? row?.cases[condition]?.[analysis.side] : undefined,
),
);
};
@@ -419,6 +454,7 @@ export function renderE03StabSection(root: HTMLElement): void {
result = await api<Result2d>(`${base}/result2d`);
if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로
sections.clear();
showReach(result.search_reach);
missingHost.replaceChildren(buildMissingBanner(result.missing_stations));
const conds = result.conditions;
if (!conds.includes(condition)) condition = conds[0] ?? "dry";
@@ -467,8 +503,18 @@ export function renderE03StabSection(root: HTMLElement): void {
}
async function analyze(): Promise<void> {
const pick = reachField.select.value;
const meters = Number(reachMeters.input.value);
if (pick === "m" && !(meters > 0)) {
reachMeters.setError(t("E03_Reach_Invalid"));
return;
}
reachMeters.setError();
try {
const response = await api<{ status: string }>(`${base}/analyze2d`, { method: "POST" });
const response = await api<{ status: string }>(`${base}/analyze2d`, {
method: "POST",
body: JSON.stringify({ search_reach: pick === "m" ? meters : pick }),
});
if (response.status === "fresh") {
showToast(t("E03_Fresh"), "info");
await load();
+26 -3
View File
@@ -18,6 +18,7 @@ import {
type Analysis,
type Point,
type SectionView,
type SideCase,
} from "./logic/E03_StabSection_Logic";
import { WALL_ID, materialClass, svgEl } from "./E03_StabSection_UI_Section";
import { card, emptyBox, legend, muted } from "./E03_StabSection_UI_Parts";
@@ -39,15 +40,23 @@ export function verdictChip(ok: boolean | null): HTMLElement {
return chip;
}
function kpi(analysis: Analysis, walls: number): HTMLElement {
function flag(textKey: LocaleKey, hintKey: LocaleKey, role: string, hint?: string): HTMLElement {
const badge = createBadge(t(textKey), "warning", { size: 18 });
badge.title = hint ?? t(hintKey);
badge.dataset.role = role;
return badge;
}
function kpi(analysis: Analysis, walls: number, basis?: SideCase): HTMLElement {
const c = analysis.critical;
const method = (basis?.method || "bishop") as string;
const rows: KvRow[] = [
{ label: t("E03_Fs"), value: fmt(c?.fs, 3), big: true },
{ label: t("E03_Verdict"), value: verdictChip(analysis.ok) },
{ label: t("E03_Required"), value: fmt(analysis.required) },
{
label: t("E03_Method"),
value: `${t("E03_Method_bishop")} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`,
value: `${t(`E03_Method_${method}` as LocaleKey)} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`,
},
{
label: t("E03_Converged"),
@@ -57,6 +66,19 @@ function kpi(analysis: Analysis, walls: number): HTMLElement {
},
{ label: t("E03_Height"), value: `${fmt(analysis.height_m)} m` },
];
if (analysis.surface_check) {
const circular = basis?.fs_circular;
const hint = `${t("E03_SurfaceCheck_Hint")}${circular != null ? ` · ${t("E03_Method_bishop")} ${fmt(circular, 3)}` : ""}`;
rows.push({
label: t("E03_Col_Basis"),
value: flag("E03_SurfaceCheck", "E03_SurfaceCheck_Hint", "surface-check", hint),
});
}
if (analysis.edge)
rows.push({
label: t("E03_Col_Basis"),
value: flag("E03_EdgeSurface", "E03_EdgeSurface_Hint", "edge-surface"),
});
if (walls)
rows.push({
label: t("E03_Walls"),
@@ -273,6 +295,7 @@ export function buildResult(
analysis: Analysis | null,
picked: number | null,
onPick: (i: number | null) => void,
basis?: SideCase,
): HTMLElement {
const root = card("e03-result");
if (!analysis || !analysis.critical) {
@@ -282,7 +305,7 @@ export function buildResult(
const head = (key: LocaleKey): HTMLElement =>
el("h3", { className: "e03-card__title", text: t(key) });
const walls = view.regions.filter((r) => r.material_id === WALL_ID).length;
root.append(head("E03_Result"), kpi(analysis, walls), methodTable(analysis));
root.append(head("E03_Result"), kpi(analysis, walls, basis), methodTable(analysis));
root.append(head("E03_Slices"), sliceTable(analysis, picked, onPick));
root.append(head("E03_Fbd"), freeBody(view, analysis, picked));
root.append(head("E03_Graph"), stressGraph(analysis, picked));
@@ -186,6 +186,12 @@
white-space: nowrap;
}
.e03-basis {
display: inline-flex;
gap: var(--spacing-4);
align-items: center;
}
.e03-check {
font-size: var(--text-body-sm);
}
@@ -17,6 +17,11 @@ export interface SideCase {
ok: boolean | null;
kind: string | null;
converged: boolean | null;
/** 판정 면의 방법 · 원호 Bishop 안전율 · 면 확인 · 모형 끝 면 — 옛 저장본은 없음 */
method?: string | null;
fs_circular?: number | null;
surface_check?: boolean;
edge?: boolean | null;
}
export interface StationRow {
@@ -83,6 +88,8 @@ export interface Analysis {
required: number | null;
ok: boolean | null;
min_by_method: Record<string, number | null>;
surface_check?: boolean | null;
edge?: boolean | null;
/** 「해 없음」 면이 있는 방법(Spencer · M-P) — 옛 저장본은 없음 */
no_solution?: string[];
critical: Surface | null;
@@ -122,6 +129,28 @@ export function verdictOf(row: StationRow, condition?: Condition): Verdict {
return values.includes("ok") ? "ok" : "none";
}
/** 판정 면 표시 — 그 조건에서 안전율이 가장 낮은 비탈의 방법 · 면 확인 · 모형 끝 면은 비탈 하나라도 */
export interface Basis {
method: string | null;
circular: boolean | null;
surfaceCheck: boolean;
edge: boolean;
}
export function basisOf(row: StationRow, condition: Condition): Basis {
const sides = Object.values(row.cases[condition] ?? {});
const worst = sides
.filter((c) => c.fs !== null && c.method)
.sort((a, b) => (a.fs as number) - (b.fs as number))[0];
const method = worst?.method ?? null;
return {
method,
circular: method === null ? null : method === "bishop",
surfaceCheck: sides.some((c) => c.surface_check === true),
edge: sides.some((c) => c.edge === true),
};
}
export type SortMode = "chainage" | "fs";
/** 목록 정렬 · 거름 — fs = 그 조건 최소 안전율 낮은 순(없으면 맨 뒤) */
+59 -1
View File
@@ -69,11 +69,13 @@ def frozen():
CALLS: list[float] = []
REACHES: list[object] = []
def _fake_analyze(cross, design, *, layers=None, ground=None, conditions=("dry", "wet"), **kw):
"""엔진 출력 꼴 그대로 · 값은 누가거리로 정함(1.0 + ch/100 · 건기 +0.3)."""
CALLS.append(float(cross["chainage_m"]))
REACHES.append(kw.get("search_reach"))
model = section_model_from_b06(
cross, design, layers=layers, ground=ground, materials=kw.get("materials")
)
@@ -209,6 +211,33 @@ def test_지문이_같으면_다시_안셈(project):
assert body["fresh"] and any(v["required"] == 9.0 and v["ok"] is False for v in fill_dry)
def test_탐색_한계는_저장되고_지문이_바뀜(project):
first = _analyze()
assert set(REACHES) == {"model_end"}
body = asyncio.run(router.result2d(UUID(PID), USER))
assert body["search_reach"] == "model_end"
async def go(reach):
out = await router.analyze2d(UUID(PID), router.AnalyzeRequest(search_reach=reach))
while router._TASKS:
await asyncio.gather(*list(router._TASKS))
return out
REACHES.clear()
second = asyncio.run(go("2h"))
assert second["status"] == "accepted" and second["fingerprint"] != first["fingerprint"]
assert set(REACHES) == {"2h"} and len(REACHES) == N_STATIONS
assert asyncio.run(router.result2d(UUID(PID), USER))["search_reach"] == "2h"
assert asyncio.run(go(None))["status"] == "fresh" # 값 없음 = 저장값 그대로
REACHES.clear()
assert asyncio.run(go(12.5))["status"] == "accepted" and set(REACHES) == {12.5}
assert asyncio.run(router.result2d(UUID(PID), USER))["search_reach"] == 12.5
asyncio.run(go("model_end"))
assert (
router.plan_run(project, router._ground(project))["fp"] == first["fingerprint"]
) # 기본 = 옛 지문
def test_진행률_올라감(project, monkeypatch):
seen = []
@@ -446,6 +475,7 @@ process.stdout.write(JSON.stringify({
zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)],
stride: [m.labelStride(100, 64), m.labelStride(40, 64), m.labelStride(10, 64), m.labelStride(0.1, 64)],
labels: [1, 2, 5].map((k) => m.planLabelKeys(input.rows, 20, k, null)),
basis: input.basisRows.map((r) => m.basisOf(r, "dry")),
labelsActive: [m.planLabelKeys(input.rows, 20, 5, "e"), m.planLabelKeys(input.rows, 20, 5, "b")],
}));
"""
@@ -500,7 +530,28 @@ def test_logic_TS():
"pore_pressure": 4.47,
}
fs_list = [None, 1.0, 1.2, 1.35, 1.5, 1.65, 1.875, 2.25, 9.0]
payload = {"rows": rows, "view": sec, "fsList": fs_list, "shape": shape, "slice": slice_row}
def side(fs, method, check=False, edge=None):
return {"fs": fs, "method": method, "surface_check": check, "edge": edge}
basis_rows = [
{
"cases": {
"dry": {"left": side(1.2, "bishop"), "right": side(2.0, "spencer", True, True)}
}
},
{"cases": {"dry": {"left": side(1.1, "morgenstern_price", True, False)}}},
{"cases": {"dry": {"left": {"fs": 1.5}}}}, # 옛 저장본 — 새 칸 없음
{"cases": {}},
]
payload = {
"basisRows": basis_rows,
"rows": rows,
"view": sec,
"fsList": fs_list,
"shape": shape,
"slice": slice_row,
}
proc = subprocess.run(
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS)],
input=json.dumps(payload),
@@ -577,6 +628,13 @@ def test_logic_TS():
)
assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"]
assert out["zAt"] == [2.5, 0]
# 판정 면 칸 — 가장 낮은 비탈의 방법 · 면 확인 · 모형 끝 면은 비탈 하나라도
assert out["basis"] == [
{"method": "bishop", "circular": True, "surfaceCheck": True, "edge": True},
{"method": "morgenstern_price", "circular": False, "surfaceCheck": True, "edge": False},
{"method": None, "circular": None, "surfaceCheck": False, "edge": False},
{"method": None, "circular": None, "surfaceCheck": False, "edge": False},
]
# 평면 띠 측점 글 솎음 — 간격 px × 걸음 ≥ 64 · 본 측점 중 걸음 배수 · 고른 측점은 늘
assert out["stride"] == [1, 2, 10, 100]
assert out["labels"] == [["a", "b", "c", "d", "e"], ["a", "c", "e"], ["a"]]
+27
View File
@@ -53,6 +53,32 @@ export const ui_locales_e03 = {
E03_Left: ["좌 비탈", "Left slope"],
E03_Right: ["우 비탈", "Right slope"],
E03_Side: ["비탈", "Slope"],
E03_Col_Basis: ["판정 면", "Basis"],
E03_Basis_Circular: ["원호", "Circ."],
E03_Basis_NonCircular: ["비원호", "Non-circ."],
E03_Basis_Hint: [
"판정 면 — 원호(Bishop)와 비원호(Spencer · M-P) 중 안전율이 더 낮은 쪽",
"Governing surface — lower of circular (Bishop) and non-circular (Spencer · M-P)",
],
E03_SurfaceCheck: ["면 확인", "Check surface"],
E03_SurfaceCheck_Hint: [
"비원호 면이 원호 Bishop 보다 15 % 넘게 낮음 — 판정 면 모양을 확인",
"Non-circular FS is over 15 % below circular Bishop — check the surface shape",
],
E03_EdgeSurface: ["모형 끝 면", "Edge surface"],
E03_EdgeSurface_Hint: [
"판정 면이 모형 끝에 닿음 — 탐색 한계를 넓혀 다시 해석",
"Governing surface touches the model edge — widen the search reach and re-run",
],
E03_Reach: ["탐색 한계", "Search reach"],
E03_Reach_model_end: ["모형 끝", "Model edge"],
E03_Reach_2h: ["비탈 끝 + 2H", "Slope end + 2H"],
E03_Reach_m: ["비탈 끝 + 정한 m", "Slope end + set m"],
E03_Reach_Hint: [
"[해석]을 누르면 이 값으로 셈 · 저장됨",
"Applied and saved when you press Analyze",
],
E03_Reach_Invalid: ["탐색 한계 m 는 0 보다 커야 합니다.", "Search reach m must be above 0."],
E03_Rep_Hint: [
"대표 단면(보고서) — 자동: 쌓기 10 m · 깎기 15 m 이상 · 최소 안전율 하위 5 · 누르면 켬/끔",
"Representative section (report) — auto: fill ≥ 10 m · cut ≥ 15 m · lowest 5 FS · click to toggle",
@@ -91,6 +117,7 @@ export const ui_locales_e03 = {
E03_Method_bishop: ["Bishop", "Bishop"],
E03_Method_janbu: ["Janbu 보정", "Janbu corr."],
E03_Method_spencer: ["Spencer", "Spencer"],
E03_Method_morgenstern_price: ["Morgenstern-Price", "Morgenstern-Price"],
E03_Circle: ["원호", "Circular"],
E03_NonCircle: ["비원호", "Non-circular"],
E03_Converged: ["수렴", "Converged"],