diff --git a/D02_PlanBase/D02_PlanBase_Router.py b/D02_PlanBase/D02_PlanBase_Router.py index b61e39661..79d888bf0 100644 --- a/D02_PlanBase/D02_PlanBase_Router.py +++ b/D02_PlanBase/D02_PlanBase_Router.py @@ -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)} diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index d4e9c89fb..d7320eeca 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -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(path: string, init?: RequestInit): Promise { return data as T; } -/** 서버 연도 행 → 경계점 목록(연도 수 + 1) */ +/** 서버 두 점 → [시점, 종점] */ function boundaryPoints(view: PointsView): Array { 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 { @@ -88,13 +85,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { 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 { 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> = []; - 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> = + 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 { 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 { 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(`/projects/${projectId}/plan/points`, { method: "PUT", diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css index 39cf82ad8..fe70a3929 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Style.css +++ b/D02_PlanBase/D02_PlanBase_UI_Style.css @@ -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; } diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index d7da920a5..9b86ec5ed 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -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 + ) diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index e1225726f..598b87117 100644 --- a/E03_StabSection/E03_StabSection_UI_Style.css +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -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; } } diff --git a/E03_StabSection/E03_StabSection_UI_View3D.ts b/E03_StabSection/E03_StabSection_UI_View3D.ts index 5ea6b5006..70518fe4b 100644 --- a/E03_StabSection/E03_StabSection_UI_View3D.ts +++ b/E03_StabSection/E03_StabSection_UI_View3D.ts @@ -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(); diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts index fa66bf777..ce94a8e73 100644 --- a/E06_StabRock/E06_StabRock_UI_Page.ts +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -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 { - 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 { if (!view?.editable) return; const ok = await showConfirmDialog( diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py index 159cf7d51..9ab353736 100644 --- a/E07_StabArea/E07_StabArea_Router.py +++ b/E07_StabArea/E07_StabArea_Router.py @@ -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), diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index 2cee76c17..32ae7397e 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -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 { + async function analyze(): Promise { 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(`/projects/${projectId}/stab/area/progress`).catch(() => null); if (p?.running) void poll(); diff --git a/db_management/021_plan_base_json.sql b/db_management/021_plan_base_json.sql new file mode 100644 index 000000000..af164e2e8 --- /dev/null +++ b/db_management/021_plan_base_json.sql @@ -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)'; diff --git a/resources/tester/test_529_3_plan_points.py b/resources/tester/test_529_3_plan_points.py index 7c65cf634..892df2b10 100644 --- a/resources/tester/test_529_3_plan_points.py +++ b/resources/tester/test_529_3_plan_points.py @@ -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]) diff --git a/resources/tester/test_532_3_e03_section.py b/resources/tester/test_532_3_e03_section.py index 17d0cd9cd..4e051d996 100644 --- a/resources/tester/test_532_3_e03_section.py +++ b/resources/tester/test_532_3_e03_section.py @@ -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 diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py index 6bfe08965..d35050163 100644 --- a/resources/tester/test_536_2_e07_area.py +++ b/resources/tester/test_536_2_e07_area.py @@ -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) diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index 7e510a954..7442a766c 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -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"], diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 29befffc6..48138007e 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -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: [ "아직 해석하지 않았습니다 — [해석]을 누르세요.", diff --git a/ui_template/ui_template_locale_e06.ts b/ui_template/ui_template_locale_e06.ts index 83273a815..8888b1d95 100644 --- a/ui_template/ui_template_locale_e06.ts +++ b/ui_template/ui_template_locale_e06.ts @@ -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"], diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts index a405ebc12..577d387d2 100644 --- a/ui_template/ui_template_locale_e07.ts +++ b/ui_template/ui_template_locale_e07.ts @@ -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 · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",