Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-27 20:04:01 +09:00
17 changed files with 379 additions and 213 deletions
+62 -42
View File
@@ -1,16 +1,17 @@
"""D 예정노선 1단계 기준 지정 — 연도별 시점 · 종점 정본 API (요구 345 · 요구서 3-2 · PLAN 529-3).
"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 두 점 정본 API (요구서 389 3장 1 · PLAN 560-2).
- `GET /api/projects/{id}/plan/points` — 사업연도 연도마다 시점 · 종점(경위도 + TM) · 빈 = null
- `PUT …/plan/points` — 경위도를 받아 서버가 TM 을 셈 → `plan_route_years` 행
- `GET /api/projects/{id}/plan/points` — {epsg, start, end} · 점 = {lon, lat, x, y} · 빈 = null
- `PUT …/plan/points` — {start: {lon, lat}, end: {lon, lat}} → TM 셈 → `projects.plan_base_json`
(`{"epsg", "start": {"x", "y"}, "end": {"x", "y"}}` · 좌표계 = 두 점 평균 경도의 TM 띠)
- 칸이 비면(옛 프로젝트) 첫 연도 시점 + 마지막 연도 종점으로 접어 보임 · 연도 행은 D03 [저장] 몫.
- 고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
규칙: 직전 연도 종점 = 다음 연도 시점(서버가 맞춤 · 보낸 시점은 첫 연도만 씀) ·
범위 밖 · 빈 연도 거절 · 좌표계 = 모든 지점 평균 경도의 TM 띠 한 벌 ·
지점이 바뀐 연도는 분석 노선(route_json · fingerprint · confirmed_at) 비움 ·
고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
연도 잇기 꼴(`chain_years` · `with_tm` · `changed` · `_store`)은 D03 [저장]이 씀.
"""
from __future__ import annotations
import json
from typing import Any
from uuid import UUID
@@ -38,8 +39,9 @@ class YearPoints(BaseModel):
end: LonLat | None = None
class PointsRequest(BaseModel):
years: list[YearPoints]
class BaseRequest(BaseModel):
start: LonLat | None = None
end: LonLat | None = None
def chain_years(year_start: int, year_end: int, items: list[YearPoints]) -> list[dict[str, Any]]:
@@ -94,7 +96,7 @@ async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, A
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(
"SELECT project_kind, project_year, project_year_end FROM projects"
"SELECT project_kind, project_year, project_year_end, plan_base_json FROM projects"
" WHERE id = %s AND deleted_at IS NULL",
(str(project_id),),
)
@@ -157,50 +159,68 @@ def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None:
return {"lon": lon, "lat": lat, "x": float(x), "y": float(y)}
def _view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
start = int(project["project_year"])
end = int(project["project_year_end"] or start)
years = []
for year in range(start, end + 1):
row = rows.get(year) or {}
years.append(
{
"year": year,
"start": _point(row.get("start_x"), row.get("start_y"), row.get("epsg")),
"end": _point(row.get("end_x"), row.get("end_y"), row.get("epsg")),
}
)
epsgs = {int(r["epsg"]) for r in rows.values() if r.get("epsg") is not None}
def base_view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""{epsg, start, end} — 정본 칸 · 비면 첫 연도 시점 + 마지막 연도 종점으로 접음."""
stored = project.get("plan_base_json")
if isinstance(stored, str):
stored = json.loads(stored)
if stored:
epsg, start, end = stored.get("epsg"), stored.get("start"), stored.get("end")
elif rows:
first, last = rows[min(rows)], rows[max(rows)]
epsg = first.get("epsg")
start = {"x": first.get("start_x"), "y": first.get("start_y")}
end = {"x": last.get("end_x"), "y": last.get("end_y")}
else:
epsg, start, end = None, None, None
return {
"status": "success",
"year_start": start,
"year_end": end,
"epsg": epsgs.pop() if len(epsgs) == 1 else None,
"years": years,
"epsg": epsg,
"start": _point(start["x"], start["y"], epsg) if start else None,
"end": _point(end["x"], end["y"], epsg) if end else None,
}
async def load_base(project_id: UUID) -> dict[str, Any]:
"""전체 시점 · 종점 — D03 이 읽는 길."""
project, rows = await _load(project_id)
return base_view(project, rows)
def stored_base(payload: BaseRequest) -> dict[str, Any]:
"""PUT 몸 → 정본 칸 꼴(TM m)."""
given = [p for p in (payload.start, payload.end) if p]
epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given)) if given else None
def tm(p: LonLat | None) -> dict[str, float] | None:
if p is None or epsg is None:
return None
x, y = lonlat_to_tm(p.lon, p.lat, epsg)
return {"x": x, "y": y}
return {"epsg": epsg, "start": tm(payload.start), "end": tm(payload.end)}
async def _store_base(project_id: UUID, base: dict[str, Any]) -> None:
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"UPDATE projects SET plan_base_json = %s WHERE id = %s",
(json.dumps(base), str(project_id)),
)
@router.get("/{project_id}/plan/points", response_model=None)
async def get_plan_points(project_id: UUID) -> dict[str, Any]:
project, rows = await _load(project_id)
return _view(project, rows)
return {"status": "success", **await load_base(project_id)}
@router.put("/{project_id}/plan/points", response_model=None)
async def put_plan_points(
project_id: UUID,
payload: PointsRequest,
payload: BaseRequest,
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
if not is_forest_service(session):
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
project, old = await _load(project_id)
start = int(project["project_year"])
end = int(project["project_year_end"] or start)
epsg, rows = with_tm(chain_years(start, end, payload.years))
writes = [row for row in rows if changed(row, old.get(row["year"]), epsg)]
drops = [year for year in old if not start <= year <= end]
if writes or drops:
await _store(project_id, epsg, writes, drops)
_, now = await _load(project_id)
return {**_view(project, now), "changed_years": [row["year"] for row in writes]}
await _load(project_id) # 없음 · 예정노선 아님 거절
await _store_base(project_id, stored_base(payload))
return {"status": "success", **await load_base(project_id)}
+20 -37
View File
@@ -1,7 +1,6 @@
/* =============================================================================
* D02_PlanBase_UI_Page.ts
* D02 1단계 기준 지정(요구서 345 3-2 · PLAN 529-3) — 메인 = 카카오맵 · 좌 = 연도 지점 표.
* 지점 = 경계점 목록 P0..Pn(연도 i = P(i) → P(i+1)) · 직전 종점 = 다음 시점이 모양 그대로.
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점.
* 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
* · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장).
* 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · [>] = 그냥 D03.
@@ -34,13 +33,11 @@ interface ServerPoint extends LonLat {
y: number;
}
interface PointsView {
year_start: number;
year_end: number;
epsg: number | null;
years: Array<{ year: number; start: ServerPoint | null; end: ServerPoint | null }>;
start: ServerPoint | null;
end: ServerPoint | null;
}
const YEAR_COLORS = ["#E60046", "#1F6FEB", "#1A9E55", "#D97706", "#8B5CF6", "#0891B2"];
const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
@@ -58,10 +55,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return data as T;
}
/** 서버 연도 행 → 경계점 목록(연도 수 + 1) */
/** 서버 두 점 → [시점, 종점] */
function boundaryPoints(view: PointsView): Array<LonLat | null> {
const pick = (p: ServerPoint | null): LonLat | null => (p ? { lon: p.lon, lat: p.lat } : null);
return [pick(view.years[0]?.start ?? null), ...view.years.map((y) => pick(y.end))];
return [pick(view.start), pick(view.end)];
}
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
@@ -88,13 +85,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
let active: number | null = null; // 지도 누름이 놓을 지점
let editable = false; // 산림청만 고침
const years = (): number[] => (view ? view.years.map((y) => y.year) : []);
const pointName = (i: number): string => {
const ys = years();
if (i === 0) return `${ys[0]} ${t("D02_Start")}`;
if (i === ys.length) return `${ys[i - 1]} ${t("D02_End")}`;
return `${ys[i - 1]} ${t("D02_End")} · ${ys[i]} ${t("D02_Start")}`;
};
const pointName = (i: number): string => t(i === 0 ? "D02_Start" : "D02_End");
const firstEmpty = (): number | null => {
const i = points.findIndex((p) => !p);
return i < 0 ? null : i;
@@ -126,19 +117,17 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
const pins: MapPin[] = points.flatMap((p, i) =>
p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [],
);
const lines: Array<Array<[number, number]>> = [];
const colors: string[] = [];
years().forEach((_, i) => {
const a = points[i];
const b = points[i + 1];
if (!a || !b) return;
lines.push([
[a.lat, a.lon],
[b.lat, b.lon],
]);
colors.push(YEAR_COLORS[i % YEAR_COLORS.length]);
});
map.set(pins, lines, colors, focus);
const [a, b] = points;
const lines: Array<Array<[number, number]>> =
a && b
? [
[
[a.lat, a.lon],
[b.lat, b.lon],
],
]
: [];
map.set(pins, lines, undefined, focus);
}
function drawTable(): void {
@@ -186,17 +175,13 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
lat,
],
});
const color = YEAR_COLORS[Math.min(i, years().length - 1) % YEAR_COLORS.length];
row.style.setProperty("--d02-year-color", color);
return row;
});
table.replaceChildren(...rows);
const saved = view?.years.find((y) => y.start);
const saved = view?.start;
info.textContent = view?.epsg
? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` +
(saved?.start
? ` · ${saved.year} X ${saved.start.x.toFixed(3)} Y ${saved.start.y.toFixed(3)}`
: "")
(saved ? ` · X ${saved.x.toFixed(3)} Y ${saved.y.toFixed(3)}` : "")
: t("D02_Not_Saved");
}
@@ -214,9 +199,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
if (!view) return false;
showLoadingOverlay();
try {
const body = {
years: years().map((year, i) => ({ year, start: points[i], end: points[i + 1] })),
};
const body = { start: points[0], end: points[1] };
adopt(
await request<PointsView>(`/projects/${projectId}/plan/points`, {
method: "PUT",
+2 -2
View File
@@ -1,4 +1,4 @@
/* D02 기준 지정(529-3) — 메인 지도 = 본문 높이 가득 · 좌 연도 지점 표 */
/* D02 기준 지정(529-3 · 560-2) — 메인 지도 = 본문 높이 가득 · 좌 시점 · 종점 표 */
.d02-map {
height: calc(100dvh - var(--app-header-h));
}
@@ -20,7 +20,7 @@
gap: 4px;
align-items: center;
padding: 4px 6px;
border-left: 4px solid var(--d02-year-color);
border-left: 4px solid var(--draw-primary);
border-radius: 4px;
}
+61 -5
View File
@@ -21,6 +21,7 @@ import base64
import hashlib
import json
import logging
import math
import os
import subprocess
import sys
@@ -32,8 +33,10 @@ from typing import Any
from uuid import UUID
import numpy as np
import shapely
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from scipy.spatial import cKDTree
from common_util.common_util_auth import verify_session
from common_util.common_util_derived_store import (
@@ -73,6 +76,9 @@ ENGINE_FILES = (
)
#: 3D 보기 노선면 솎음 간격(m)
CORRIDOR_STEP_M = 1.0
TERRAIN_MARGIN_M = 80.0 # 노선면 둘레 지형 띠 폭(B05 지형 띠 여유와 같음)
TERRAIN_STEP_M = 2.0 # 지형 칸(이보다 촘촘히 안 함)
TERRAIN_MAX_CELLS = 250_000 # 응답 크기 상한 — 넘으면 칸을 키움
#: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험)
WORKERS: int | None = None
@@ -435,10 +441,49 @@ async def put_representative(project_id: UUID, payload: RepresentativeRequest) -
return {"overrides": overrides}
def _terrain(dtm: str, origin: list[float], strips: list[np.ndarray]) -> dict[str, Any] | None:
"""설계 지형 격자(E 복사본 DTM) — 노선면 둘레 띠만 · 노면 발자국 안 = None(깎기 지형이 노면을
덮지 않게 · 가장자리 틈 한 칸 허용). 꼴 = E04 Grid + z(행 우선 iy·nx+ix) · 리본과 같은 원점."""
try:
sampler = DtmGridSampler.open_cached(dtm)
except (OSError, ValueError, KeyError):
logger.warning("E03 3D 지형 격자 못 엶: %s", dtm)
return None
pts = np.vstack([p.reshape(-1, 3)[:, :2] for p in strips])
lo = pts.min(axis=0) - TERRAIN_MARGIN_M
hi = pts.max(axis=0) + TERRAIN_MARGIN_M
area = float(np.prod(hi - lo))
dx = max(TERRAIN_STEP_M, math.ceil(math.sqrt(area / TERRAIN_MAX_CELLS) * 10) / 10)
nx, ny = (np.floor((hi - lo) / dx).astype(int) + 1).tolist()
xx, yy = np.meshgrid(lo[0] + np.arange(nx) * dx, lo[1] + np.arange(ny) * dx)
flat = np.column_stack([xx.ravel(), yy.ravel()])
near = np.isfinite(cKDTree(pts).query(flat, distance_upper_bound=TERRAIN_MARGIN_M)[0])
# 발자국 = 리본마다 첫 열 + 끝 열(거꾸로) 다각형의 합집합
rings = [np.vstack([p[:, 0, :2], p[::-1, -1, :2]]) for p in strips if len(p) >= 2]
foot = shapely.union_all([shapely.make_valid(shapely.Polygon(r)) for r in rings])
hole = shapely.contains_xy(foot, flat[:, 0], flat[:, 1])
z, valid = sampler.sample_xy(flat + np.asarray(origin[:2]))
keep = near & valid & ~hole & np.isfinite(z)
local = np.round(z - origin[2], 2)
return {
"grid": {
"x0": round(float(lo[0]), 3),
"y0": round(float(lo[1]), 3),
"dx": dx,
"nx": nx,
"ny": ny,
},
"z": [float(v) if k else None for v, k in zip(local.tolist(), keep.tolist())],
}
@lru_cache(maxsize=2)
def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
def _corridor3d(
path: str, _stamp: tuple[int, int], dtm: str | None, _dtm_stamp: tuple[int, int] | None
) -> dict[str, Any]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
ribbons = []
strips = []
origin = None
for ribbon in data.get("ribbons") or []:
cols = int(ribbon["colCount"])
@@ -453,6 +498,7 @@ def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
if keep[-1] != len(chain) - 1:
keep = np.r_[keep, len(chain) - 1]
local = pos[keep] - np.asarray(origin)
strips.append(local)
ribbons.append(
{
"kind": ribbon["kind"],
@@ -462,15 +508,25 @@ def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]:
"positions": np.round(local, 3).reshape(-1).tolist(),
}
)
return {"origin": origin, "ribbons": ribbons}
terrain = _terrain(dtm, origin, strips) if dtm and origin and strips else None
return {"origin": origin, "ribbons": ribbons, "terrain": terrain}
@router.get("/{project_id}/stab/corridor3d", response_model=None)
async def corridor3d(project_id: UUID) -> dict[str, Any]:
"""복사본 노선면 — 원점 빼고 1 m 솎음(모델 좌표 x · y · z)."""
"""복사본 노선면 — 원점 빼고 1 m 솎음(모델 x · y · z) + 설계 지형 격자(노면 구멍)."""
root, _ = await e02._project(project_id)
path = src.corridor_path(src.source_dir(root))
source = src.source_dir(root)
path = src.corridor_path(source)
if not path:
raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.")
stat = path.stat()
return await asyncio.to_thread(_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size))
try:
dtm = src.terrain_grid_path(source)
dtm_stat = dtm.stat()
dtm_key = (str(dtm), (dtm_stat.st_mtime_ns, dtm_stat.st_size))
except (OSError, FileNotFoundError):
dtm_key = (None, None)
return await asyncio.to_thread(
_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size), *dtm_key
)
@@ -27,6 +27,7 @@
--e03-critical: #0b0b0b;
--e03-surface-line: #52514e;
--e03-plot: #fcfcfb;
--e03-terrain: #c9c2a8;
}
:root[data-theme="dark"] {
@@ -49,6 +50,7 @@
--e03-critical: #f0efec;
--e03-surface-line: #c3c2b7;
--e03-plot: #1a1a19;
--e03-terrain: #5b5646;
}
@media (prefers-color-scheme: dark) {
@@ -72,6 +74,7 @@
--e03-critical: #f0efec;
--e03-surface-line: #c3c2b7;
--e03-plot: #1a1a19;
--e03-terrain: #5b5646;
}
}
+41 -2
View File
@@ -3,6 +3,7 @@
* E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만).
* 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) ·
* 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀.
* 설계 지형 = 응답 `terrain`(E 복사본 DTM · 노면 자리 구멍 · 요구 390) → E04 `terrainMesh` · 한 색 · 켬끔.
* ========================================================================== */
import * as THREE from "three";
@@ -10,7 +11,9 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { legend, muted } from "./E03_StabSection_UI_Parts";
import { terrainMesh } from "../E04_Stab3D/logic/E04_Stab3D_Mesh";
import type { Grid } from "../E04_Stab3D/logic/E04_Stab3D_Raster";
import { check, legend, muted } from "./E03_StabSection_UI_Parts";
import {
bandIndex,
RATIO_BREAKS,
@@ -27,6 +30,8 @@ interface Corridor3d {
chainages: number[];
positions: number[];
}[];
/** 설계 지형 — z 행 우선(iy·nx+ix) · null = 노면 구멍 · 띠 밖 · 복사본에 격자 없으면 null */
terrain?: { grid: Grid; z: (number | null)[] } | null;
}
/** 측점 하나 그 조건 최소 비(안전율 ÷ 기준)의 띠 */
@@ -61,9 +66,16 @@ export async function openView3d(
color: `var(--e03-band-${i})`,
}));
items.push({ label: t("E03_Verdict_none"), color: "var(--e03-none)" });
let ground: THREE.Mesh | null = null;
const legendRow = el("div", {
className: "e03-legend",
children: [muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`), legend(items)],
children: [
muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`),
legend(items),
...(data.terrain
? [check(t("E03_View3d_Terrain"), true, (v) => ground && (ground.visible = v))]
: []),
],
});
const host = el("div", { className: "e03-view3d__canvas" });
let teardown = (): void => undefined;
@@ -145,6 +157,30 @@ export async function openView3d(
}
scene.add(group);
// 설계 지형 — 모델 [x, y, z] → three [x, z, −y](리본과 같음) · 노면과 겹치는 곳은 뒤로 밀어 둠
if (data.terrain) {
const mesh = terrainMesh(data.terrain.grid, data.terrain.z);
const pos = new Float32Array(mesh.positions.length);
for (let i = 0; i < pos.length; i += 3) {
pos.set([mesh.positions[i], mesh.positions[i + 2], -mesh.positions[i + 1]], i);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3));
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
geometry.computeVertexNormals();
ground = new THREE.Mesh(
geometry,
new THREE.MeshLambertMaterial({
color: cssColor("--e03-terrain"),
side: THREE.DoubleSide,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
}),
);
scene.add(ground);
}
const box = new THREE.Box3().setFromObject(group);
const sphere = box.getBoundingSphere(new THREE.Sphere());
const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000));
@@ -178,12 +214,15 @@ export async function openView3d(
(n, m) => n + ((m as THREE.Mesh).geometry.getAttribute("position").count ?? 0),
0,
),
terrain: ground?.geometry.getAttribute("position").count ?? 0,
};
const dispose = (): void => {
cancelAnimationFrame(frame);
observer.disconnect();
controls.dispose();
ground?.geometry.dispose();
(ground?.material as THREE.Material | undefined)?.dispose();
group.traverse((o) => {
if (o instanceof THREE.Mesh) {
o.geometry.dispose();
+6 -25
View File
@@ -1,11 +1,11 @@
/* =============================================================================
* E06_StabRock_UI_Page.ts
* E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow).
* 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석]
* 좌 = 절리군 표 · 해석 설정 · [초기화] [해석]
* 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도)
* 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset ·
* [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET.
* 조작 = sessionStorage 캐시만(자동저장 없음) · [초기화] = POST …/reset ·
* [해석] = 저장(고침이 있으면 PUT) → POST …/analyze(서버 단독) → 결과 GET · 저장 단추 없음.
* ========================================================================== */
import "./E06_StabRock_UI_Style.css";
@@ -88,11 +88,6 @@ export function renderE06StabRock(root: HTMLElement): void {
// ---- 좌 ----
const cards = el("div", { className: "e06-cards" });
const status = el("div", { className: "e06-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
variant: "ghost",
onClick: () => void save(),
});
const resetButton = createButton({
label: t("Common_Btn_Reset"),
variant: "danger-ghost",
@@ -103,9 +98,9 @@ export function renderE06StabRock(root: HTMLElement): void {
variant: "filled",
onClick: () => void analyze(),
});
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true;
resetButton.disabled = analyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton, analyzeButton);
actions.append(resetButton, analyzeButton);
panel.append(note(t("E06_Note")), cards, status, actions);
attachCollapsible(cards);
@@ -274,7 +269,7 @@ export function renderE06StabRock(root: HTMLElement): void {
if (!cached) sessionStorage.removeItem(cacheKey(projectId));
view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints;
dirty = Boolean(cached);
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable;
resetButton.disabled = analyzeButton.disabled = !view.editable;
renderCards();
};
@@ -292,20 +287,6 @@ export function renderE06StabRock(root: HTMLElement): void {
return true;
}
async function save(): Promise<void> {
showLoadingOverlay();
try {
if (await putJoints()) {
showToast(t("E06_Saved"), "success");
await loadResult();
}
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
}
}
async function reset(): Promise<void> {
if (!view?.editable) return;
const ok = await showConfirmDialog(
+43 -8
View File
@@ -1,7 +1,7 @@
"""E07 광역 자연사면 위험 지도 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2).
- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문이 저장본과 같으면
`cached` · force = 다시
- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문(복사본 · 설정 · E02 물성 ·
엔진 파일 해시)이 저장본과 같으면 `cached` · force = 다시(시험 · 관리용)
- `GET …/stab/area/progress` — 진행률
- `GET …/stab/area` — 기본값(E02 붕적토) · 물성 목록 · 노선 · 측점 · 저장본(격자 · 측점표 · 요약)
@@ -26,6 +26,7 @@ import logging
import time
from collections.abc import Callable
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
@@ -46,7 +47,8 @@ from E07_StabArea import E07_StabArea_Engine as engine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["E07 Stab Area"])
ENGINE_VERSION = "e07-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
ENGINE_FILES = ("E07_StabArea_Engine.py", "E07_StabArea_Router.py") # 엔진 판 = 이 파일들 해시
MATERIAL_KEYS = ("gamma_t", "gamma_sat", "c", "phi")
RESULT = Path("E07_StabArea") / "_derived" / "area.json"
DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토)
RANGE_C_KPA, RANGE_PHI_DEG = (
@@ -122,8 +124,33 @@ def defaults(project_root: Path) -> dict[str, Any]:
}
def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str:
body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION}
@lru_cache(maxsize=1)
def engine_hash() -> str:
"""엔진 코드 지문 — 코드가 바뀌면 지문이 달라져 다시 셈."""
digest = hashlib.sha1()
for name in ENGINE_FILES:
digest.update((Path(__file__).parent / name).read_bytes())
return digest.hexdigest()[:12]
def ground_material(project_root: Path, material_id: str | None) -> dict[str, Any] | None:
"""E02 물성 값(고른 id · 없으면 붕적토) — 지문에 넣어 E02 가 바뀌면 옛 결과가 됨."""
materials = _ground(project_root).get("materials") or []
mat = next((m for m in materials if m.get("id") == material_id), None) or next(
(m for m in materials if m.get("id") == DEFAULT_MATERIAL), None
)
return {k: mat.get(k) for k in ("id", *MATERIAL_KEYS)} if mat else None
def fingerprint(
source_fingerprint: str, settings: dict[str, Any], ground: dict[str, Any] | None = None
) -> str:
body = {
"source": source_fingerprint,
"settings": settings,
"ground": ground,
"engine": engine_hash(),
}
text = json.dumps(body, ensure_ascii=False, sort_keys=True)
return hashlib.sha1(text.encode("utf-8")).hexdigest()
@@ -216,7 +243,7 @@ def run(
}
doc = {
"fingerprint": finger,
"engine": ENGINE_VERSION,
"engine": engine_hash(),
"settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"),
"elapsed_s": round(time.perf_counter() - started, 2),
@@ -275,7 +302,11 @@ async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]:
project_root = await _project_root(project_id)
root = _source_root(project_root)
settings = payload.model_dump(exclude={"force"})
finger = fingerprint(source.manifest(root)["source_fingerprint"], settings)
finger = fingerprint(
source.manifest(root)["source_fingerprint"],
settings,
ground_material(project_root, settings["material_id"]),
)
saved = await asyncio.to_thread(load, project_root)
if not payload.force and saved and saved["fingerprint"] == finger:
return {"status": "cached", "summary": saved["summary"]}
@@ -311,7 +342,11 @@ async def progress(project_id: UUID) -> dict[str, Any]:
def _overview(project_root: Path, root: Path) -> dict[str, Any]:
saved = load(project_root)
if saved:
current = fingerprint(source.manifest(root)["source_fingerprint"], saved["settings"])
current = fingerprint(
source.manifest(root)["source_fingerprint"],
saved["settings"],
ground_material(project_root, saved["settings"].get("material_id")),
)
saved["stale"] = saved["fingerprint"] != current
return {
"defaults": defaults(project_root),
+30 -23
View File
@@ -4,7 +4,8 @@
* 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률
* 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례
* 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점)
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage.
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage
* · 없으면 지난 결과 조건 + 물성은 지금 E02 값.
* ========================================================================== */
import "./E07_StabArea_UI_Style.css";
@@ -239,15 +240,10 @@ export function renderE07StabArea(root: HTMLElement): void {
const analyzeButton = createButton({
label: t("E07_Analyze"),
variant: "filled",
onClick: () => void analyze(false),
});
const reanalyzeButton = createButton({
label: t("E07_Reanalyze"),
variant: "ghost",
onClick: () => void analyze(true),
onClick: () => void analyze(),
});
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(defaultsButton, reanalyzeButton, analyzeButton);
actions.append(defaultsButton, analyzeButton);
const readOnly = note(t("E07_ReadOnly"), "e07-hint");
readOnly.hidden = true;
panel.append(
@@ -295,24 +291,31 @@ export function renderE07StabArea(root: HTMLElement): void {
remember();
});
fields.get("band_m")!.input.addEventListener("input", paintScenario);
material.select.addEventListener("change", () => {
const m = overview?.materials.find((x) => x.id === material.select.value);
if (!m) return;
const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v));
/** E02 물성 값(범위 위 = 기본값과 같은 폭) — 없는 id 면 빈 것 */
function materialValues(id: unknown): Settings {
const m = overview?.materials.find((x) => x.id === id);
if (!m) return {};
const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c);
const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi);
set("gamma_t", m.gamma_t);
set("gamma_sat", m.gamma_sat);
set("c", m.c);
set("c_hi", m.c + rangeC);
set("phi", m.phi);
set("phi_hi", m.phi + rangePhi);
return {
gamma_t: m.gamma_t,
gamma_sat: m.gamma_sat,
c: m.c,
c_hi: m.c + rangeC,
phi: m.phi,
phi_hi: m.phi + rangePhi,
};
}
material.select.addEventListener("change", () => {
const values = materialValues(material.select.value);
for (const [key, v] of Object.entries(values)) fields.get(key)!.input.value = String(v);
remember();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
analyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
@@ -445,11 +448,11 @@ export function renderE07StabArea(root: HTMLElement): void {
}
}
async function analyze(force: boolean): Promise<void> {
async function analyze(): Promise<void> {
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, {
method: "POST",
body: JSON.stringify({ ...read(), force }),
body: JSON.stringify(read()),
});
if (body.status === "cached") {
showToast(t("E07_Cached"), "info");
@@ -478,8 +481,12 @@ export function renderE07StabArea(root: HTMLElement): void {
const cached = JSON.parse(
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
) as Settings | null;
// 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움
write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) });
// 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값
const last = ov.result?.settings as Settings | undefined;
write({
...ov.defaults,
...(cached ?? { ...last, ...materialValues(last?.material_id ?? ov.defaults.material_id) }),
});
map.setRoute(ov.line, ov.stations);
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (p?.running) void poll();
+7
View File
@@ -0,0 +1,7 @@
-- 021_plan_base_json.sql
-- 예정노선 전체 시점 · 종점 두 점 (요구서 389 3장 1 · PLAN 560-2)
-- {"epsg": int, "start": {"x", "y"}, "end": {"x", "y"}} · TM m · 비면 옛 연도 행을 접어 보임(D02 서버)
USE aislo_db;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS plan_base_json JSON NULL COMMENT '예정노선 전체 시점 · 종점(D02)';
+47 -59
View File
@@ -1,10 +1,13 @@
"""D02 기준 지정(PLAN 529-3) — 경위도 ↔ TM 변환 · 지점 정본 API(잇기 · 빈 연도 · 권한).
"""D02 기준 지정(PLAN 529-3 · 560-2) — 경위도 ↔ TM 변환 · 전체 두 점 정본 API · 옛 연도 행 접기
· 연도 잇기(D03 [저장]이 씀).
./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q
"""
import json
import pytest
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_company
@@ -59,21 +62,16 @@ def api(monkeypatch):
"project": {"project_kind": "PLAN", "project_year": 2026, "project_year_end": 2027},
"rows": {},
"session": {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1},
"stores": [],
}
async def load(project_id):
return state["project"], {y: dict(r) for y, r in state["rows"].items()}
async def store(project_id, epsg, writes, drops):
state["stores"].append((epsg, writes, drops))
for row in writes:
state["rows"][row["year"]] = {**row, "epsg": epsg}
for year in drops:
state["rows"].pop(year)
async def store_base(project_id, base):
state["project"] = {**state["project"], "plan_base_json": json.dumps(base)}
monkeypatch.setattr(router_mod, "_load", load)
monkeypatch.setattr(router_mod, "_store", store)
monkeypatch.setattr(router_mod, "_store_base", store_base)
app = FastAPI()
app.include_router(router_mod.router)
app.dependency_overrides[require_company] = lambda: state["session"]
@@ -82,68 +80,58 @@ def api(monkeypatch):
return client
def test_put_chains_years(api) -> None:
# 2027 시점을 엉뚱하게 보내도 서버가 2026 종점으로 맞춤
body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "start": A, "end": C}]}
got = api.put(URL, json=body)
def test_put_two_points(api) -> None:
got = api.put(URL, json={"start": A, "end": C})
assert got.status_code == 200
data = got.json()
assert data["epsg"] == 5186 and data["changed_years"] == [2026, 2027]
y26, y27 = data["years"]
assert y27["start"]["x"] == y26["end"]["x"] and y27["start"]["y"] == y26["end"]["y"]
assert abs(y27["start"]["lon"] - B["lon"]) < 1e-8 and abs(y27["start"]["lat"] - B["lat"]) < 1e-8
assert (y26["start"]["x"], y26["start"]["y"]) == pytest.approx(
assert data["epsg"] == 5186
assert (data["start"]["x"], data["start"]["y"]) == pytest.approx(
lonlat_to_tm(127.10, 37.50, 5186)
)
assert abs(data["end"]["lon"] - C["lon"]) < 1e-8 and abs(data["end"]["lat"] - C["lat"]) < 1e-8
stored = json.loads(api.state["project"]["plan_base_json"])
assert set(stored) == {"epsg", "start", "end"} and set(stored["start"]) == {"x", "y"}
assert api.state["rows"] == {} # 연도 행은 D02 가 안 만듦
def test_put_rejects_empty_and_out_of_range(api) -> None:
only_first = {"years": [{"year": 2026, "start": A, "end": B}]}
got = api.put(URL, json=only_first)
assert got.status_code == 400 and "2027년 종점" in got.json()["detail"]
no_start = {"years": [{"year": 2026, "end": B}, {"year": 2027, "end": C}]}
assert "2026년 시점" in api.put(URL, json=no_start).json()["detail"]
outside = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2028, "end": C}]}
assert api.put(URL, json=outside).status_code == 400
assert api.state["stores"] == []
def test_put_one_point_and_empty(api) -> None:
data = api.put(URL, json={"start": A}).json()
assert data["start"] and data["end"] is None and data["epsg"] == 5186
data = api.put(URL, json={}).json()
assert data == {"status": "success", "epsg": None, "start": None, "end": None}
def test_put_forest_only(api) -> None:
body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]}
api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1}
assert api.put(URL, json=body).status_code == 403
assert api.put(URL, json={"start": A, "end": B}).status_code == 403
api.state["session"] = {"role": "SYSTEM_ADMIN", "email": "admin@x.com", "company_id": None}
assert api.put(URL, json=body).status_code == 200
assert api.put(URL, json={"start": A, "end": B}).status_code == 200
def test_put_clears_only_changed_years(api) -> None:
body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]}
api.put(URL, json=body)
# 같은 지점 다시 저장 = 쓰기 없음(분석 노선 그대로) · 2027 종점만 옮기면 2027 만
assert api.put(URL, json=body).json()["changed_years"] == []
moved = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": A}]}
assert api.put(URL, json=moved).json()["changed_years"] == [2027]
assert len(api.state["stores"]) == 2
def test_get_lists_every_year_and_drops_outside(api) -> None:
api.state["rows"][2030] = {
"year": 2030,
"start_x": 1,
"start_y": 1,
"end_x": 2,
"end_y": 2,
"epsg": 5186,
}
def test_get_folds_old_year_rows(api) -> None:
epsg = 5186
a, b, c = (lonlat_to_tm(p["lon"], p["lat"], epsg) for p in (A, B, C))
for year, s, e in ((2026, a, b), (2027, b, c)):
api.state["rows"][year] = {
"year": year,
**{"start_x": s[0], "start_y": s[1], "end_x": e[0], "end_y": e[1]},
"epsg": epsg,
}
data = api.get(URL).json()
assert [y["year"] for y in data["years"]] == [2026, 2027]
assert data["years"][0]["start"] is None
body = {"years": [{"year": 2026, "start": A, "end": B}, {"year": 2027, "end": C}]}
api.put(URL, json=body)
assert api.state["stores"][-1][2] == [2030] and 2030 not in api.state["rows"]
assert data["epsg"] == epsg
assert (data["start"]["x"], data["end"]["x"]) == pytest.approx((a[0], c[0]))
def test_not_plan_project(api) -> None:
api.state["project"] = {"project_kind": "ROAD", "project_year": 2026, "project_year_end": None}
# _load 대역이 그대로 돌려주므로 진짜 _load 의 거절은 서버 시험(ORCA)에서
assert api.get(URL).status_code == 200
def test_get_empty_new_project(api) -> None:
assert api.get(URL).json() == {"status": "success", "epsg": None, "start": None, "end": None}
def test_chain_years_for_d03() -> None:
items = [
router_mod.YearPoints(year=2026, start=A, end=B),
router_mod.YearPoints(year=2027, start=A, end=C),
]
rows = router_mod.chain_years(2026, 2027, items)
assert rows[1]["start"].lon == B["lon"] # 시점 = 직전 종점
with pytest.raises(HTTPException):
router_mod.chain_years(2026, 2027, items[:1])
@@ -271,6 +271,39 @@ def test_노선면_솎음(project):
assert max(abs(v) for v in ribbon["positions"]) < 5000 # 원점 뺀 자리
def test_노선면_설계_지형(project):
"""지형 = E04 Grid 꼴 · 리본과 같은 원점 · 노면 발자국 안 = None · 노면 옆 표고 ≈ 비탈 끝."""
body = asyncio.run(router.corridor3d(UUID(PID)))
terrain = body["terrain"]
g, z = terrain["grid"], terrain["z"]
assert len(z) == g["nx"] * g["ny"] and g["dx"] >= router.TERRAIN_STEP_M
assert g["nx"] * g["ny"] <= router.TERRAIN_MAX_CELLS * 1.05
have = [v for v in z if v is not None]
assert have and max(abs(v) for v in have) < 500 # 원점 표고 뺀 값
def cell(x, y):
ix = round((x - g["x0"]) / g["dx"])
iy = round((y - g["y0"]) / g["dx"])
return z[iy * g["nx"] + ix]
# 노면 리본 가운데 열 = 발자국 안 → 구멍
road = next(r for r in body["ribbons"] if r["colCount"] >= 3)
cols = road["colCount"]
mid = [
road["positions"][(i * cols + cols // 2) * 3 : (i * cols + cols // 2) * 3 + 2]
for i in range(len(road["chainages"]))
]
holes = [cell(*p) is None for p in mid]
assert sum(holes) / len(holes) > 0.9
# 지형 있는 칸의 원래 표고 = DTM 표본 − 원점 표고
sampler = router.DtmGridSampler.from_npz(src.terrain_grid_path(src.source_dir(project)))
i = next(k for k, v in enumerate(z) if v is not None)
x = g["x0"] + (i % g["nx"]) * g["dx"] + body["origin"][0]
y = g["y0"] + (i // g["nx"]) * g["dx"] + body["origin"][1]
want, ok = sampler.sample_xy([[x, y]])
assert ok[0] and z[i] == pytest.approx(want[0] - body["origin"][2], abs=0.02)
class _Cursor:
def __init__(self, rows):
self.rows = rows
+12
View File
@@ -266,3 +266,15 @@ def test_길_띠_측점표_저장본(project):
"s", {**settings, "c": 6}
)
json.dumps(doc) # 그대로 JSON
def test_지문_E02_물성_엔진_해시(project, monkeypatch):
"""광역 물성 = E02 값 — E02 붕적토가 바뀌거나 엔진 파일이 바뀌면 지문이 달라 옛 결과."""
settings = router_mod.defaults(project)
ground = router_mod.ground_material(project, settings["material_id"])
assert ground["id"] == "colluvium" and ground["c"] == settings["c"]
assert router_mod.ground_material(project, "없는것") == ground # 모르는 id = 붕적토
before = router_mod.fingerprint("s", settings, ground)
assert before != router_mod.fingerprint("s", settings, {**ground, "c": ground["c"] + 1})
monkeypatch.setattr(router_mod, "engine_hash", lambda: "바뀜")
assert before != router_mod.fingerprint("s", settings, ground)
+3 -3
View File
@@ -118,10 +118,10 @@ export const ui_locales_de = {
DE_Link_NoRoad: ["우리 회사 임도 프로젝트가 없습니다.", "No road project in our company."],
/* --- D02 기준 지정(529-3) --- */
D02_Points_Title: ["연도 지점", "Yearly Points"],
D02_Points_Title: ["전체 시점 · 종점", "Overall Start · End"],
D02_Points_Hint: [
"📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 직전 종점 = 다음 시점.",
"Pick a point with 📍 and click the map, drag markers, or type lon · lat. Each end is the next start.",
"📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 연도 나누기는 노선분석에서.",
"Pick a point with 📍 and click the map, drag markers, or type lon · lat. Split by year in analysis.",
],
D02_Start: ["시점", "start"],
D02_End: ["종점", "end"],
+1
View File
@@ -25,6 +25,7 @@ export const ui_locales_e03 = {
E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."],
E03_View3d: ["3D 보기", "3D View"],
E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"],
E03_View3d_Terrain: ["설계 지형", "Design terrain"],
E03_Loading: ["불러오는 중…", "Loading…"],
E03_NotYet: [
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
+6 -4
View File
@@ -10,8 +10,8 @@ import type { LocaleEntry } from "./ui_template_locale_common";
export const ui_locales_e06 = {
/* --- E06 좌측 --- */
E06_Note: [
"암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [저장] → [해석]. 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.",
"Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, [Save] → [Analyze]. Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).",
"암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [해석](넣은 값 저장 + 해석). 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.",
"Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, then [Analyze] (saves and analyzes). Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).",
],
E06_Card_Joints: ["절리군", "Joint sets"],
E06_JointsHint: [
@@ -61,8 +61,10 @@ export const ui_locales_e06 = {
E06_Running: ["해석 중…", "Analyzing…"],
E06_Done: ["암반 해석을 마쳤습니다.", "Rock analysis done."],
E06_Fresh: ["입력이 같아 지난 결과를 씁니다.", "Inputs unchanged — previous result kept."],
E06_Saved: ["저장했습니다.", "Saved."],
E06_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
E06_Unsaved: [
"저장 안 됨(해석 전) — [해석]을 누르면 저장됩니다.",
"Not saved (before analysis) — [Analyze] saves.",
],
E06_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
E06_Invalid: ["틀린 칸", "Invalid fields"],
E06_Err_Range: ["범위 밖", "Out of range"],
+2 -3
View File
@@ -44,7 +44,6 @@ export const ui_locales_e07 = {
E07_LevelHi: ["측점 높음 문턱 (SI<1 비율 %)", "Station high threshold (SI<1 share %)"],
E07_Defaults: ["기본값", "Defaults"],
E07_Analyze: ["해석", "Analyze"],
E07_Reanalyze: ["다시 해석", "Re-analyze"],
E07_Running: ["해석 중", "Analyzing"],
E07_Done: ["광역 해석을 마쳤습니다.", "Area analysis finished."],
E07_Cached: ["같은 조건의 결과가 있습니다.", "A result with the same inputs exists."],
@@ -67,8 +66,8 @@ export const ui_locales_e07 = {
E07_Result_Title: ["광역 결과", "Area result"],
E07_Result_None: ["아직 해석 결과가 없습니다.", "No result yet."],
E07_Result_Stale: [
"엔진이 바뀌어 결과가 옛것입니다 — [다시 해석].",
"The engine changed — re-analyze.",
"입력(E02 물성 · 엔진)이 바뀌어 결과가 옛것입니다 — [해석]을 누르세요.",
"Inputs (E02 soil · engine) changed — press [Analyze].",
],
E07_Result_Note: [
"문헌 추정치 · 현장 시험 없음 · 기본값은 바꿀 수 있음. 표준 = 포화비 W = min(R·a / (T·sinθ), 1). SI = c · φ · T/R 범위 안에서 Fs > 1 일 확률(하한 Fs ≥ 1 이면 그 값 · 상한 Fs < 1 이면 0). Fs = 가장 나쁜 쪽(c · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",