Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
*
|
||||
* 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝.
|
||||
* 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷:
|
||||
* 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것).
|
||||
* 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것) ·
|
||||
* 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈) · 미리보기 값은 + 오르막 / − 내리막.
|
||||
* 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움).
|
||||
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
|
||||
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
|
||||
@@ -165,7 +166,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
});
|
||||
lengthField.input.dataset.role = "draw-length";
|
||||
const slopeField = createInputField({
|
||||
label: "기울기(+ 오르막)",
|
||||
label: "기울기(± 같음)",
|
||||
type: "number",
|
||||
value: String(slopePct),
|
||||
unit: "%",
|
||||
@@ -240,10 +241,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
point = fixedLengthPoint(from, cursor, lengthM);
|
||||
} else if (kind === "slope") {
|
||||
point = fixedSlopePoint(from, cursor, slopePct, z);
|
||||
if (!point) note = `이 방향엔 ${signed(slopePct)} % 자리 없음`;
|
||||
if (!point) note = `이 방향엔 ±${fmt(Math.abs(slopePct))} % 자리 없음`;
|
||||
} else if (kind === "both") {
|
||||
point = compassPoint(from, cursor, lengthM, slopePct, z);
|
||||
if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ${signed(slopePct)} % 자리 없음`;
|
||||
if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ±${fmt(Math.abs(slopePct))} % 자리 없음`;
|
||||
}
|
||||
const at = point ?? cursor;
|
||||
const length = segmentLength(from, at);
|
||||
@@ -268,7 +269,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let bestPx = LINE_HIT_PX;
|
||||
params.network().lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
const vertex = line.vertices.findIndex((v) => near(map.toScreen(v), [px, py], SNAP_PX));
|
||||
// 붙는 반경 안 꺾임점 중 가장 가까운 것
|
||||
let vertex = -1;
|
||||
let vertexPx = SNAP_PX;
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [vx, vy] = map.toScreen(v);
|
||||
const d = Math.hypot(vx - px, vy - py);
|
||||
if (d <= vertexPx) [vertex, vertexPx] = [i, d];
|
||||
});
|
||||
const atM =
|
||||
vertex >= 0
|
||||
? lineLength(line.vertices.slice(0, vertex + 1))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = 그 조건 최소 안전율 낮은 순(없으면 맨 뒤) */
|
||||
|
||||
@@ -37,6 +37,7 @@ from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary
|
||||
from E04_Stab3D import E04_Stab3D_Run as run3d
|
||||
from E05_StabReport import E05_StabReport_Area as area
|
||||
from E05_StabReport import E05_StabReport_Figure as figure
|
||||
from E05_StabReport import E05_StabReport_Maps as maps
|
||||
from E05_StabReport import E05_StabReport_Print as printer
|
||||
from E05_StabReport import E05_StabReport_Prob as prob
|
||||
from E05_StabReport import E05_StabReport_Reinforce as reinforce
|
||||
@@ -225,6 +226,7 @@ def gather(project_root: Path) -> dict[str, Any]:
|
||||
"rock": rock.read(project_root),
|
||||
"prob": prob.read(project_root),
|
||||
"fingerprint": analysis_fingerprint(project_root),
|
||||
"project_root": project_root, # 그림 셋이 저장본 격자를 읽음
|
||||
}
|
||||
|
||||
|
||||
@@ -420,7 +422,7 @@ def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]:
|
||||
"x": float(at["center_x"]),
|
||||
"y": float(at["center_y"]),
|
||||
"ok": None if verdict not in ("ok", "ng") else verdict == "ok",
|
||||
"label": at.get("label"),
|
||||
"label": _label_at(labels, float(st["chainage_m"])),
|
||||
}
|
||||
)
|
||||
line = [
|
||||
@@ -460,6 +462,7 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st
|
||||
prob.fill_prob(by["s12"], inputs["prob"], _labels(inputs["stations"]))
|
||||
else: # 확률 해석이 없으면 시트를 뺌
|
||||
book["시트"] = [s for s in book["시트"] if s["id"] != "s12"]
|
||||
jobs += maps.figure_jobs(by, inputs, inputs["project_root"])
|
||||
printer.fill_toc(by["s9"], book, None) # 쪽 번호 = 저장 전 `printer.number_toc`
|
||||
return jobs
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""E05 보고서 그림 셋 — 3D 안전율 지도(E04) · 스테레오넷(E06) · 광역 위험 지도(E07)
|
||||
(요구 390 4 · PLAN 565-2).
|
||||
|
||||
map3d_svg(docs, line, ends, condition) 3D 구간 격자(셀 최소 Fs) · 노선 · 첫 · 끝 측점
|
||||
stereonet_svg(net, title) E06 측점 하나 쪽 평사투영 — 위험 영역 · 대원 · 극점 · 교선
|
||||
area_svg(doc, line, ends) E07 광역 등급(SI) 격자 · 노선 (그림 셋 다 684 × 460)
|
||||
figure_jobs(by, inputs, root) 시트(s6 · s7 · s11) 표 밑에 그림 붙임 · 일감 [(열쇠, SVG)]
|
||||
|
||||
격자는 PNG(data: 주소)로 SVG 안에 — 셀 하나 = 화소 하나(값 셈 없음 · 저장본 그대로 칠함).
|
||||
색 = 화면 범례와 같은 값(E04 `logic/E04_Stab3D_Raster.ts` FS_STEPS ·
|
||||
E07 `logic/E07_StabArea_Raster.ts` CLASS_COLORS) — 한쪽을 고치면 다른 쪽도.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from E04_Stab3D import E04_Stab3D_Run as run3d
|
||||
from E05_StabReport.E05_StabReport_Cells import _label_at, _labels, _set
|
||||
from E05_StabReport.E05_StabReport_Figure import INK, MUTED, _empty, _poly, _text
|
||||
from E06_StabRock import E06_StabRock_Model as rock_model
|
||||
from E07_StabArea.E07_StabArea_Router import load as load_area
|
||||
|
||||
W, H = 684, 460
|
||||
PAD, TOP, LEGEND = 24, 30, 46
|
||||
MAP3D_KEY, STEREONET_KEY, AREA_KEY = "fig/3d_map.png", "fig/stereonet.png", "fig/area_map.png"
|
||||
FS_STEPS = ( # (미만, 색) — E04 화면 FS_STEPS
|
||||
(1.0, "#a50026"),
|
||||
(1.2, "#d73027"),
|
||||
(1.3, "#f46d43"),
|
||||
(1.5, "#fdae61"),
|
||||
(2.0, "#fee08b"),
|
||||
(3.0, "#a6d96a"),
|
||||
(float("inf"), "#1a9850"),
|
||||
)
|
||||
CLASS_COLORS = ("#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027")
|
||||
CLASS_NAMES = ("안정", "보통 안정", "준안정", "하한 불안정", "상한 불안정", "불안정")
|
||||
CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
|
||||
ROCK_ORDER = {"NG": 0, "check": 1, "OK": 2, "none": 3}
|
||||
ZONE_COLORS = {"planar": "#d73027", "wedge": "#fc8d59", "toppling": "#7b1fa2"}
|
||||
ZONE_NAMES = {"planar": "평면", "wedge": "쐐기", "toppling": "전도"}
|
||||
JOINT_COLORS = ("#1565c0", "#2e7d32", "#ef6c00", "#6a1b9a", "#00838f", "#5d4037")
|
||||
|
||||
|
||||
def _rgb(hex_color: str) -> tuple[int, int, int]:
|
||||
n = int(hex_color[1:], 16)
|
||||
return (n >> 16) & 255, (n >> 8) & 255, n & 255
|
||||
|
||||
|
||||
def _png_uri(rgba: np.ndarray) -> str:
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def _paint(values: np.ndarray, colors: list[tuple[int, int, int]]) -> np.ndarray:
|
||||
"""색 번호 격자(-1 = 비움 · 줄 0 = 남쪽) → RGBA(줄 0 = 북쪽)."""
|
||||
rgba = np.zeros((*values.shape, 4), np.uint8)
|
||||
for k, color in enumerate(colors):
|
||||
rgba[values == k] = (*color, 255)
|
||||
return rgba[::-1]
|
||||
|
||||
|
||||
def _frame(boxes: list[tuple[float, float, float, float]]):
|
||||
"""모델 상자들(x0, y0, x1, y1) → 화면 변환(북 = 위 · 같은 축척)."""
|
||||
x0 = min(b[0] for b in boxes)
|
||||
y0 = min(b[1] for b in boxes)
|
||||
x1 = max(b[2] for b in boxes)
|
||||
y1 = max(b[3] for b in boxes)
|
||||
w, h = W - 2 * PAD, H - TOP - LEGEND - PAD / 2
|
||||
scale = min(w / max(x1 - x0, 1e-6), h / max(y1 - y0, 1e-6))
|
||||
ox = PAD + (w - (x1 - x0) * scale) / 2
|
||||
oy = TOP + (h - (y1 - y0) * scale) / 2
|
||||
|
||||
def sp(x: float, y: float) -> tuple[float, float]:
|
||||
return ox + (x - x0) * scale, oy + (y1 - y) * scale
|
||||
|
||||
return sp, scale
|
||||
|
||||
|
||||
def _grid_box(grid: dict[str, Any]) -> tuple[float, float, float, float]:
|
||||
half = grid["dx"] / 2
|
||||
return (
|
||||
grid["x0"] - half,
|
||||
grid["y0"] - half,
|
||||
grid["x0"] + (grid["nx"] - 0.5) * grid["dx"],
|
||||
grid["y0"] + (grid["ny"] - 0.5) * grid["dx"],
|
||||
)
|
||||
|
||||
|
||||
def _image(grid: dict[str, Any], uri: str, sp, scale: float) -> str:
|
||||
x0, _, _, y1 = _grid_box(grid)
|
||||
a, b = sp(x0, y1)
|
||||
return (
|
||||
f"<image x='{a:.1f}' y='{b:.1f}' width='{grid['nx'] * grid['dx'] * scale:.1f}' "
|
||||
f"height='{grid['ny'] * grid['dx'] * scale:.1f}' preserveAspectRatio='none' "
|
||||
f"style='image-rendering:pixelated' href='{uri}'/>"
|
||||
)
|
||||
|
||||
|
||||
def _head(title: str) -> list[str]:
|
||||
return [
|
||||
f"<svg xmlns='http://www.w3.org/2000/svg' width='{W}' height='{H}' viewBox='0 0 {W} {H}'>",
|
||||
f"<rect width='{W}' height='{H}' fill='#ffffff'/>",
|
||||
_text(W / 2, 18, title, 13, font_weight="bold", fill=INK),
|
||||
]
|
||||
|
||||
|
||||
def _route(line: list[tuple[float, float]], ends: list[tuple[float, float, str]], sp) -> list[str]:
|
||||
out = [_poly([sp(x, y) for x, y in line], stroke=INK, stroke_width="1.6")]
|
||||
for x, y, label in ends:
|
||||
a, b = sp(x, y)
|
||||
out.append(f"<circle cx='{a:.1f}' cy='{b:.1f}' r='3' fill='{INK}'/>")
|
||||
out.append(
|
||||
_text(
|
||||
a,
|
||||
b - 7,
|
||||
label,
|
||||
10,
|
||||
fill=INK,
|
||||
stroke="#ffffff",
|
||||
stroke_width="3",
|
||||
paint_order="stroke",
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _legend(items: list[tuple[str, str]], kind: str = "box") -> list[str]:
|
||||
out, lx, ly = [], PAD, H - 18
|
||||
for color, label in items:
|
||||
if kind == "line":
|
||||
out.append(
|
||||
f"<line x1='{lx}' y1='{ly - 4}' x2='{lx + 16}' y2='{ly - 4}' stroke='{color}' "
|
||||
"stroke-width='2'/>"
|
||||
)
|
||||
else:
|
||||
out.append(f"<rect x='{lx}' y='{ly - 10}' width='14' height='10' fill='{color}'/>")
|
||||
out.append(_text(lx + 19, ly, label, 10, "start", fill=INK))
|
||||
lx += 26 + 6.5 * len(label) + 4 * sum(ord(ch) > 0x2000 for ch in label)
|
||||
return out
|
||||
|
||||
|
||||
def map3d_svg(docs: list[dict[str, Any]], line, ends, condition: str) -> str:
|
||||
title = f"3D 안전율 지도 — {CONDITION_NAMES.get(condition, condition)} · 셀별 최소 Fs"
|
||||
if not docs:
|
||||
return _empty(W, H, title)
|
||||
boxes = [_grid_box(d["grid"]) for d in docs] + [(x, y, x, y) for x, y in line]
|
||||
sp, scale = _frame(boxes)
|
||||
out = _head(title)
|
||||
colors = [_rgb(c) for _, c in FS_STEPS]
|
||||
for doc in docs:
|
||||
g = doc["grid"]
|
||||
f = np.array([np.nan if v is None else v for v in doc["f"]], float).reshape(
|
||||
g["ny"], g["nx"]
|
||||
)
|
||||
steps = np.searchsorted([b for b, _ in FS_STEPS[:-1]], f, side="right")
|
||||
steps[~np.isfinite(f)] = -1
|
||||
out.append(_image(g, _png_uri(_paint(steps, colors)), sp, scale))
|
||||
out += _route(line, ends, sp)
|
||||
labels = ["< 1.0", "1.0~1.2", "1.2~1.3", "1.3~1.5", "1.5~2.0", "2.0~3.0", "≥ 3.0"]
|
||||
out += _legend([(c, t) for (_, c), t in zip(FS_STEPS, labels)])
|
||||
out.append("</svg>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def area_svg(doc: dict[str, Any] | None, line, ends) -> str:
|
||||
title = "광역 자연사면 위험 지도 — 안정 등급(SI)"
|
||||
if not doc:
|
||||
return _empty(W, H, title)
|
||||
g = doc["grid"]
|
||||
cls = np.frombuffer(base64.b64decode(doc["cls"]), np.uint8).reshape(g["ny"], g["nx"])
|
||||
sp, scale = _frame([_grid_box(g)] + [(x, y, x, y) for x, y in line])
|
||||
out = _head(title)
|
||||
rgba = _paint(cls.astype(int) - 1, [_rgb(c) for c in CLASS_COLORS])
|
||||
out.append(_image(g, _png_uri(rgba), sp, scale))
|
||||
out += _route(line, ends, sp)
|
||||
out += _legend(list(zip(CLASS_COLORS, CLASS_NAMES)))
|
||||
out.append("</svg>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def stereonet_svg(net: dict[str, Any], title: str) -> str:
|
||||
"""하반구 투영 — 반지름 1 원 · x = 동 · y = 북."""
|
||||
r0 = (H - TOP - LEGEND - 10) / 2
|
||||
cx, cy = W / 2, TOP + 6 + r0
|
||||
|
||||
def sp(x: float, y: float) -> tuple[float, float]:
|
||||
return cx + x * r0, cy - y * r0
|
||||
|
||||
def path(points: list[list[float]], closed: bool = False) -> str:
|
||||
d = " ".join(f"{a:.1f},{b:.1f}" for a, b in (sp(*p) for p in points))
|
||||
return f"M{d}{' Z' if closed else ''}"
|
||||
|
||||
out = _head(title)
|
||||
out.append(f"<circle cx='{cx}' cy='{cy}' r='{r0:.1f}' fill='#ffffff' stroke='{INK}'/>")
|
||||
for mode, zone in (net.get("zones") or {}).items():
|
||||
if zone:
|
||||
color = ZONE_COLORS[mode]
|
||||
out.append(
|
||||
f"<path d='{path(zone, True)}' fill='{color}' fill-opacity='0.18' "
|
||||
f"stroke='{color}' stroke-opacity='0.5'/>"
|
||||
)
|
||||
for r, dash in ((net["friction_pole_r"], "5 3"), (net["friction_line_r"], "2 3")):
|
||||
out.append(
|
||||
f"<circle cx='{cx}' cy='{cy}' r='{r * r0:.1f}' fill='none' stroke='{MUTED}' "
|
||||
f"stroke-dasharray='{dash}'/>"
|
||||
)
|
||||
out.append(
|
||||
f"<path d='{path(net['slope']['great_circle'])}' fill='none' stroke='{INK}' "
|
||||
"stroke-width='2.4'/>"
|
||||
)
|
||||
legend = [(INK, "비탈면")]
|
||||
for k, plane in enumerate(net.get("planes") or []):
|
||||
color = JOINT_COLORS[k % len(JOINT_COLORS)]
|
||||
out.append(
|
||||
f"<path d='{path(plane['great_circle'])}' fill='none' stroke='{color}' "
|
||||
"stroke-width='1.4'/>"
|
||||
)
|
||||
a, b = sp(*plane["pole"])
|
||||
out.append(f"<circle cx='{a:.1f}' cy='{b:.1f}' r='3.5' fill='{color}'/>")
|
||||
legend.append((color, plane.get("name") or plane["id"]))
|
||||
for item in net.get("intersections") or []:
|
||||
a, b = sp(*item["point"])
|
||||
out.append(
|
||||
f"<rect x='{a - 3:.1f}' y='{b - 3:.1f}' width='6' height='6' fill='none' "
|
||||
f"stroke='{INK}'/>"
|
||||
)
|
||||
out.append(_text(cx, cy - r0 - 4, "N", 11, font_weight="bold", fill=INK))
|
||||
out += _legend(legend, "line")
|
||||
zx = W - PAD
|
||||
for mode in ("toppling", "wedge", "planar"):
|
||||
label = f"{ZONE_NAMES[mode]} 위험 영역"
|
||||
out.append(_text(zx, H - 34, label, 10, "end", fill=ZONE_COLORS[mode]))
|
||||
zx -= 14 + 11 * len(label)
|
||||
out.append("</svg>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ── 시트에 붙임 ──────────────────────────────────────────────────────────────
|
||||
def _last_row(sheet: dict[str, Any]) -> int:
|
||||
rows = [int("".join(ch for ch in a1 if ch.isdigit())) for a1 in sheet["칸"]]
|
||||
for fig in sheet.get("그림") or []:
|
||||
rows.append(int("".join(ch for ch in fig["닻"].split(":")[-1] if ch.isdigit())))
|
||||
return max(rows, default=0)
|
||||
|
||||
|
||||
def _attach(sheet: dict[str, Any], key: str, caption: str) -> None:
|
||||
"""표 밑 한 줄 띄고 「n. 제목」 줄(서식 3 · A:H 병합) + 그림 한 줄(그림 높이 ·
|
||||
한 행이라 쪽에서 안 끊김)."""
|
||||
n = 1 + sum(
|
||||
1
|
||||
for c in sheet["칸"].values()
|
||||
if c.get("서식") == 3 and str(c.get("값") or "")[:1].isdigit()
|
||||
)
|
||||
r = _last_row(sheet) + 2
|
||||
_set(sheet, f"A{r}", f"{n}. {caption}", 3)
|
||||
sheet.setdefault("병합", []).append(f"A{r}:H{r}")
|
||||
rows = sheet.setdefault("행", {})
|
||||
rows[str(r)] = {"높이": 22}
|
||||
rows[str(r + 1)] = {"높이": round(H * 0.75)}
|
||||
sheet.setdefault("그림", []).append({"열쇠": key, "닻": f"A{r + 1}:H{r + 1}", "이름": caption})
|
||||
|
||||
|
||||
def _line_ends(inputs: dict[str, Any]):
|
||||
line = [(float(p["model_x"]), float(p["model_y"])) for p in inputs["route_points"]]
|
||||
labels = _labels(inputs["stations"])
|
||||
ends = []
|
||||
for p in (inputs["route_points"][0], inputs["route_points"][-1]) if line else ():
|
||||
ends.append(
|
||||
(float(p["model_x"]), float(p["model_y"]), _label_at(labels, float(p["chainage_m"])))
|
||||
)
|
||||
return line, ends
|
||||
|
||||
|
||||
def _rock_pick(rock: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""스테레오넷을 그릴 측점 — 판정 나쁜 쪽 · 같으면 최소 Fs 낮은 쪽."""
|
||||
entries = [e for e in rock.get("stations") or [] if e.get("joints")]
|
||||
return min(
|
||||
entries,
|
||||
key=lambda e: (
|
||||
ROCK_ORDER.get(e.get("verdict"), 9),
|
||||
e.get("min_F") is None,
|
||||
e.get("min_F") or 0,
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def figure_jobs(by: dict[str, dict], inputs: dict[str, Any], root: Path) -> list[tuple[str, str]]:
|
||||
"""결과가 있는 그림만 시트에 붙이고 그림 일감 — 3D(s6) · 광역(s7) · 암반(s11)."""
|
||||
jobs: list[tuple[str, str]] = []
|
||||
line, ends = _line_ends(inputs)
|
||||
secs = inputs["sections3d"]
|
||||
if secs:
|
||||
conds = {s.get("condition") or "dry" for s in secs}
|
||||
cond = "dry" if "dry" in conds else sorted(conds)[0]
|
||||
docs = [run3d.load(root, s["key"]) for s in secs if (s.get("condition") or "dry") == cond]
|
||||
docs = [d for d in docs if d and d.get("grid") and d.get("f")]
|
||||
if docs:
|
||||
_attach(by["s6"], MAP3D_KEY, "3D 안전율 지도")
|
||||
jobs.append((MAP3D_KEY, map3d_svg(docs, line, ends, cond)))
|
||||
if inputs.get("area"):
|
||||
doc = load_area(root)
|
||||
if doc and doc.get("grid") and doc.get("cls"):
|
||||
_attach(by["s7"], AREA_KEY, "광역 위험 지도")
|
||||
jobs.append((AREA_KEY, area_svg(doc, line, ends)))
|
||||
rock = inputs.get("rock")
|
||||
entry = _rock_pick(rock) if rock else None
|
||||
if entry:
|
||||
settings = rock["settings"]
|
||||
kind = settings.get("projection") or "equal_angle"
|
||||
net = rock_model.station_net(entry, settings)[kind]
|
||||
names = {j["id"]: j.get("name") or j["id"] for j in entry["joints"]}
|
||||
for plane in net.get("planes") or []:
|
||||
plane["name"] = names.get(plane["id"], plane["id"])
|
||||
label = _label_at(_labels(inputs["stations"]), float(entry["chainage_m"]))
|
||||
side = {"left": "좌", "right": "우"}.get(entry["slope"].get("side"), "")
|
||||
proj = "등각(Wulff)" if kind == "equal_angle" else "등면적(Schmidt)"
|
||||
caption = f"평사투영 — {label} {side} · 하반구 {proj}"
|
||||
_attach(by["s11"], STEREONET_KEY, caption)
|
||||
jobs.append((STEREONET_KEY, stereonet_svg(net, caption)))
|
||||
return jobs
|
||||
@@ -1,7 +1,8 @@
|
||||
"""E05 안정성평가 보고서 API (요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
|
||||
|
||||
- `GET /api/projects/{id}/stab/report` — 작업본(없고 2D 결과가 있으면 처음 만듦 + 초기값) · 판 ·
|
||||
해석 지문이 바뀌었는지(`stale`) · 고칠 수 있는지
|
||||
해석 지문이 바뀌었는지(`stale`) · 고칠 수 있는지 · 고칠 수 있으면 낡은 작업본을 저절로 갱신
|
||||
(`refreshed` · `overwritten` — 페이지 단독 [해석] 뒤)
|
||||
- `PUT …/stab/report` — [저장] `{rev, book}` · 판이 다르면 409 · 서버 재계산(`with_server_values`)
|
||||
- `POST …/stab/report/refresh` — [보고서 갱신] `{rev}` · 프로그램 몫 시트 · 그림만 다시 ·
|
||||
덮인 시트에 사용자 고침이 있었으면 `overwritten` + 옛 판 `_지난/`
|
||||
@@ -97,14 +98,23 @@ def _state(root: Path) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _ensure(root: Path) -> dict[str, Any]:
|
||||
"""작업본이 없으면 처음 만듦(2D 결과가 없으면 빈 상태 · 까닭)."""
|
||||
def _ensure(root: Path, refresh: bool = False) -> dict[str, Any]:
|
||||
"""작업본이 없으면 처음 만듦(2D 결과가 없으면 빈 상태 · 까닭) · `refresh` 면 페이지 단독
|
||||
[해석] 으로 낡은 작업본을 저절로 갱신(`refreshed` · 덮인 시트 `overwritten`)."""
|
||||
if store.load(root)[0] is None:
|
||||
try:
|
||||
store.create(root)
|
||||
except build.MissingInput as error:
|
||||
return {"book": None, "reason": str(error)}
|
||||
return _state(root)
|
||||
return _state(root)
|
||||
state = _state(root)
|
||||
if not (refresh and state["stale"]):
|
||||
return state
|
||||
try:
|
||||
result = store.refresh(root)
|
||||
except build.MissingInput:
|
||||
return state
|
||||
return {**_state(root), "refreshed": True, "overwritten": result["overwritten"]}
|
||||
|
||||
|
||||
def _recalc_errors(error: Exception) -> HTTPException:
|
||||
@@ -118,12 +128,13 @@ async def get_report(
|
||||
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
root, company_id = await _project(project_id)
|
||||
editable = _editable(session, company_id)
|
||||
async with _lock(project_id):
|
||||
try:
|
||||
state = await asyncio.to_thread(_ensure, root)
|
||||
state = await asyncio.to_thread(_ensure, root, editable)
|
||||
except (RecalcError, PrintError) as error:
|
||||
raise _recalc_errors(error) from error
|
||||
return {"status": "success", "editable": _editable(session, company_id), **state}
|
||||
return {"status": "success", "editable": editable, **state}
|
||||
|
||||
|
||||
@router.put("/{project_id}/stab/report", response_model=None)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴).
|
||||
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) ·
|
||||
* [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시).
|
||||
* 열 때 해석이 바뀌었으면 서버가 저절로 갱신(`refreshed` · 덮인 시트 `overwritten` 알림).
|
||||
* [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -42,6 +43,7 @@ interface ReportResponse {
|
||||
editable?: boolean;
|
||||
reason?: string;
|
||||
overwritten?: string[];
|
||||
refreshed?: boolean;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -317,7 +319,12 @@ export function renderE05StabReport(root: HTMLElement): void {
|
||||
|
||||
void run(async () => {
|
||||
try {
|
||||
await adopt(await request(`/projects/${projectId}/stab/report`), true);
|
||||
const body: ReportResponse = await request(`/projects/${projectId}/stab/report`);
|
||||
await adopt(body, true);
|
||||
// 페이지 단독 [해석] 뒤 서버가 저절로 갱신(565-2)
|
||||
const over = body.overwritten ?? [];
|
||||
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
|
||||
else if (body.refreshed) showToast(t("E05_Refreshed"), "success");
|
||||
} catch (error) {
|
||||
sheetHost.replaceChildren(
|
||||
emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")),
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
목차 쪽 번호 = 실제 · 부록 절편 · 측점표 쪽 나눔 고르게 · 옛 작업본 갱신(목차 자리 · 서식 붙임).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
@@ -21,6 +22,7 @@ from common_util.common_util_workbook_xlsx import workbook_to_xlsx
|
||||
from E02_StabGround import E02_StabGround_Model as ground_model
|
||||
from E05_StabReport import E05_StabReport_Build as build
|
||||
from E05_StabReport import E05_StabReport_Figure as figure
|
||||
from E05_StabReport import E05_StabReport_Maps as maps
|
||||
from E05_StabReport import E05_StabReport_Print as printer
|
||||
from E05_StabReport import E05_StabReport_Store as store
|
||||
|
||||
@@ -175,6 +177,8 @@ def project(tmp_path, monkeypatch) -> Path:
|
||||
{
|
||||
"fingerprint": "3d-1",
|
||||
"settings": {},
|
||||
"grid": {"x0": 0.0, "y0": 0.0, "dx": 10.0, "nx": 3, "ny": 2}, # 565-2 3D 지도
|
||||
"f": [1.1, 1.6, None, 2.5, 0.9, 3.2],
|
||||
"summary": {
|
||||
"start_m": 0.0,
|
||||
"end_m": 200.0,
|
||||
@@ -252,7 +256,14 @@ def test_처음_만듦_측점수_NG_그림(project):
|
||||
assert [toc[f"B{r}"]["값"] for r in (4, 5)] == [printer.TITLES["s2"], printer.TITLES["s3"]]
|
||||
assert toc["A10"]["값"] == "부록" # 보강검토 없음 → 본문 6 + 부록 and toc["C4"]["값"] == 2
|
||||
figs = sorted((project / "E05_StabReport" / "fig").glob("*.png"))
|
||||
assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY
|
||||
assert len(figs) == len(REPS) + 2 and meta["figures"][-2:] == [build.BAND_KEY, maps.MAP3D_KEY]
|
||||
# 565-2 3D 안전율 지도 = 3D요약 표 밑 「3. …」 줄 + 그림 한 행(우기 구간뿐 → 우기)
|
||||
pic = by["3D요약"]["그림"][-1]
|
||||
assert pic["열쇠"] == maps.MAP3D_KEY and pic["닻"] == "A23:H23"
|
||||
assert by["3D요약"]["칸"]["A22"]["값"] == "3. 3D 안전율 지도"
|
||||
assert "광역요약" not in [
|
||||
s["이름"] for s in book["시트"] if s.get("그림")
|
||||
] # 광역 전 = 그림 없음
|
||||
assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS]
|
||||
assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84]
|
||||
assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상"
|
||||
@@ -297,6 +308,8 @@ def test_광역요약_E07_저장본(project):
|
||||
"level_hi_pct": 20.0,
|
||||
},
|
||||
"computed_at": "2026-10-25T21:00:00",
|
||||
"grid": {"x0": 0.0, "y0": 0.0, "dx": 2.0, "nx": 2, "ny": 2}, # 565-2 광역 지도
|
||||
"cls": base64.b64encode(bytes([1, 6, 0, 4])).decode("ascii"),
|
||||
"stations": [
|
||||
{
|
||||
"chainage_m": 0.0,
|
||||
@@ -348,7 +361,12 @@ def test_광역요약_E07_저장본(project):
|
||||
book, _ = store.load(project)
|
||||
s7 = _sheets(book)["광역요약"]["칸"]
|
||||
assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0]
|
||||
assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["1+0", "2+0"] and "A24" not in s7
|
||||
assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["1+0", "2+0"]
|
||||
assert s7["A25"]["값"] == "4. 광역 위험 지도" # 565-2 위험 측점 표 밑
|
||||
assert _sheets(book)["광역요약"]["그림"] == [
|
||||
{"열쇠": maps.AREA_KEY, "닻": "A26:H26", "이름": "광역 위험 지도"}
|
||||
]
|
||||
assert build.fig_path(build.stage_dir(project), maps.AREA_KEY).is_file()
|
||||
assert (
|
||||
s7["E22"]["값"] == "높음 (100.0 %)"
|
||||
and s7["E23"]["값"] == "보통 (5.6 %)"
|
||||
@@ -439,12 +457,12 @@ def test_xlsx_되읽기_pdf_쪽수(project):
|
||||
_create(project)
|
||||
book, _ = store.load(project)
|
||||
figs = store.figures_of(project, book)
|
||||
assert len(figs) == len(REPS) + 1
|
||||
assert len(figs) == len(REPS) + 2 # 대표 단면 · 위험 띠 · 3D 지도(565-2)
|
||||
wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
|
||||
assert wb.sheetnames == [
|
||||
"표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "부록_절편",
|
||||
] # fmt: skip
|
||||
assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1
|
||||
assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 2
|
||||
assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84]
|
||||
assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith(
|
||||
"FFC7CE"
|
||||
@@ -458,8 +476,9 @@ def test_xlsx_되읽기_pdf_쪽수(project):
|
||||
texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages]
|
||||
stab = [i for i, t in enumerate(texts) if "산정 안전율" in t]
|
||||
assert len(stab) == len(book["시트"][4]["인쇄"]["쪽나눔"]) + 1 # 측점표 = 나눈 덩이 수
|
||||
# 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 + 부록 3
|
||||
assert len(texts) == 4 + len(stab) + len(REPS) + 1 + 1 + len(REPS)
|
||||
# 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 2(지도) + 광역 1 + 부록 3
|
||||
assert len(texts) == 4 + len(stab) + len(REPS) + 2 + 1 + len(REPS)
|
||||
assert sum("3. 3D 안전율 지도" in t for t in texts) == 1 # 565-2 그림 제목 줄
|
||||
body = len(texts) - 1
|
||||
assert f"/ {body}" not in texts[0] and "검증 임도" not in texts[0].split("보고서")[0]
|
||||
assert all(f"- {i} / {body} -" in texts[i] and "검증 임도" in texts[i] for i in range(1, body))
|
||||
@@ -524,6 +543,16 @@ def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch):
|
||||
assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지"
|
||||
back = client.post(f"{url}/reset").json()
|
||||
assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {})
|
||||
# 565-2 페이지 단독 [해석](3D 저장본 바뀜) 뒤 열면 저절로 갱신 · 보기만 회사는 띠만
|
||||
r3 = project / "E04_Stab3D" / "results" / "0.00_200.00_wet.json"
|
||||
r3.write_text(r3.read_text(encoding="utf-8").replace("1.42", "1.40"), encoding="utf-8")
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 9}
|
||||
view = client.get(url).json()
|
||||
assert view["stale"] is True and "refreshed" not in view
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3}
|
||||
auto = client.get(url).json()
|
||||
assert auto["refreshed"] is True and auto["stale"] is False and auto["overwritten"] == []
|
||||
assert _sheets(auto["book"])["3D요약"]["칸"]["D20"]["값"] == 1.40
|
||||
|
||||
|
||||
def test_서명_화면_왕복_같음(project):
|
||||
@@ -534,3 +563,26 @@ def test_서명_화면_왕복_같음(project):
|
||||
assert s3["칸"]["B22"]["값"] == 10.0
|
||||
trip = json.loads(json.dumps(s3).replace("10.0", "10"))
|
||||
assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3)
|
||||
|
||||
|
||||
def test_565_2_스테레오넷_측점_고름_그림():
|
||||
"""암반 그림 = 판정 나쁜 측점(NG > 검토 > O.K) · 같으면 최소 Fs 낮은 쪽."""
|
||||
from E06_StabRock import E06_StabRock_Model as rock_model
|
||||
|
||||
joints = [
|
||||
{"id": "J1", "name": "J1", "dip_direction": 115.0, "dip": 45.0, "phi": 30.0, "c": 0.0},
|
||||
{"id": "J2", "name": "J2", "dip_direction": 200.0, "dip": 70.0, "phi": 30.0, "c": 0.0},
|
||||
]
|
||||
slope = {"dip_direction": 110.0, "dip": 70.0, "side": "left", "height_m": 8.0}
|
||||
rows = [
|
||||
{"chainage_m": 0.0, "verdict": "OK", "min_F": 0.9, "joints": joints, "slope": slope},
|
||||
{"chainage_m": 20.0, "verdict": "NG", "min_F": 1.2, "joints": joints, "slope": slope},
|
||||
{"chainage_m": 40.0, "verdict": "NG", "min_F": 1.05, "joints": joints, "slope": slope},
|
||||
]
|
||||
assert maps._rock_pick({"stations": rows})["chainage_m"] == 40.0
|
||||
net = rock_model.station_net(
|
||||
rows[2], {"lateral_planar_deg": 20.0, "lateral_toppling_deg": 10.0}
|
||||
)
|
||||
svg = maps.stereonet_svg(net["equal_angle"], "평사투영")
|
||||
assert svg.count("r='3.5'") == 2 and "평면 위험 영역" in svg # 극점 둘
|
||||
assert svg.count("<path") >= 4 # 비탈 · 절리 대원 둘 · 위험 영역
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user