diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index 9b86ec5ed..664abfa09 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -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 = {}, {} diff --git a/E03_StabSection/E03_StabSection_Router_Prob.py b/E03_StabSection/E03_StabSection_Router_Prob.py index bc8e4fcfe..981ab6e07 100644 --- a/E03_StabSection/E03_StabSection_Router_Prob.py +++ b/E03_StabSection/E03_StabSection_Router_Prob.py @@ -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 diff --git a/E03_StabSection/E03_StabSection_Router_WhatIf.py b/E03_StabSection/E03_StabSection_Router_WhatIf.py index 9380dda50..d466b60a5 100644 --- a/E03_StabSection/E03_StabSection_Router_WhatIf.py +++ b/E03_StabSection/E03_StabSection_Router_WhatIf.py @@ -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 = {} diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index db51f4c4d..608181b9e 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -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"); diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index 4d5621753..d1d20cf5c 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -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; + /** 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(`${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 { + 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(); diff --git a/E03_StabSection/E03_StabSection_UI_Result.ts b/E03_StabSection/E03_StabSection_UI_Result.ts index c165f7a87..10f6a0906 100644 --- a/E03_StabSection/E03_StabSection_UI_Result.ts +++ b/E03_StabSection/E03_StabSection_UI_Result.ts @@ -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)); diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index 598b87117..8634a01f8 100644 --- a/E03_StabSection/E03_StabSection_UI_Style.css +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -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); } diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index ea80a9bc6..495680e23 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -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; + 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 = 그 조건 최소 안전율 낮은 순(없으면 맨 뒤) */ diff --git a/resources/tester/test_532_3_e03_section.py b/resources/tester/test_532_3_e03_section.py index 7cf96914f..ea7629685 100644 --- a/resources/tester/test_532_3_e03_section.py +++ b/resources/tester/test_532_3_e03_section.py @@ -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"]] diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 48138007e..b5f324e1a 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -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"],