diff --git a/A00_Common/app_settings.ts b/A00_Common/app_settings.ts index fc4e4d242..3991bdcaf 100644 --- a/A00_Common/app_settings.ts +++ b/A00_Common/app_settings.ts @@ -46,6 +46,7 @@ const KEY_ROWS: [LocaleKey, LocaleKey][] = [ ["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"], ["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"], ["App_Settings_Keys_RouteEdit", "App_Settings_Keys_RouteEdit_Keys"], + ["App_Settings_Keys_Plan", "App_Settings_Keys_Plan_Keys"], ["App_Settings_Keys_Section", "App_Settings_Keys_Section_Keys"], ["App_Settings_Keys_Cad", "App_Settings_Keys_Cad_Keys"], ["App_Settings_Keys_Table", "App_Settings_Keys_Table_Keys"], diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 53f567821..6e18bfc88 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -526,9 +526,14 @@ def download_geodata( # 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함). prune_sheets(sheet_store, sheet_grid) - # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록)가 같으면 스킵 + from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION + + # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록 · 파서 판)가 같으면 스킵 merge_key_path = processed_dir / "sheet_merge.key.json" - merge_key = {"sheets": sorted(str(item) for item in sheet_result["available"])} + merge_key = { + "sheets": sorted(str(item) for item in sheet_result["available"]), + "parser": PARSER_VERSION, + } if sheet_result["available"] and ( rebuild or not any(processed_dir.glob("도엽_*.geojson")) diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py b/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py index ff575eeeb..8d1ef96dd 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py @@ -37,6 +37,10 @@ SHEET_LAYERS: dict[str, tuple[str, str]] = { # 산출 파일 접두사 (기존 processed/의 국가 GIS geojson과 구분) _OUTPUT_PREFIX = "도엽" +# 파서 판 번호 — 병합 캐시 열쇠에 넣음 · 읽는 법이 바뀌면 올려 옛 병합본을 다시 지음 +PARSER_VERSION = 2 +# .cpg 없는 도엽 dbf 의 글자 인코딩(국토지리정보원 배포본) +DBF_FALLBACK_ENCODING = "cp949" def _read_layer_from_zip( @@ -57,7 +61,9 @@ def _read_layer_from_zip( with tempfile.TemporaryDirectory() as td: for n in wanted: (Path(td) / Path(n).name).write_bytes(zf.read(n)) - gdf = gpd.read_file(Path(td) / f"{member_base}.shp") + has_cpg = any(Path(n).suffix.lower() == ".cpg" for n in wanted) + options = {} if has_cpg else {"encoding": DBF_FALLBACK_ENCODING} + gdf = gpd.read_file(Path(td) / f"{member_base}.shp", **options) if gdf.empty: return None diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py index c83868057..9919509ec 100644 --- a/D01_PlanRegister/D01_PlanRegister_Router.py +++ b/D01_PlanRegister/D01_PlanRegister_Router.py @@ -1,4 +1,8 @@ -"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2).""" +"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2). + +화면은 이름 · 시작 연도 · 사업 지역만 보냄(요구서 389 3장 1 · 3) — 끝 연도 = 시작 · 임도 종류 없음 += 설계속도 작업임도 20 · 측점 없음(DB 칸 그대로). +""" from typing import Any @@ -21,7 +25,7 @@ class CreatePlanRequest(BaseModel): project_year: int = Field(..., ge=2000, le=2100) project_year_end: int | None = Field(default=None, ge=2000, le=2100) region: str = Field(..., min_length=1, max_length=100) - road_type: str = Field(..., pattern="^(main|fire|work)$") + road_type: str | None = Field(default=None, pattern="^(main|fire|work)$") design_speed_kph: int | None = None start_chainage_m: float | None = Field(default=None, ge=0) end_chainage_m: float | None = Field(default=None, ge=0) diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 38345f802..06ddb0822 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -1,51 +1,25 @@ /* ============================================================================= * D01_PlanRegister_UI_Page.ts - * D01 예정노선 등록(요구서 345 3-1 · PLAN 529-1) — B02 등록 화면 꼴 · 도면 칸 없음. - * 칸 = 사업연도 시작 · 끝 · 사업지역 · 종류 「노선 계획」(고정) · 임도 종류(B02 값) · 설계속도 - * (종류별 고를 수 있는 값 · 기본 = 가장 낮은 값 = B05 resolve_design_speed) · 프로젝트명(B02 이름 - * 조합기 · 연도 자리 = 「시작~끝」) · 노선 시작 · 종료 누가거리. + * D01 예정노선 등록(요구서 345 3-1 · 389 3장 1~3 · PLAN 560-1) — B02 등록 화면 꼴 · 도면 칸 없음. + * 칸 = 사업연도(시작) · 사업지역 · 프로젝트명(B02 이름 조합기 · 「<시작 연도> <사업 지역> 노선 계획」) + * · 사업연도 끝 · 임도 종류 · 설계속도 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본). * [등록] = POST /api/d01/project → 현재 프로젝트 · D02 로. * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; -import { - createButton, - createCard, - createInputField, - createSelectField, -} from "@ui/ui_template_elements"; +import { createButton, createCard, createInputField } from "@ui/ui_template_elements"; import { createFormGrid, createGeneralLayout } from "@ui/ui_template_general_layout"; import { createHelpButton } from "@ui/ui_template_help"; -import { createText } from "@ui/ui_template_text"; import { t } from "@ui/ui_template_locale"; -import { stationLabel } from "../common_util/common_util_station"; import { createGroupProject } from "../A00_Common/group_workflow"; import { navigateTo } from "../A00_Common/router"; import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name"; -import { SPEED_CHOICES } from "../B05_Profile/logic/B05_Profile_Grade_Criteria"; - -/** B02 임도 종류 → 설계속도 기준 등급(B05 grade_class_of 와 같음) */ -const GRADE_CLASS: Record = { main: "trunk", fire: "fire", work: "work" }; - -/** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */ -const STATION_INTERVAL_M = 20; - -const numberOrNull = (text: string): number | null => - text.trim() === "" ? null : Number.parseFloat(text); export function renderD01PlanRegister(root: HTMLElement): void { - const year = new Date().getFullYear(); const yearStart = createInputField({ label: t("DE_Reg_YearStart"), type: "number", - value: String(year), - min: 2000, - max: 2100, - }); - const yearEnd = createInputField({ - label: t("DE_Reg_YearEnd"), - type: "number", - value: String(year), + value: String(new Date().getFullYear()), min: 2000, max: 2100, }); @@ -54,75 +28,29 @@ export function renderD01PlanRegister(root: HTMLElement): void { placeholder: t("B02_Proj_Field_Region_Placeholder"), required: true, }); - const kind = createInputField({ label: t("DE_Reg_Kind"), value: t("DE_Reg_KindPlan") }); - kind.input.readOnly = true; - const roadType = createSelectField({ - label: t("B02_Proj_Field_RoadType"), - options: [ - { value: "main", text: t("B02_Proj_RoadType_Main") }, - { value: "fire", text: t("B02_Proj_RoadType_Fire") }, - { value: "work", text: t("B02_Proj_RoadType_Work") }, - ], - }); - const speed = createSelectField({ label: t("DE_Reg_DesignSpeed"), options: [] }); const name = createInputField({ label: t("DE_Reg_Name"), required: true }); - const startM = createInputField({ label: t("DE_Reg_StartM"), type: "number", min: 0 }); - const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 }); - // 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로) - for (const field of [startM, endM]) { - const shown = createText("", "muted"); - shown.dataset.station = ""; - field.root.insertBefore(shown, field.root.lastElementChild); - field.input.addEventListener("input", () => { - const value = numberOrNull(field.input.value); - shown.textContent = - value !== null && Number.isFinite(value) && value >= 0 - ? `= ${stationLabel(value, STATION_INTERVAL_M, 1)}` - : ""; - }); - } - - // 설계속도 = 종류별 고를 수 있는 값 · 기본 = 가장 낮은 값(20) - const syncSpeeds = (): void => { - const choices = SPEED_CHOICES[GRADE_CLASS[roadType.select.value]] ?? [20]; - speed.setOptions( - choices.map((kph) => ({ value: String(kph), text: `${kph} km/h` })), - String(Math.min(...choices)), - ); - }; // 이름 = B02 조합기 그대로(손대기 전 = 자동 · 고친 뒤 = 조각만 갈아 끼움) const composer = createNameComposer(); - const years = (): string => { - const [a, b] = [yearStart.input.value.trim(), yearEnd.input.value.trim()]; - return b && b !== a ? `${a}~${b}` : a; - }; const syncName = (): void => { name.input.value = composer.update({ - year: years(), + year: yearStart.input.value.trim(), region: region.input.value, - type: roadType.select.selectedOptions[0]?.textContent ?? "", + type: t("DE_Reg_KindPlan"), }); }; name.input.addEventListener("input", () => composer.edit(name.input.value)); - for (const field of [yearStart, yearEnd, region]) field.input.addEventListener("input", syncName); - roadType.select.addEventListener("change", () => (syncSpeeds(), syncName())); - syncSpeeds(); + for (const field of [yearStart, region]) field.input.addEventListener("input", syncName); syncName(); const submit = (): void => { - const fields = [yearStart, yearEnd, region, name, startM, endM]; + const fields = [yearStart, region, name]; fields.forEach((field) => field.setError()); const y0 = Number.parseInt(yearStart.input.value, 10); - const y1 = Number.parseInt(yearEnd.input.value, 10); - const [s, e] = [numberOrNull(startM.input.value), numberOrNull(endM.input.value)]; const errors: Array<[(typeof fields)[number], string | false]> = [ [yearStart, !(y0 >= 2000 && y0 <= 2100) && t("Common_Validation_NumberRange")], - [yearEnd, !(y1 >= y0 && y1 <= 2100) && t("DE_Reg_YearOrder")], [region, !region.input.value.trim() && t("B02_Proj_Error_Required")], [name, !name.input.value.trim() && t("B02_Proj_Error_Required")], - [startM, s !== null && !(s >= 0) && t("Common_Validation_NumberRange")], - [endM, e !== null && s !== null && !(e > s) && t("DE_Reg_ChainOrder")], ]; const bad = errors.filter(([, message]) => message); bad.forEach(([field, message]) => field.setError(message as string)); @@ -132,20 +60,13 @@ export function renderD01PlanRegister(root: HTMLElement): void { { project_name: name.input.value.trim(), project_year: y0, - project_year_end: y1, region: region.input.value.trim(), - road_type: roadType.select.value, - design_speed_kph: Number(speed.select.value), - start_chainage_m: s, - end_chainage_m: e, }, ROUTES.D02_PLAN_BASE, ); }; - const grid = createFormGrid( - [yearStart, yearEnd, region, kind, roadType, speed, name, startM, endM].map((f) => f.root), - ); + const grid = createFormGrid([yearStart, region, name].map((f) => f.root)); const actions = document.createElement("div"); actions.className = "ui-general-block__actions ui-general-block__actions--end"; actions.append( 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/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index 7d3be6c57..d50044880 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -26,7 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_MapSheet import ( sheet5k_to_bounds, sheets_for_points, ) -from B04_PreProcess.B04_PreProcess_Engine_SheetParser import parse_and_merge_sheets +from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION, parse_and_merge_sheets from B04_PreProcess.B04_PreProcess_Engine_SheetStore import ( ensure_sheets, get_project_map_sheets_dir, @@ -114,7 +114,7 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st key_path = processed / "sheet_merge.key.json" old = (_read_json(key_path) or {}).get("sheets") or [] sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)}) - key = {"sheets": sheets} + key = {"sheets": sheets, "parser": PARSER_VERSION} if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))): parse_and_merge_sheets(store, sheets, processed) atomic_write_json(key_path, key) diff --git a/D04_PlanConfirm/D04_PlanConfirm_Link.py b/D04_PlanConfirm/D04_PlanConfirm_Link.py index ed03bac9f..e6ae64914 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Link.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Link.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio import hashlib import json +import re import shutil import tempfile from pathlib import Path @@ -250,10 +251,12 @@ async def import_route_files( async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -> str: """예정노선 이름 · 지역 · 임도 종류 · 그 연도로 임도 프로젝트(B02 등록과 같은 함수).""" body = _parsed(assignee["route_json"]) or {} + # 「2026 홍천 노선 계획」 꼴 = 앞 연도를 그 연도로 갈아 끼움(연도 두 번 안 붙음) · 그 밖 = 뒤에 연도 + name, dated = re.subn(r"^\d{4}(?:~\d{4})?(?=\s)", str(assignee["year"]), assignee["name"]) created = await create_project( user_id=int(session["user_id"]), company_id=int(session["company_id"]), - name=f"{assignee['name']} {assignee['year']}", + name=name if dated else f"{assignee['name']} {assignee['year']}", region=assignee["region"] or "", road_type=assignee["road_type"] or "work", project_year=int(assignee["year"]), diff --git a/E02_StabGround/E02_StabGround_Chain.py b/E02_StabGround/E02_StabGround_Chain.py new file mode 100644 index 000000000..9c75c7f8c --- /dev/null +++ b/E02_StabGround/E02_StabGround_Chain.py @@ -0,0 +1,288 @@ +"""E02 [해석 시작] 체인 (요구 390 1 · PLAN 564-1). + +차례 = 2D → 3D(모든 구간 · 건기 · 우기) → 암반 → 광역 → 보고서. 계산 자리 = ③ 서버 단독. +각 페이지 해석 함수를 그대로 부름(그 파일 안 고침) · 단계마다 입력 지문이 저장본과 같으면 건너뜀 +· 결과는 각 페이지 저장 자리에 영구 저장 · 한 단계가 실패해도 다음 단계로. +도는 동안 그 페이지 진행 표(`_JOBS`)에 같은 일감을 꽂음 → E03 · E04 · E07 화면이 「해석 중」 +· 그 페이지 [해석] 은 409. [끊기] = 다음 확인 때 멈춤(3D 는 엔진 안 · 그 밖은 단계 사이). + +진행 꼴 = {running, percent, stage, stage_name, steps:[{key, name, state, seconds}], + message, error, cancelled} · state = wait · run · done · skip · error · cancel +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any +from uuid import UUID + +logger = logging.getLogger(__name__) + +#: 단계 — (열쇠, 이름, 비율 몫) · 몫 = 1.2 km 실측 시간 비(2D 1~3 분 · 3D 건기 · 우기 약 25 분) +STEPS: tuple[tuple[str, str, float], ...] = ( + ("2d", "2D 측점 해석", 10.0), + ("3d", "3D 구간 해석", 84.0), + ("rock", "암반 해석", 2.0), + ("area", "광역 위험", 2.0), + ("report", "보고서", 2.0), +) +CONDITIONS_3D = ("dry", "wet") + +Report = Callable[[float, str], None] # (단계 안 비율 0~1, 글) +Stopped = Callable[[], bool] +Step = Callable[[Report, Stopped], Awaitable[str]] # → "done" | "skip" + +_JOBS: dict[str, dict[str, Any]] = {} +_STOPS: dict[str, bool] = {} +_TASKS: set[asyncio.Task] = set() + + +def idle() -> dict[str, Any]: + return { + "running": False, + "percent": 0, + "stage": None, + "stage_name": None, + "steps": [{"key": k, "name": n, "state": "wait", "seconds": None} for k, n, _ in STEPS], + "message": None, + "error": None, + "cancelled": False, + } + + +def _mark_stage(project_id: str, key: str, state: str) -> None: + """대시보드 단계 상태 — ponytail: 지금은 안 적음 · 565-1 함수가 들어오면 여기서 부름.""" + + +async def run_chain( + job: dict[str, Any], steps: dict[str, Step], stopped: Stopped, project_id: str = "" +) -> None: + """STEPS 차례로 돎 — job 을 제자리에서 고침(진행 GET 이 그대로 읽음).""" + total = sum(w for _, _, w in STEPS) + base = 0.0 + errors: list[str] = [] + for index, (key, name, weight) in enumerate(STEPS): + row = job["steps"][index] + if stopped(): + job.update(cancelled=True, message="끊음") + row["state"] = "cancel" + break + job.update(stage=key, stage_name=name, message=name) + row["state"] = "run" + _mark_stage(project_id, key, "run") + + def report(frac: float, message: str, base: float = base, weight: float = weight) -> None: + frac = min(1.0, max(0.0, float(frac))) + job.update(percent=min(99, int(100 * (base + weight * frac) / total)), message=message) + + started = time.perf_counter() + try: + row["state"] = await steps[key](report, stopped) + except InterruptedError: + row["state"] = "cancel" + job.update(cancelled=True, message="끊음") + break + except Exception as error: # noqa: BLE001 — 단계 실패 격리 · 까닭은 그대로 올림 + logger.exception("E02 체인 %s 실패: project_id=%s", key, project_id) + row["state"] = "error" + errors.append(f"{name}: {getattr(error, 'detail', None) or error}") + finally: + row["seconds"] = round(time.perf_counter() - started, 1) + _mark_stage(project_id, key, row["state"]) + base += weight + job["percent"] = min(99, int(100 * base / total)) + job["error"] = " · ".join(errors) or None + if not job["cancelled"]: + job.update(percent=100, message="끝") + job.update(stage=None, stage_name=None) + + +# --------------------------------------------------------------------------- +# 실제 단계 — 페이지 함수 부르기(늦은 import · E03 · E06 라우터가 E02 라우터를 import 함) +# --------------------------------------------------------------------------- + + +def _plug(jobs: dict[str, dict[str, Any]], pid: str, extra: dict[str, Any]) -> dict[str, Any]: + """그 페이지 진행 표에 체인 일감을 꽂음 — 페이지 화면 「해석 중」 · 페이지 [해석] 409.""" + job = {"running": True, "percent": 0, "message": "한 번에 해석", "error": None, **extra} + jobs[pid] = job + return job + + +def build_steps(project_id: UUID, project_root: Path) -> dict[str, Step]: + from E01_StabRegister import E01_StabRegister_Source as source + from E03_StabSection import E03_StabSection_Router as e03 + from E04_Stab3D import E04_Stab3D_Router as e04 + from E04_Stab3D import E04_Stab3D_Run as run3d + from E05_StabReport import E05_StabReport_Build as e05_build + from E05_StabReport import E05_StabReport_Router as e05 + from E05_StabReport import E05_StabReport_Store as e05_store + from E06_StabRock import E06_StabRock_Router as e06 + from E07_StabArea import E07_StabArea_Router as e07 + + pid = str(project_id) + root = project_root + + async def step_2d(report: Report, _stopped: Stopped) -> str: + plan = await asyncio.to_thread(e03.plan_run, root, e03._ground(root)) + if e03.read_derived(root, e03.KIND_SUMMARY, plan["fp"]): + return "skip" + page = _plug(e03._JOBS, pid, {"computed": 0}) + + def relay(percent: int, message: str) -> None: + page.update(percent=int(percent), message=message) + report(percent / 100, f"2D {message}") + + try: + page["computed"] = await asyncio.to_thread(e03.run_all, root, plan, relay) + page.update(percent=100, message="끝") + finally: + page["running"] = False + return "done" + + def search_settings() -> dict[str, Any]: + """3D 탐색 설정 = 가장 최근 저장본 설정 · 없으면 기본값(구간 · 조건 빼고).""" + files = sorted(run3d.results_dir(root).glob("*.json"), key=lambda p: p.stat().st_mtime_ns) + saved = run3d.load(root, files[-1].stem) if files else None + fields = e04.SearchSettings.model_fields + values = {k: v for k, v in ((saved or {}).get("settings") or {}).items() if k in fields} + return e04.SearchSettings(**values).model_dump() + + async def step_3d(report: Report, stopped: Stopped) -> str: + src = e04._source_root(root) + stations = await asyncio.to_thread(source.stations, src) + sections = run3d.auto_sections(stations) + search = await asyncio.to_thread(search_settings) + units = [(s, c) for s in sections for c in CONDITIONS_3D] + computed = 0 + for index, (section, condition) in enumerate(units): + if stopped(): + raise InterruptedError + settings = { + "start_m": round(section["start_m"], 2), + "end_m": round(section["end_m"], 2), + "condition": condition, + **search, + } + key = run3d.section_key(settings["start_m"], settings["end_m"], condition) + label = f"3D {index + 1} / {len(units)} 구간" + finger = await asyncio.to_thread(e04._current_fingerprint, root, src, settings) + saved = await asyncio.to_thread(run3d.load, root, key) + if saved and saved["fingerprint"] == finger: + report((index + 1) / len(units), label) + continue + page = _plug(e04._JOBS, pid, {**e04._IDLE, "running": True, "key": key}) + e04._STOPS[pid] = False + + def relay(percent: int, message: str, index: int = index, page=page) -> None: + page.update(percent=max(0, min(99, int(percent))), message=message) + report((index + percent / 100) / len(units), label) + + try: + page["summary"] = await asyncio.to_thread( + run3d.analyze, + root, + src, + settings, + finger, + relay, + lambda: stopped() or e04._STOPS.get(pid, False), + ) + page.update(percent=100, message="끝") + except InterruptedError: + page.update(cancelled=True, message="끊음") + raise + finally: + page["running"] = False + computed += 1 + return "done" if computed else "skip" + + async def step_rock(_report: Report, _stopped: Stopped) -> str: + out = await asyncio.to_thread(e06._run, root) + return "skip" if out["status"] == "fresh" else "done" + + async def step_area(report: Report, _stopped: Stopped) -> str: + src = e07._source_root(root) + saved = await asyncio.to_thread(e07.load, root) + base = (saved or {}).get("settings") or e07.defaults(root) + settings = e07.AreaRequest(**base).model_dump(exclude={"force"}) + finger = e07.fingerprint(source.manifest(src)["source_fingerprint"], settings) + if saved and saved["fingerprint"] == finger: + return "skip" + page = _plug(e07._JOBS, pid, {}) + + def relay(percent: int, message: str) -> None: + page.update(percent=max(0, min(99, int(percent))), message=message) + report(percent / 100, f"광역 {message}") + + try: + await asyncio.to_thread(e07.run, root, src, settings, finger, relay) + page.update(percent=100, message="끝") + finally: + page["running"] = False + return "done" + + async def step_report(_report: Report, _stopped: Stopped) -> str: + async with e05._lock(project_id): + book, meta = e05_store.load(root) + fresh = await asyncio.to_thread(e05_build.analysis_fingerprint, root) + if book is not None and meta and meta.get("fingerprint") == fresh: + return "skip" + await asyncio.to_thread(e05_store.refresh, root) + return "done" + + return { + "2d": step_2d, + "3d": step_3d, + "rock": step_rock, + "area": step_area, + "report": step_report, + } + + +def busy(project_id: UUID) -> bool: + """체인 · 페이지 해석 하나라도 도는지 — 한 프로젝트에 체인 하나만.""" + from E03_StabSection import E03_StabSection_Router as e03 + from E04_Stab3D import E04_Stab3D_Router as e04 + from E07_StabArea import E07_StabArea_Router as e07 + + pid = str(project_id) + return any( + (jobs.get(pid) or {}).get("running") for jobs in (_JOBS, e03._JOBS, e04._JOBS, e07._JOBS) + ) + + +def start(project_id: UUID, project_root: Path) -> dict[str, Any]: + """뒤에서 체인 시작 — 부르는 쪽이 busy 를 먼저 봄.""" + pid = str(project_id) + job = {**idle(), "running": True, "message": "시작"} + _JOBS[pid] = job + _STOPS[pid] = False + steps = build_steps(project_id, project_root) + + async def work() -> None: + try: + await run_chain(job, steps, lambda: _STOPS.get(pid, False), pid) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E02 chain {pid}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return job + + +def progress(project_id: UUID) -> dict[str, Any]: + return dict(_JOBS.get(str(project_id)) or idle()) + + +def cancel(project_id: UUID) -> bool: + pid = str(project_id) + running = bool((_JOBS.get(pid) or {}).get("running")) + if running: + _STOPS[pid] = True + return running diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py index aee593430..66fe01cc6 100644 --- a/E02_StabGround/E02_StabGround_Router.py +++ b/E02_StabGround/E02_StabGround_Router.py @@ -5,6 +5,8 @@ - `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json` · 측점(B06 암 경계)은 서버 값 - `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) +- `POST …/stab/run` — [해석 시작] 지반 저장 → 한 번에 해석 체인(`E02_StabGround_Chain` · PLAN 564-1) + · `GET …/stab/run/progress` · `POST …/stab/run/cancel` 초기값 = E 생성 직후 마스터 기본값 + 복사본 B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음. 공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. @@ -29,6 +31,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M from E01_StabRegister.E01_StabRegister_Source import db_snapshot, missing_stations, source_dir +from E02_StabGround import E02_StabGround_Chain as chain from E02_StabGround import E02_StabGround_Model as model router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"]) @@ -130,20 +133,25 @@ async def get_ground( } -@router.put("/{project_id}/stab/ground") -async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: - root, _ = await _project(project_id) +def _save(root: Path, payload: dict[str, Any]) -> dict[str, Any]: + """검사 뒤 작업본 쓰기 — 틀리면 422.""" defaults = model.load_defaults() - errors = model.check_ground(payload.ground, defaults["limits"]) + errors = model.check_ground(payload, defaults["limits"]) if errors: raise HTTPException(status_code=422, detail={"errors": errors}) - ground = model.clean(payload.ground) + ground = model.clean(payload) # 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음 base = _read_working(root) or ensure_initial(root) ground["stations"] = base.get("stations", []) ground["version"] = defaults["version"] atomic_write_json(model.ground_path(root), ground) - return {"ground": ground} + return ground + + +@router.put("/{project_id}/stab/ground") +async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: + root, _ = await _project(project_id) + return {"ground": _save(root, payload.ground)} @router.post("/{project_id}/stab/ground/reset") @@ -152,3 +160,26 @@ async def reset_ground(project_id: UUID) -> dict[str, Any]: initial = ensure_initial(root) atomic_write_json(model.ground_path(root), initial) return {"ground": model.upgrade(initial, model.load_defaults())} + + +@router.post("/{project_id}/stab/run") +async def run_chain(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: + """[해석 시작] — 지반 저장 → 체인(뒤에서) · 진행은 `GET …/stab/run/progress`.""" + root, _ = await _project(project_id) + if chain.busy(project_id): + raise HTTPException(status_code=409, detail="이미 해석 중입니다.") + ground = _save(root, payload.ground) + return {"ground": ground, **chain.start(project_id, root)} + + +@router.get("/{project_id}/stab/run/progress") +async def run_progress(project_id: UUID) -> dict[str, Any]: + await _project(project_id) + return chain.progress(project_id) + + +@router.post("/{project_id}/stab/run/cancel") +async def run_cancel(project_id: UUID) -> dict[str, Any]: + """[끊기] — 다음 확인 때 멈춤 · 끝난 단계 저장본은 그대로.""" + await _project(project_id) + return {"stopping": chain.cancel(project_id)} diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index a483e2d71..a32bae7c5 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -2,8 +2,10 @@ * E02_StabGround_UI_Page.ts * E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow). * GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지. - * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) · - * [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝). + * 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 · + * 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른 + * 페이지에 갔다 와도 이어 보임) · [끊기] · [초기화] = POST …/reset(초기값 덮어씀). + * 검사 = logic/E02_StabGround_Check(파이썬 짝). * 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05). * ========================================================================== */ @@ -18,6 +20,8 @@ import { showToast, } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { createListRow } from "@ui/ui_template_list_row"; +import { createProgressBar } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; @@ -38,8 +42,33 @@ interface GroundResponse { missing_stations?: number[]; } +interface RunStep { + key: string; + state: string; + seconds: number | null; +} + +/** 체인 진행(`E02_StabGround_Chain` 진행 꼴) */ +interface RunProgress { + running: boolean; + percent: number; + steps: RunStep[]; + message: string | null; + error: string | null; + cancelled: boolean; +} + const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; +/** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */ +const STEP_TONE: Record = { + run: "var(--color-accent)", + done: "var(--color-success)", + skip: "var(--color-success)", + error: "var(--color-danger)", + cancel: "var(--color-warning)", +}; + /** 검사 열쇠 → 범위 열쇠(limits) */ function limitKey(path: string): string { const last = path.split(".").pop() ?? ""; @@ -56,13 +85,13 @@ function errorText(error: CheckError, defaults: GroundDefaults): string { return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text; } -async function request(path: string, init?: RequestInit): Promise { +async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); - const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown }; + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; if (!response.ok) { const detail = body.detail; if (typeof detail === "string") throw new Error(detail); @@ -76,29 +105,95 @@ export function renderE02StabGround(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const main = el("div", { className: "e02-page" }); const status = el("div", { className: "e02-status" }); - const saveButton = createButton({ - label: t("Common_Btn_Save"), + const runButton = createButton({ + label: t("E02_Run"), variant: "filled", - onClick: () => void save(), + onClick: () => void runChain(), }); const resetButton = createButton({ label: t("Common_Btn_Reset"), variant: "danger-ghost", onClick: () => void reset(), }); - saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 + runButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(resetButton, saveButton); + actions.append(resetButton, runButton); + const progressBar = createProgressBar(); + const progressText = createText(""); + const stepList = el("div", { className: "e02-run__steps" }); + const cancelButton = createButton({ + label: t("E02_Run_Cancel"), + variant: "danger-ghost", + size: "sm", + onClick: () => void cancelChain(), + }); + const runBox = el("div", { className: "e02-run" }); + runBox.append(progressBar.root, progressText, stepList, cancelButton); + runBox.hidden = true; const panel = el("div", { className: "ui-sidebar-panel e02-panel" }); - panel.append(hook(createText(t("E02_Note")), "e02-hint"), status, actions); + panel.append( + hook(createText(t("E02_Note")), "e02-hint"), + hook(createText(t("E02_Run_Hint")), "e02-hint"), + status, + runBox, + actions, + ); mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel }); if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty")); let view: E02View | null = null; let refreshStations = (): void => {}; let dirty = false; + let running = false; let missing: number[] = []; + const paintButtons = (): void => { + runButton.disabled = resetButton.disabled = !view?.editable || running; + }; + + const paintRun = (p: RunProgress): void => { + running = p.running; + runBox.hidden = !p.running && p.steps.every((s) => s.state === "wait"); + progressBar.set(p.percent / 100, `${p.percent}%`); + progressText.textContent = p.running ? `${t("E02_Run_Running")} · ${p.message ?? ""}` : ""; + stepList.replaceChildren( + ...p.steps.map((s) => + createListRow({ + label: t(`E02_Run_Step_${s.key}` as LocaleKey), + trailing: [ + t(`E02_Run_State_${s.state}` as LocaleKey), + s.seconds !== null && s.state !== "wait" ? `${s.seconds} s` : "", + ] + .filter(Boolean) + .join(" · "), + stripe: STEP_TONE[s.state] ?? "transparent", + active: s.state === "run", + size: 28, + }), + ), + ); + cancelButton.hidden = !p.running; + paintButtons(); + }; + + /** 체인이 끝날 때까지 — 다른 페이지로 가면 멈춤(체인은 서버에서 계속 · 다시 오면 이어 봄) */ + async function poll(): Promise { + while (root.isConnected) { + const p = await request(`/projects/${projectId}/stab/run/progress`).catch( + () => null, + ); + if (!p) return; + paintRun(p); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else if (p.cancelled) showToast(t("E02_Run_Cancelled"), "info"); + else showToast(t("E02_Run_Done"), "success"); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + const paintStatus = (errors: CheckError[]): void => { const lines: string[] = []; if (view && !view.editable) lines.push(t("E02_ReadOnly")); @@ -158,12 +253,13 @@ export function renderE02StabGround(root: HTMLElement): void { view.ground = ground; view.defaults = body.defaults; view.editable = body.editable ?? false; - saveButton.disabled = resetButton.disabled = !view.editable; + paintButtons(); render(); }; - async function save(): Promise { - if (!view?.editable) return; + /** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */ + async function runChain(): Promise { + if (!view?.editable || running) return; const errors = mark(); if (errors.length) { showToast( @@ -174,16 +270,30 @@ export function renderE02StabGround(root: HTMLElement): void { } showLoadingOverlay(); try { - const body = await request(`/projects/${projectId}/stab/ground`, { - method: "PUT", + const body = await request(`/projects/${projectId}/stab/run`, { + method: "POST", body: JSON.stringify({ ground: view.ground }), }); - adopt({ ...body, defaults: view.defaults, editable: view.editable }, false); - showToast(t("E02_Saved"), "success"); + adopt({ ground: body.ground, defaults: view.defaults, editable: view.editable }, false); + paintRun(body); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + return; + } finally { + hideLoadingOverlay(); + } + void poll(); + } + + async function cancelChain(): Promise { + cancelButton.disabled = true; + try { + await request(`/projects/${projectId}/stab/run/cancel`, { method: "POST" }); + showToast(t("E02_Run_Stopping"), "info"); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } finally { - hideLoadingOverlay(); + cancelButton.disabled = false; } } @@ -246,6 +356,9 @@ export function renderE02StabGround(root: HTMLElement): void { }, }; adopt(body, true); + const p = await request(`/projects/${projectId}/stab/run/progress`); + paintRun(p); + if (p.running) void poll(); } catch (error) { main.replaceChildren( hook( diff --git a/E02_StabGround/E02_StabGround_UI_Style.css b/E02_StabGround/E02_StabGround_UI_Style.css index bade5d2fd..0bf50e4be 100644 --- a/E02_StabGround/E02_StabGround_UI_Style.css +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -121,3 +121,20 @@ gap: 2px; align-items: center; } + +/* [해석 시작] 진행 — 막대 · 단계 줄 · [끊기] */ +.e02-run { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e02-run[hidden] { + display: none; +} + +.e02-run__steps { + display: flex; + flex-direction: column; +} 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_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index 9cfcf82ed..db51f4c4d 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -9,6 +9,7 @@ import { createIconButton } from "@ui/ui_template_button"; import { el } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; 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 { listRows, @@ -29,7 +30,9 @@ export interface ListDraw { sort: SortMode; ngOnly: boolean; active: string | null; - /** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */ + /** 측점 간격(m) — 측점 글 「No.+거리」 의 분모 */ + interval: number; + /** 사용자 켬/끔 — 서버 값(별 누름 = 바로 저장) */ representative: (row: StationRow) => { on: boolean; user: boolean | null }; editable: boolean; } @@ -81,7 +84,7 @@ export function buildList( ); verdict.classList.add("e03-verdict", `e03-verdict--${v}`); const station = el("span", { - text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key, + text: stationLabel(row.chainage_m, draw.interval, 1), children: row.walls ? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })] : [], diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index b7baa1711..4d5621753 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -24,7 +24,8 @@ import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; -import { buildPlan } from "./E03_StabSection_UI_Plan"; +import { buildPlan, type PlanView } from "./E03_StabSection_UI_Plan"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts"; import { buildResult } from "./E03_StabSection_UI_Result"; import { @@ -84,7 +85,6 @@ async function api(path: string, init?: RequestInit): Promise { return body; } -const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); export function renderE03StabSection(root: HTMLElement): void { @@ -111,9 +111,9 @@ export function renderE03StabSection(root: HTMLElement): void { const slopeRecs = new Map(); // 측점 열쇠 → 마지막 가정 const wallRecs = new Map(); const supportRecs = new Map(); - let repCache: Record = JSON.parse( - sessionStorage.getItem(repKey(pid)) ?? "{}", - ); + let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390) + const planView: PlanView = {}; + let repSaving: Promise = Promise.resolve(); // 별 누름마다 차례로 저장 // ---- 좌측 ---- const condField = createSelectField({ @@ -158,15 +158,10 @@ export function renderE03StabSection(root: HTMLElement): void { variant: "ghost", onClick: () => void show3d(), }); - const repButton = createButton({ - label: t("E03_RepSave"), - variant: "ghost", - onClick: () => void saveRep(), - }); const controls = el("div", { className: "e03-panel__row" }); controls.append(condField.root, sortField.root); const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(repButton, view3dButton, analyzeButton); + actions.append(view3dButton, analyzeButton); panel.append(controls, ngBox, status, listHost, actions); // ---- 본문 ---- @@ -187,11 +182,8 @@ export function renderE03StabSection(root: HTMLElement): void { const repOf = (row: StationRow): { on: boolean; user: boolean | null } => { const server = row.representative ?? { auto: [], user: null, on: false }; - if (!(row.key in repCache)) return { on: server.on, user: server.user }; - const user = repCache[row.key]; - return { on: user === null ? server.auto.length > 0 : user, user }; + return { on: server.on, user: server.user }; }; - const repDirty = (): boolean => Object.keys(repCache).length > 0; const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active); @@ -217,10 +209,8 @@ export function renderE03StabSection(root: HTMLElement): void { if (!result.fresh) lines.push(t("E03_Stale")); } if (result && !result.editable) lines.push(t("E03_ReadOnly")); - if (repDirty()) lines.push(t("E03_RepUnsaved")); status.textContent = lines.join("\n"); analyzeButton.disabled = !result?.editable; - repButton.disabled = !result?.editable || !repDirty(); view3dButton.disabled = result?.status !== "ok"; }; @@ -235,22 +225,12 @@ export function renderE03StabSection(root: HTMLElement): void { sort, ngOnly, active, + interval, representative: repOf, editable: result.editable, }, (key) => void select(key), - (key) => { - const row = result!.stations.find((r) => r.key === key)!; - const now = repOf(row).on; - const auto = (row.representative?.auto.length ?? 0) > 0; - // 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌 - const next = !now === auto ? null : !now; - if (next === (row.representative?.user ?? null)) delete repCache[key]; - else repCache[key] = next; - sessionStorage.setItem(repKey(pid), JSON.stringify(repCache)); - paintList(); - paintStatus(); - }, + (key) => void toggleRep(key), ), ); }; @@ -286,7 +266,7 @@ export function renderE03StabSection(root: HTMLElement): void { }); const title = el("h3", { className: "e03-card__title", - text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, + text: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, }); const flags = el("div", { className: "e03-panel__row" }); flags.append( @@ -399,7 +379,16 @@ export function renderE03StabSection(root: HTMLElement): void { const paintPlan = (): void => { planHost.replaceChildren( - result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"), + result + ? buildPlan( + result.stations, + condition, + active, + interval, + planView, + (key) => void select(key), + ) + : el("div"), ); }; @@ -438,7 +427,10 @@ export function renderE03StabSection(root: HTMLElement): void { condition, ); const keys = result.stations.map((r) => r.key); - repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k))); + interval = inferStationInterval( + result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m), + ); + delete planView.zoom; // 다시 읽음 = 띠 원배율 if (!active || !keys.includes(active)) { // 처음 = 그 조건 가장 낮은 안전율 측점 const worst = result.stations @@ -488,20 +480,50 @@ export function renderE03StabSection(root: HTMLElement): void { } } - async function saveRep(): Promise { - try { + /** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */ + function toggleRep(key: string): Promise { + repSaving = repSaving.then(async () => { + const row = result?.stations.find((r) => r.key === key); + if (!row) return; + const server = row.representative ?? { auto: [], user: null, on: false }; + const auto = server.auto.length > 0; + const next = !server.on === auto ? null : !server.on; const overrides: Record = {}; - for (const row of result?.stations ?? []) { - const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null); - if (user !== null) overrides[row.key] = user; + for (const r of result?.stations ?? []) { + const user = r.key === key ? next : (r.representative?.user ?? null); + if (user !== null) overrides[r.key] = user; } - await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); - repCache = {}; - sessionStorage.removeItem(repKey(pid)); - showToast(t("E03_RepSaved"), "success"); - await load(); - } catch (error) { - showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + try { + await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); + row.representative = { ...server, user: next, on: next === null ? auto : next }; + paintList(); + void refreshReport(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + }); + return repSaving; + } + + /** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */ + let reportRun = 0; + async function refreshReport(): Promise { + const run = ++reportRun; + await sleep(800); // 별을 잇달아 누르면 마지막 한 번만 + if (run !== reportRun) return; + try { + const state = await api<{ book: unknown; rev: string | null }>( + `/projects/${pid}/stab/report`, + ); + if (!state.book || run !== reportRun) return; + const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, { + method: "POST", + body: JSON.stringify({ rev: state.rev }), + }); + const over = body.overwritten ?? []; + if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000); + } catch { + /* 보고서는 E05 를 열 때 stale 로 알림 */ } } diff --git a/E03_StabSection/E03_StabSection_UI_Plan.ts b/E03_StabSection/E03_StabSection_UI_Plan.ts index cb38e360c..cb1bacaea 100644 --- a/E03_StabSection/E03_StabSection_UI_Plan.ts +++ b/E03_StabSection/E03_StabSection_UI_Plan.ts @@ -3,14 +3,20 @@ * E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이 * 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점. * 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`. + * 확대 · 이동 = 공용 `attachSvgZoomPan`(휠 · 가운데 끌기 · 두 번 누름 원배율 · 요구 390) · 측점 글 솎음 + * = logic `labelStride` · `planLabelKeys` · 배율 · 폭은 바깥 `PlanView` 가 쥠(다시 지어도 살아남음). * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; +import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; +import { stationLabel } from "../common_util/common_util_station"; import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { bandSegments, + labelStride, planFit, + planLabelKeys, verdictOf, type Condition, type Point, @@ -21,11 +27,20 @@ import { svgEl } from "./E03_StabSection_UI_Section"; const H = 96; const PAD = 10; +const LABEL_GAP = 64; // 측점 글 사이 최소 화면 간격(px) + +/** 띠 배율 · 그때 폭 — 폭이 바뀌면 원배율로 */ +export interface PlanView { + zoom?: ZoomPanState; + width?: number; +} export function buildPlan( rows: StationRow[], condition: Condition, active: string | null, + interval: number, + state: PlanView, onSelect: (key: string) => void, ): HTMLElement { const box = card("e03-plan"); @@ -49,47 +64,110 @@ export function buildPlan( const host = el("div"); box.append(host); + const ro = new ResizeObserver(() => draw()); const draw = (): void => { + if (!host.isConnected) return void ro.disconnect(); // 다시 지은 띠 — 옛 것은 손 뗌 const W = Math.max(host.clientWidth, 300); const segs = bandSegments(rows, condition); const all: Point[] = segs.flatMap((s) => s.points); const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" }); - if (all.length) { - const fit = planFit(all, W, H, PAD); - const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; - for (const seg of segs) { - svg.append( - svgEl("polyline", { - points: seg.points.map((p) => scr(p).join(",")).join(" "), - class: `e03-plan__seg e03-plan__seg--${seg.verdict}`, - "data-verdict": seg.verdict, - }), - ); - } - const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null); - const picked = centers.find((r) => r.key === active); - if (picked) { - const [x, y] = scr([picked.center![0]!, picked.center![1]!]); - svg.append( - svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }), - ); - } - svg.addEventListener("click", (ev) => { - const box2 = svg.getBoundingClientRect(); - const px = ((ev.clientX - box2.left) / box2.width) * W; - const py = ((ev.clientY - box2.top) / box2.height) * H; - let best: StationRow | null = null; - let dist = Infinity; - for (const r of centers) { - const [x, y] = scr([r.center![0]!, r.center![1]!]); - const d = Math.hypot(x - px, y - py); - if (d < dist) [best, dist] = [r, d]; - } - if (best && dist < 30) onSelect(best.key); - }); - } host.replaceChildren(svg); + if (!all.length) return; + const fit = planFit(all, W, H, PAD); + const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; + const layer = svgEl("g", { "data-role": "plan-layer" }) as SVGGElement; + for (const seg of segs) { + layer.append( + svgEl("polyline", { + points: seg.points.map((p) => scr(p).join(",")).join(" "), + class: `e03-plan__seg e03-plan__seg--${seg.verdict}`, + "data-verdict": seg.verdict, + }), + ); + } + const centers = rows + .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) + .sort((a, b) => a.chainage_m - b.chainage_m); + const at = (r: StationRow): Point => scr([r.center![0]!, r.center![1]!]); + // 측점 간격 하나의 화면 길이(원배율) — 중심 이은 길이 ÷ 누가 길이 × 간격 + const span = + centers.length > 1 ? centers[centers.length - 1].chainage_m - centers[0].chainage_m : 0; + let len = 0; + for (let i = 1; i < centers.length; i += 1) { + const [a, b] = [at(centers[i - 1]), at(centers[i])]; + len += Math.hypot(b[0] - a[0], b[1] - a[1]); + } + const intervalPx = span > 0 ? (len / span) * interval : 0; + const maxScale = Math.min(500, Math.max(8, Math.ceil(240 / Math.max(intervalPx, 1e-3)))); + const picked = centers.find((r) => r.key === active); + const pick = picked + ? svgEl("circle", { + cx: at(picked)[0], + cy: at(picked)[1], + r: 6, + class: "e03-plan__pick", + "data-key": picked.key, + }) + : null; + const labels = svgEl("g", { class: "e03-plan__labels" }) as SVGGElement; + layer.append(labels, ...(pick ? [pick] : [])); + svg.append(layer); + let stride = 0; + const apply = (s: ZoomPanState): void => { + state.zoom = s; + labels.style.setProperty("--e03-label-k", String(1 / s.scale)); // 글 · 표시는 확대 안 함 + pick?.setAttribute("r", String(6 / s.scale)); + const next = intervalPx > 0 ? labelStride(intervalPx * s.scale, LABEL_GAP) : 100; + if (next === stride) return; + stride = next; + const keep = new Set(planLabelKeys(centers, interval, stride, active)); + labels.replaceChildren( + ...centers + .filter((r) => keep.has(r.key)) + .map((r) => + svgEl( + "text", + { + x: at(r)[0], + y: at(r)[1], + dy: "-0.9em", + class: "e03-label e03-plan__label", + "data-key": r.key, + }, + stationLabel(r.chainage_m, interval, 1), + ), + ), + ); + }; + const initial = state.width === W ? state.zoom : undefined; + state.width = W; + attachSvgZoomPan({ + svg, + layer, + widthPx: W, + heightPx: H, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, + wheelZoom: true, + maxScale, + initial, + onChange: apply, + }); + apply(initial ?? { scale: 1, tx: 0, ty: 0 }); + svg.addEventListener("click", (ev) => { + const box2 = svg.getBoundingClientRect(); + const px = ((ev.clientX - box2.left) / box2.width) * W; + const py = ((ev.clientY - box2.top) / box2.height) * H; + const s = state.zoom ?? { scale: 1, tx: 0, ty: 0 }; + let best: StationRow | null = null; + let dist = Infinity; + for (const r of centers) { + const [x, y] = at(r); + const d = Math.hypot(s.scale * x + s.tx - px, s.scale * y + s.ty - py); + if (d < dist) [best, dist] = [r, d]; + } + if (best && dist < 30) onSelect(best.key); + }); }; - new ResizeObserver(draw).observe(host); + ro.observe(host); return box; } diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index f0de0ff7b..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,13 +74,14 @@ --e03-critical: #f0efec; --e03-surface-line: #c3c2b7; --e03-plot: #1a1a19; + --e03-terrain: #5b5646; } } /* ---- 틀 ---- */ .e03-page { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); gap: var(--spacing-8); height: calc(100dvh - var(--app-header-h)); padding: var(--spacing-8) var(--spacing-12); @@ -207,6 +210,7 @@ .e03-plan__seg { fill: none; stroke-width: 7; + vector-effect: non-scaling-stroke; stroke-linecap: butt; stroke-linejoin: round; cursor: pointer; @@ -228,6 +232,12 @@ fill: var(--e03-plot); stroke: var(--e03-critical); stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.e03-plan__label { + text-anchor: middle; + pointer-events: none; } .e03-legend { 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/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index c869cb37a..969d97871 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -190,6 +190,31 @@ export function planFit( return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 }; } +/** 평면 띠 측점 글 솎음 걸음 — 측점 간격 화면 길이(px) × 걸음이 minPx 넘는 가장 작은 것 */ +export const PLAN_LABEL_STRIDES = [1, 2, 5, 10, 25, 50, 100] as const; + +export function labelStride(intervalPx: number, minPx: number): number { + return PLAN_LABEL_STRIDES.find((s) => s * intervalPx >= minPx) ?? 100; +} + +/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘 */ +export function planLabelKeys( + rows: StationRow[], + interval: number, + stride: number, + active: string | null, +): string[] { + const step = interval > 0 ? interval : 1; + return rows + .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) + .filter((r) => { + if (r.key === active || stride === 1) return true; + const n = Math.round(r.chainage_m / step); + return Math.abs(r.chainage_m - n * step) < 0.05 && n % stride === 0; + }) + .map((r) => r.key); +} + /* ---- 안전율 색 ------------------------------------------------------------ */ /** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */ diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index 2416a95e8..374ba4eab 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -5,7 +5,7 @@ - `POST …/stab/analyze3d/cancel` — 도는 해석 끊기(다시 시작 = analyze3d 다시) - `GET …/stab/progress3d` — 진행률 - `GET …/stab/result3d` — 자동 구간 · 노선 · 측점 · 저장본 요약(+ 지문이 지금과 다른지) -- `GET …/stab/result3d?key=` — 한 구간 저장본(래스터 · 임계 활동면) +- `GET …/stab/result3d?key=` — 한 구간 · 조건 저장본(래스터 · 임계 활동면) · 열쇠 = `시작_끝_조건` 계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`(지정받은 회사 = GET 만 · 그 밖 403). """ @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio import logging +import re from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -126,7 +127,7 @@ async def analyze3d(project_id: UUID, payload: Analyze3dRequest) -> dict[str, An project_root = await _project_root(project_id) root = _source_root(project_root) settings = _settings(payload) - key = run.section_key(settings["start_m"], settings["end_m"]) + key = run.section_key(settings["start_m"], settings["end_m"], settings["condition"]) finger = await asyncio.to_thread(_current_fingerprint, project_root, root, settings) saved = await asyncio.to_thread(run.load, project_root, key) if not payload.force and saved and saved["fingerprint"] == finger: @@ -229,7 +230,7 @@ async def result3d( overview = await asyncio.to_thread(_overview, project_root, root) editable = await _editable(project_id, session) return {"status": "success", "editable": editable, **overview} - if not all(ch.isdigit() or ch in "._" for ch in key): + if not re.fullmatch(r"\d+\.\d{2}_\d+\.\d{2}(_(dry|wet))?", key): raise HTTPException(status_code=400, detail="구간 열쇠가 틀렸습니다.") doc = await asyncio.to_thread(run.load, project_root, key) if doc is None: diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index fcd600263..2b40fcaaf 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -1,8 +1,9 @@ """E04 3D 해석 한 번 — 입력 모으기 · 입력 지문 · 엔진 부르기 · 파생 저장본 (PLAN 533-3). 계산 자리 = ③ 서버 단독. 엔진(탐색 · 기둥 솔버 · 합성 지형) 주인 = 533-1 · 2 — 여기는 부르기만. -저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 입력 지문(복사본 지문 · E02 지반 · -요청 · 엔진 판)이 같으면 다시 셈하지 않음. +저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 열쇠 = `시작_끝_조건` +(예 `0.00_100.00_wet` · 옛 `시작_끝` 파일은 그 안 조건으로 읽음) · 입력 지문(복사본 지문 · +E02 지반 · 요청 · 엔진 판 = 엔진 파일 해시)이 같으면 다시 셈하지 않음. 래스터 꼴(화면 `logic/E04_Stab3D_Raster.ts` 와 같은 열쇠): grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열 @@ -22,6 +23,7 @@ import math import time from collections.abc import Callable from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Any @@ -34,7 +36,13 @@ from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine -ENGINE_VERSION = "e04-4" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_FILES = ( + "E04_Stab3D_Engine_Column.py", + "E04_Stab3D_Engine_Grid.py", + "E04_Stab3D_Engine_Search.py", + "E04_Stab3D_Run.py", +) +CONDITIONS = ("dry", "wet") STAGE_DIR = "E04_Stab3D" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리) @@ -48,8 +56,23 @@ def results_dir(project_root: Path) -> Path: return Path(project_root) / STAGE_DIR / "results" -def section_key(start_m: float, end_m: float) -> str: - return f"{start_m:.2f}_{end_m:.2f}" +@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 section_key(start_m: float, end_m: float, condition: str = "dry") -> str: + return f"{start_m:.2f}_{end_m:.2f}_{condition}" + + +def _doc_key(doc: dict[str, Any]) -> str: + summary = doc["summary"] + condition = summary.get("condition") or doc["settings"].get("condition") or "dry" + return section_key(float(summary["start_m"]), float(summary["end_m"]), condition) def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]: @@ -85,7 +108,7 @@ def fingerprint( "source": source_fingerprint, "ground": ground, "settings": settings, - "engine": ENGINE_VERSION, + "engine": engine_hash(), }, ensure_ascii=False, sort_keys=True, @@ -308,7 +331,7 @@ def pack( ) return { "fingerprint": finger, - "engine": ENGINE_VERSION, + "engine": engine_hash(), "settings": settings, "computed_at": datetime.now().isoformat(timespec="seconds"), "elapsed_s": round(elapsed_s, 2), @@ -346,24 +369,37 @@ def save(project_root: Path, key: str, result: dict[str, Any]) -> None: def load(project_root: Path, key: str) -> dict[str, Any] | None: - path = results_dir(project_root) / f"{key}.json" - return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + """열쇠 파일 → 없으면 옛 이름(`시작_끝`) 파일이 같은 조건일 때.""" + folder = results_dir(project_root) + path = folder / f"{key}.json" + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + base, _, condition = key.rpartition("_") + legacy = folder / f"{base}.json" + if condition in CONDITIONS and legacy.is_file(): + doc = json.loads(legacy.read_text(encoding="utf-8")) + if _doc_key(doc) == key: + return doc + return None def saved_summaries(project_root: Path) -> list[dict[str, Any]]: - """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용.""" - out = [] + """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용. + + 열쇠 = 구간 + 조건 · 같은 열쇠면 새 이름 파일이 옛 이름 파일을 이김.""" + found: dict[str, dict[str, Any]] = {} for path in sorted(results_dir(project_root).glob("*.json")): doc = json.loads(path.read_text(encoding="utf-8")) - out.append( - { - "key": path.stem, - "fingerprint": doc["fingerprint"], - "settings": doc["settings"], - **doc["summary"], - } - ) - return out + key = _doc_key(doc) + if key in found and path.stem != key: + continue + found[key] = { + "key": key, + "fingerprint": doc["fingerprint"], + "settings": doc["settings"], + **doc["summary"], + } + return [found[k] for k in sorted(found)] def analyze( @@ -385,5 +421,9 @@ def analyze( ground = read_ground(project_root) raw = run_engine(root, around, ground, settings, on_progress, should_stop) result = pack(raw, picked, settings, finger, time.perf_counter() - started) - save(project_root, section_key(settings["start_m"], settings["end_m"]), result) + save( + project_root, + section_key(settings["start_m"], settings["end_m"], settings["condition"]), + result, + ) return result["summary"] diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index e0a3dbbff..ef7c04817 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -4,7 +4,8 @@ * 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기] * 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면) * 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법) - * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage. + * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage + * · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음). * ========================================================================== */ import "./E04_Stab3D_UI_Style.css"; @@ -44,6 +45,7 @@ interface SavedSummary { f_min: number; volume: number; method: string; + condition: string; stale: boolean; } @@ -67,7 +69,10 @@ interface Progress { } const cacheKey = (projectId: string): string => `e04_settings:${projectId}`; -const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; +const CONDITIONS = ["dry", "wet"] as const; +const rangeKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; +const sectionKey = (start: number, end: number, condition: string): string => + `${rangeKey(start, end)}_${condition}`; async function api(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -218,16 +223,11 @@ export function renderE04Stab3D(root: HTMLElement): void { const analyzeButton = createButton({ label: t("E04_Analyze"), variant: "filled", - onClick: () => void analyze(false), + onClick: () => void analyze(), }); - const reanalyzeButton = createButton({ - label: t("E04_Reanalyze"), - variant: "ghost", - onClick: () => void analyze(true), - }); - analyzeButton.disabled = reanalyzeButton.disabled = true; + analyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(reanalyzeButton, analyzeButton); + actions.append(analyzeButton); const readOnly = note(t("E04_ReadOnly"), "e04-hint"); readOnly.hidden = true; panel.append( @@ -252,40 +252,46 @@ export function renderE04Stab3D(root: HTMLElement): void { let shownKey = ""; settings.onChange(() => { remember(); - const { start_m, end_m } = settings.read(); + const { start_m, end_m, condition } = settings.read(); map.setRange(start_m, end_m); paintSections(); - if (shownKey !== sectionKey(start_m, end_m)) void openResult(); + if (shownKey !== sectionKey(start_m, end_m, condition)) void openResult(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); - analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + analyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } function paintSections(): void { if (!overview) return; - const { start_m, end_m } = settings.read(); + const { start_m, end_m, condition } = settings.read(); const savedOf = new Map(overview.saved.map((s) => [s.key, s])); - const custom = overview.saved.filter( - (s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key), - ); - const items = [ - ...overview.sections, - ...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })), - ]; + const items = new Map(overview.sections.map((x) => [rangeKey(x.start_m, x.end_m), x])); + for (const s of overview.saved) { + const key = rangeKey(s.start_m, s.end_m); + if (!items.has(key)) items.set(key, { start_m: s.start_m, end_m: s.end_m }); + } sectionList.replaceChildren( - ...items.map((section) => { - const key = sectionKey(section.start_m, section.end_m); - const saved = savedOf.get(key); + ...[...items].map(([key, section]) => { + const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`)); + const saved = savedOf.get(`${key}_${condition}`); const row = createListRow({ label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`, - trailing: saved - ? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}` - : "", + trailing: byCondition + .flatMap((x) => + x + ? [ + `${t(`E04_Short_${x.condition}` as LocaleKey)} ${x.f_min.toFixed(2)}${ + x.stale ? ` ${t("E04_Stale")}` : "" + }`, + ] + : [], + ) + .join(" · "), stripe: saved ? stepColor(FS_STEPS, saved.f_min) : "transparent", - active: key === sectionKey(start_m, end_m), + active: key === rangeKey(start_m, end_m), onClick: () => settings.setRange(section.start_m, section.end_m), }); row.classList.add("e04-section"); @@ -364,15 +370,19 @@ export function renderE04Stab3D(root: HTMLElement): void { resultBody.replaceChildren(list, ...notes); } + const currentKey = (): string => { + const { start_m, end_m, condition } = settings.read(); + return sectionKey(start_m, end_m, condition); + }; + async function openResult(): Promise { - const { start_m, end_m } = settings.read(); - const key = sectionKey(start_m, end_m); + const key = currentKey(); shownKey = key; const saved = overview?.saved.some((s) => s.key === key); result = saved ? await api(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null) : null; - if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜 + if (key !== currentKey()) return; // 그새 바뀜 map.setResult(result); if (mode === "3d") view3d.show(result, overview?.line ?? [], layer); paintResult(); @@ -405,13 +415,13 @@ export function renderE04Stab3D(root: HTMLElement): void { } } - async function analyze(force: boolean): Promise { + async function analyze(): Promise { const values = settings.read(); const { start_m, end_m, condition, ...search } = values; try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, { method: "POST", - body: JSON.stringify({ start_m, end_m, condition, search, force }), + body: JSON.stringify({ start_m, end_m, condition, search }), }); if (body.status === "cached") { showToast(t("E04_Cached"), "info"); @@ -446,7 +456,7 @@ export function renderE04Stab3D(root: HTMLElement): void { ...ov.defaults, start_m: first?.start_m ?? 0, end_m: first?.end_m ?? 100, - condition: "dry", + condition: ov.saved[0]?.condition ?? "dry", }, ); map.setRoute(ov.line, ov.stations); @@ -455,6 +465,12 @@ export function renderE04Stab3D(root: HTMLElement): void { paintSections(); stations2d = await fetch2d(projectId); await openResult(); + // 캐시가 없으면 저장된 결과의 탐색 설정을 되살림 + const opened = result as Result3d | null; // openResult 가 바꿈(흐름 좁힘 풀기) + if (!cached && opened?.settings) { + settings.write(opened.settings as Partial); + remember(); + } const p = await api(`/projects/${projectId}/stab/progress3d`).catch(() => null); if (p?.running) void poll(); })(); diff --git a/E04_Stab3D/E04_Stab3D_UI_Settings.ts b/E04_Stab3D/E04_Stab3D_UI_Settings.ts index 215201303..e27573cfd 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Settings.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Settings.ts @@ -98,8 +98,8 @@ export function buildSettingsCards() { variant: "ghost", size: "sm", onClick: () => { - const range = { start_m: read().start_m, end_m: read().end_m }; - write({ ...read(), ...defaults, ...range, condition: "dry" }); + const { start_m, end_m, condition } = read(); // 구간 · 조건은 그대로 + write({ ...read(), ...defaults, start_m, end_m, condition }); changed(); }, }), 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_528_project_groups.py b/resources/tester/test_528_project_groups.py index 62968e798..9f76ff31f 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -124,6 +124,8 @@ def test_d01_plan_created(monkeypatch): assert seen["design_speed_kph"] == 40 asyncio.run(d01.post_plan_project(None, _plan(road_type="work", design_speed_kph=40), ADMIN)) assert seen["design_speed_kph"] == 20 # 작업임도 = 20 만 + asyncio.run(d01.post_plan_project(None, _plan(road_type=None), ADMIN)) + assert seen["road_type"] is None and seen["design_speed_kph"] == 20 # 560-1 임도 종류 안 받음 with pytest.raises(HTTPException) as e: asyncio.run(d01.post_plan_project(None, _plan(project_year_end=2025), FOREST)) assert e.value.status_code == 400 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_531_3_plan_link.py b/resources/tester/test_531_3_plan_link.py index 20097ac81..746c07746 100644 --- a/resources/tester/test_531_3_plan_link.py +++ b/resources/tester/test_531_3_plan_link.py @@ -264,6 +264,20 @@ def test_new_mode_creates_road_project(env, monkeypatch): assert made["company_id"] == 5 and len(env["rows"]) == 5 +def test_new_mode_name_year_once(env, monkeypatch): + """560-1 — 「<시작 연도> <지역> 노선 계획」 이름 = 앞 연도를 그 연도로(두 번 안 붙음).""" + made = {} + + async def create_project(**kw): + made.update(kw) + return {"project_id": ROAD_ID} + + monkeypatch.setattr(link, "create_project", create_project) + _use(monkeypatch, _assignee(name="2025 홍천 노선 계획")) + _link(mode="new", project_id=None) + assert made["name"] == "2026 홍천 노선 계획" + + def _stage2_writes(log): return [ args diff --git a/resources/tester/test_532_3_e03_section.py b/resources/tester/test_532_3_e03_section.py index 7f1a08727..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 @@ -411,6 +444,9 @@ process.stdout.write(JSON.stringify({ stress: m.sliceStress([input.slice]), ticks: m.niceTicks(-3, 47, 5), 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)), + labelsActive: m.planLabelKeys(input.rows, 20, 5, "e"), })); """ @@ -541,6 +577,10 @@ 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] + # 평면 띠 측점 글 솎음 — 간격 px × 걸음 ≥ 64 · 본 측점 중 걸음 배수 · 고른 측점은 늘 + assert out["stride"] == [1, 2, 10, 100] + assert out["labels"] == [["a", "b", "c", "d", "e"], ["a", "c", "e"], ["a"]] + assert out["labelsActive"] == ["a", "e"] def test_엔진_지문은_코드_내용(): diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index 0e7793b5d..1b418129e 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -107,7 +107,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): monkeypatch.setattr(run, "run_engine", counted) first = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json() - assert first == {"status": "accepted", "key": "0.00_100.00"} + assert first == {"status": "accepted", "key": "0.00_100.00_dry"} done = _wait(client) assert done["error"] is None and done["percent"] == 100 assert done["summary"]["cells"] == 5 and done["summary"]["verdict"] == "ng" @@ -133,7 +133,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): _wait(client) assert len(calls) == 3 - doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).json() + doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"}).json() grid = doc["grid"] assert len(doc["f"]) == len(doc["z"]) == len(doc["v"]) == grid["nx"] * grid["ny"] f = np.array([np.nan if v is None else v for v in doc["f"]]) @@ -143,7 +143,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): assert doc["stale"] is False over = client.get(f"/api/projects/{PID}/stab/result3d").json() - assert [s["key"] for s in over["saved"]] == ["0.00_100.00"] and over["editable"] is True + assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry"] and over["editable"] is True assert len(over["line"]) == len(STATIONS) and over["sections"][0]["end_m"] == 100.0 # E02 지반을 바꾸면 저장본이 「옛 것」 (project / "E02_StabGround").mkdir() @@ -179,7 +179,9 @@ def test_cancel_and_restart(client, monkeypatch): body = _wait(client) assert body["cancelled"] is True and body["error"] is None assert ( - client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).status_code + client.get( + f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"} + ).status_code == 404 ) # 다시 시작 @@ -191,6 +193,39 @@ def test_cancel_and_restart(client, monkeypatch): assert body["cancelled"] is False and body["percent"] == 100 +def test_dry_then_wet_both_kept(client, project, monkeypatch): + """건기 → 우기 = 다른 열쇠 · 앞 결과가 덮이지 않음 · 옛 이름(조건 없음) 파일도 읽음.""" + monkeypatch.setattr(run, "run_engine", _fake_engine) + for condition in ("dry", "wet"): + body = client.post( + f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": condition} + ).json() + assert body == {"status": "accepted", "key": f"0.00_100.00_{condition}"} + _wait(client) + over = client.get(f"/api/projects/{PID}/stab/result3d").json() + assert [(s["key"], s["condition"]) for s in over["saved"]] == [ + ("0.00_100.00_dry", "dry"), + ("0.00_100.00_wet", "wet"), + ] + folder = run.results_dir(project) + # 옛 이름 — 우기 파일을 조건 없는 이름으로 옮기면 우기 열쇠로 읽힘 + (folder / "0.00_100.00_wet.json").rename(folder / "0.00_100.00.json") + over = client.get(f"/api/projects/{PID}/stab/result3d").json() + assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry", "0.00_100.00_wet"] + url = f"/api/projects/{PID}/stab/result3d" + assert client.get(url, params={"key": "0.00_100.00_wet"}).json()["stale"] is False + again = client.post( + f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": "wet"} + ).json() + assert again["status"] == "cached" + + +def test_engine_hash_in_fingerprint(monkeypatch): + before = run.fingerprint("a", None, {}) + monkeypatch.setattr(run, "engine_hash", lambda: "changed") + assert run.fingerprint("a", None, {}) != before + + def test_bad_requests(client): url = f"/api/projects/{PID}/stab/analyze3d" assert client.post(url, json={"start_m": 100, "end_m": 50}).status_code == 400 diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 41da1f402..f130b9a86 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -170,7 +170,7 @@ def project(tmp_path, monkeypatch) -> Path: path.write_text(json.dumps(ground), encoding="utf-8") r3 = root / "E04_Stab3D" / "results" r3.mkdir(parents=True) - (r3 / "0.00_200.00.json").write_text( + (r3 / "0.00_200.00_wet.json").write_text( json.dumps( { "fingerprint": "3d-1", 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/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 2a79b490f..0de454c09 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -16,13 +16,6 @@ def _no_hex(path: str) -> None: assert not re.search(r"#[0-9a-fA-F]{3,8}\b", css), f"{path}: 날 색" -def test_d01_station_label_is_text_part(): - ts = _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts") - assert "style.cssText" not in ts - assert 'createText("", "muted")' in ts - assert 'dataset.station = ""' in ts # 시험 · 촬영이 잡음 - - def test_d02_parts(): ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts") css = _src("D02_PlanBase/D02_PlanBase_UI_Style.css") diff --git a/resources/tester/test_561_1_sheet_encoding.py b/resources/tester/test_561_1_sheet_encoding.py new file mode 100644 index 000000000..3ced79cfd --- /dev/null +++ b/resources/tester/test_561_1_sheet_encoding.py @@ -0,0 +1,74 @@ +"""PLAN 561-1 — `.cpg` 없는 도엽 dbf = cp949 로 읽음 · 병합 열쇠에 파서 판 번호. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_561_1_sheet_encoding.py -q + +`.cpg` 없는 dbf 를 그냥 읽으면 latin-1 → 칸 이름 「등고수치」가 깨져 등고 높이가 빔 +(D 프로젝트 4250c7bc 「등고 라인 94단을 격자에 굽어 0셀」). +""" + +from __future__ import annotations + +import sys +import zipfile +from pathlib import Path + +import geopandas as gpd +from shapely.geometry import LineString + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B04_PreProcess.B04_PreProcess_Engine_SheetParser as parser # noqa: E402 + +BASE = "N3L_F0010000" + + +def _sheet_zip(tmp_path: Path, with_cpg: bool) -> Path: + """등고선 두 줄짜리 도엽 zip — dbf 는 cp949 · `.cpg` 는 고름.""" + shp_dir = tmp_path / ("cpg" if with_cpg else "nocpg") + shp_dir.mkdir() + gdf = gpd.GeoDataFrame( + {"등고수치": [100.0, 105.0], "구분": ["주곡선", "주곡선"]}, + geometry=[LineString([(0, 0), (10, 0)]), LineString([(0, 5), (10, 5)])], + crs="EPSG:5186", + ) + gdf.to_file(shp_dir / f"{BASE}.shp", encoding="cp949") + cpg = shp_dir / f"{BASE}.cpg" + if with_cpg: + cpg.write_text("CP949", encoding="ascii") + else: + cpg.unlink(missing_ok=True) + zip_path = tmp_path / f"{shp_dir.name}.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in shp_dir.iterdir(): + zf.write(f, f.name) + return zip_path + + +def _read(zip_path: Path) -> gpd.GeoDataFrame: + gdf = parser._read_layer_from_zip(zip_path, "N3L", "F0010000", "EPSG:5186") + assert gdf is not None + return gdf + + +def test_no_cpg_reads_cp949(tmp_path): + gdf = _read(_sheet_zip(tmp_path, with_cpg=False)) + assert "등고수치" in gdf.columns + assert sorted(gdf["등고수치"].tolist()) == [100.0, 105.0] + assert set(gdf["구분"]) == {"주곡선"} + + +def test_with_cpg_unchanged(tmp_path): + gdf = _read(_sheet_zip(tmp_path, with_cpg=True)) + assert sorted(gdf["등고수치"].tolist()) == [100.0, 105.0] + + +def test_merge_keys_carry_parser_version(): + assert parser.PARSER_VERSION >= 2 + for rel in ( + "B04_PreProcess/B04_PreProcess_Engine.py", + "D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py", + ): + text = (ROOT / rel).read_text(encoding="utf-8") + assert '"parser": PARSER_VERSION' in text, rel diff --git a/resources/tester/test_564_1_e02_chain.py b/resources/tester/test_564_1_e02_chain.py new file mode 100644 index 000000000..3f97b87e1 --- /dev/null +++ b/resources/tester/test_564_1_e02_chain.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +"""564-1 E02 [해석 시작] 체인 — 가짜 단계로 차례 · 비율 · 건너뜀 · 끊기 · 실패 격리 · +3D 단계 = 구간 × 건기 · 우기 · 지문 같은 조각 건너뜀 · 구간 수로 나눈 진행률 · +길 셋(지반 저장 → 시작 · 두 번째 409 · 끊기). +""" + +import asyncio +import copy +import sys +from pathlib import Path +from uuid import UUID + +import pytest +from fastapi import HTTPException + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from E01_StabRegister import E01_StabRegister_Source as source # noqa: E402 +from E02_StabGround import E02_StabGround_Chain as chain # noqa: E402 +from E02_StabGround import E02_StabGround_Model as model # noqa: E402 +from E02_StabGround import E02_StabGround_Router as router # noqa: E402 +from E04_Stab3D import E04_Stab3D_Router as e04 # noqa: E402 +from E04_Stab3D import E04_Stab3D_Run as run3d # noqa: E402 + +PID = UUID("9872e9a7-e483-427b-a6b2-72cdbc063bc3") +KEYS = [k for k, _, _ in chain.STEPS] +TOTAL = sum(w for _, _, w in chain.STEPS) + + +def _fake(log, states=None, fail=(), interrupt=(), stop_after=None, flag=None): + """가짜 단계 — 부른 차례 · 단계 안 반쯤 진행 · 정한 상태 · 실패 · 끊김.""" + states = states or {} + + def make(key): + async def step(report, stopped): + log.append(key) + report(0.5, f"{key} 반") + if key in fail: + raise RuntimeError(f"{key} 망가짐") + if key in interrupt: + raise InterruptedError + if key == stop_after: + flag[0] = True + return states.get(key, "done") + + return step + + return {k: make(k) for k in KEYS} + + +class _Job(dict): + """update 마다 진행률을 적어 둠(뒤로 안 가는지 봄).""" + + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self.seen = [] + + def update(self, *a, **kw): + super().update(*a, **kw) + self.seen.append(self["percent"]) + + +def _run(steps, stopped=lambda: False): + job = _Job({**chain.idle(), "running": True}) + asyncio.run(chain.run_chain(job, steps, stopped)) + return job, job.seen + + +def test_차례_비율_건너뜀(): + log = [] + job, seen = _run(_fake(log, states={"rock": "skip", "report": "skip"})) + assert log == KEYS + assert [s["state"] for s in job["steps"]] == ["done", "done", "skip", "done", "skip"] + assert all(s["seconds"] is not None for s in job["steps"]) + assert job["percent"] == 100 and job["message"] == "끝" and job["error"] is None + assert job["stage"] is None and not job["cancelled"] + assert seen == sorted(seen) # 진행률은 뒤로 안 감 + # 3D 반쯤 = 2D 몫 + 3D 몫 반(몫 = STEPS 비율) + half_3d = int(100 * (chain.STEPS[0][2] + chain.STEPS[1][2] / 2) / TOTAL) + assert half_3d in seen + + +def test_끊기_다음_단계_안_돎(): + log, flag = [], [False] + job, _ = _run(_fake(log, stop_after="3d", flag=flag), stopped=lambda: flag[0]) + assert log == ["2d", "3d"] + assert [s["state"] for s in job["steps"]] == ["done", "done", "cancel", "wait", "wait"] + assert job["cancelled"] and job["percent"] < 100 + + +def test_단계_안에서_끊김(): + log = [] + job, _ = _run(_fake(log, interrupt={"3d"})) + assert log == ["2d", "3d"] + assert [s["state"] for s in job["steps"]][:3] == ["done", "cancel", "wait"] + assert job["cancelled"] and job["message"] == "끊음" + + +def test_실패는_그_단계만(): + log = [] + job, _ = _run(_fake(log, fail={"2d", "area"})) + assert log == KEYS + assert [s["state"] for s in job["steps"]] == ["error", "done", "done", "error", "done"] + assert "2D 측점 해석: 2d 망가짐" in job["error"] and "광역 위험" in job["error"] + assert job["percent"] == 100 + + +# --------------------------------------------------------------------------- +# 3D 단계 — 구간 × 조건 · 지문 같으면 건너뜀 · 구간 수로 나눈 진행률 · E04 [끊기] +# --------------------------------------------------------------------------- + +STATIONS = [{"chainage_m": float(c)} for c in range(0, 320, 20)] # 0~300 m → 구간 셋 + + +@pytest.fixture +def fake_3d(tmp_path, monkeypatch): + calls = [] + fresh = {"0.00_100.00_dry"} # 이 조각만 저장본 지문이 지금과 같음 + monkeypatch.setattr(e04, "_source_root", lambda root: root) + monkeypatch.setattr(source, "stations", lambda src: STATIONS) + monkeypatch.setattr(e04, "_current_fingerprint", lambda *a: "fp") + # 564-2 열쇠 꼴(조건 들어감) — 그 전 판에서도 시험이 돌게 가짜로 + monkeypatch.setattr(run3d, "section_key", lambda s, e, c="dry": f"{s:.2f}_{e:.2f}_{c}") + monkeypatch.setattr( + run3d, "load", lambda root, key: {"fingerprint": "fp"} if key in fresh else None + ) + + def analyze(root, src, settings, finger, on_progress, should_stop): + calls.append((settings["start_m"], settings["end_m"], settings["condition"])) + assert (e04._JOBS.get(str(PID)) or {}).get("running") # E04 화면 「해석 중」 + on_progress(50, "탐색") + if should_stop(): + raise InterruptedError + return {"min_fs": 1.5} + + monkeypatch.setattr(run3d, "analyze", analyze) + e04._JOBS.pop(str(PID), None) + return tmp_path, calls + + +def test_3D_구간_조건_건너뜀_진행률(fake_3d): + root, calls = fake_3d + step = chain.build_steps(PID, root)["3d"] + seen = [] + state = asyncio.run(step(lambda f, m: seen.append((round(f, 4), m)), lambda: False)) + sections = run3d.auto_sections(STATIONS) + assert len(sections) == 3 + units = len(sections) * len(chain.CONDITIONS_3D) + assert state == "done" and len(calls) == units - 1 # 지문 같은 조각 하나 건너뜀 + assert calls[0] == (0.0, 100.0, "wet") and {c for *_, c in calls} == {"dry", "wet"} + assert seen[0] == (round(1 / units, 4), f"3D 1 / {units} 구간") # 건너뛴 조각 = 바로 한 칸 + assert (round(1.5 / units, 4), f"3D 2 / {units} 구간") in seen # 둘째 조각 반쯤 + assert [f for f, _ in seen] == sorted(f for f, _ in seen) + assert not e04._JOBS[str(PID)]["running"] + + +def test_3D_E04_끊기(fake_3d): + root, calls = fake_3d + step = chain.build_steps(PID, root)["3d"] + + def report(_f, _m): + e04._STOPS[str(PID)] = True # E04 화면 [끊기] + + with pytest.raises(InterruptedError): + asyncio.run(step(report, lambda: False)) + assert len(calls) == 1 and e04._JOBS[str(PID)]["cancelled"] + + +def test_3D_모두_최신이면_건너뜀(fake_3d, monkeypatch): + root, calls = fake_3d + monkeypatch.setattr(run3d, "load", lambda root, key: {"fingerprint": "fp"}) + step = chain.build_steps(PID, root)["3d"] + assert asyncio.run(step(lambda f, m: None, lambda: False)) == "skip" and not calls + + +# --------------------------------------------------------------------------- +# 길 — [해석 시작] = 지반 저장 → 시작 · 도는 중 두 번째 409 · 끊기 +# --------------------------------------------------------------------------- + + +def test_길_저장_시작_409_끊기(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY"} + + monkeypatch.setattr(router, "_project", fake_project) + gate = {} + + def fake_steps(_pid, _root): + async def slow(report, stopped): + await gate["open"].wait() + return "done" + + async def quick(report, stopped): + return "skip" + + return {k: (slow if k == "2d" else quick) for k in KEYS} + + monkeypatch.setattr(chain, "build_steps", fake_steps) + ground = model.build_initial(model.load_defaults(), []) + ground["materials"][0]["c"] = 7.0 + + async def scenario(): + gate["open"] = asyncio.Event() + first = await router.run_chain(PID, router.GroundRequest(ground=copy.deepcopy(ground))) + assert first["running"] and first["ground"]["materials"][0]["c"] == 7.0 + assert model.ground_path(tmp_path).is_file() # 지반 저장 먼저 + await asyncio.sleep(0) + with pytest.raises(HTTPException) as busy: + await router.run_chain(PID, router.GroundRequest(ground=copy.deepcopy(ground))) + assert busy.value.status_code == 409 + assert (await router.run_progress(PID))["stage"] == "2d" + assert (await router.run_cancel(PID))["stopping"] is True + gate["open"].set() + for _ in range(50): + await asyncio.sleep(0) + return await router.run_progress(PID) + + done = asyncio.run(scenario()) + assert not done["running"] and done["cancelled"] + assert [s["state"] for s in done["steps"]][:2] == ["done", "cancel"] + + +def test_틀린_지반은_시작_안_함(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY"} + + monkeypatch.setattr(router, "_project", fake_project) + chain._JOBS.pop(str(PID), None) + ground = model.build_initial(model.load_defaults(), []) + ground["materials"][0]["gamma_t"] = 9.9 + with pytest.raises(HTTPException) as bad: + asyncio.run(router.run_chain(PID, router.GroundRequest(ground=ground))) + assert bad.value.status_code == 422 and not chain._JOBS.get(str(PID)) diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index aa72a5466..6385499b0 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -93,6 +93,9 @@ const PATHS = { menu: "M4 6l16 0|M4 12l16 0|M4 18l16 0", undo: "M9 14l-4 -4l4 -4|M5 10h11a4 4 0 1 1 0 8h-1", redo: "M15 14l4 -4l-4 -4|M19 10h-11a4 4 0 1 0 0 8h1", + // 회전 둘 — B05 반 바퀴 화살표(한 칸씩 돎 · 온 바퀴 그림 아님)를 24 칸으로 옮김 + rotateCcw: "M19.5 12a7.5 7.5 0 0 0 -15 0|M4.5 12l-2.7 -3.6|M4.5 12l3.6 -2.1", + rotateCw: "M4.5 12a7.5 7.5 0 0 1 15 0|M19.5 12l2.7 -3.6|M19.5 12l-3.6 -2.1", alert: "M12 9v4|M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0z|M12 16h.01", lock: "M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6z|M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M8 11v-4a4 4 0 1 1 8 0v4", diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index 3aa65d7c6..de94262a0 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -158,6 +158,11 @@ export const ui_locales_common = { "Ctrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+Shift+Z = 다시", "Ctrl+Z = undo\nCtrl+Y = redo\nCtrl+Shift+Z = redo", ], + App_Settings_Keys_Plan: ["D03 노선분석", "D03 route analysis"], + App_Settings_Keys_Plan_Keys: [ + "Enter(그리기) = 끝\nEsc(그리기) = 취소\nBackspace(그리기) = 마지막 점 빼기\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+Shift+Z = 다시", + "Enter (drawing) = finish\nEsc (drawing) = cancel\nBackspace (drawing) = remove last point\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+Shift+Z = redo", + ], App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"], App_Settings_Keys_Section_Keys: [ "방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc = 고름 풀기\nEsc(상세 창 고르기 중) = 고르기 취소", 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_e.ts b/ui_template/ui_template_locale_e.ts index d262b9d94..b05652da1 100644 --- a/ui_template/ui_template_locale_e.ts +++ b/ui_template/ui_template_locale_e.ts @@ -114,7 +114,10 @@ export const ui_locales_e = { E02_Col_Points: ["점 (offset · 깊이)", "Points (offset · depth)"], /* --- 상태 · 알림 --- */ - E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E02_Unsaved: [ + "해석 전 — 바뀐 값은 [해석 시작] 때 저장됩니다.", + "Not analyzed — changes are saved on [Run analysis].", + ], E02_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E02_Saved: ["저장했습니다.", "Saved."], E02_ResetDone: ["초기값으로 되돌렸습니다.", "Restored to initial values."], @@ -127,6 +130,35 @@ export const ui_locales_e = { E02_UserLayer: ["사용자 지층 — 출처 없음", "User layer — no source"], E02_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + /* --- [해석 시작] 체인(PLAN 564-3) --- */ + E02_Run: ["해석 시작", "Run analysis"], + E02_Run_Hint: [ + "[해석 시작] = 지반 값 저장 → 2D · 3D(모든 구간 · 건기 · 우기) · 암반 · 광역 · 보고서를 차례로 셈. 입력이 같은 단계는 건너뜀 · 1.2 km 약 25~30 분 · 다른 페이지로 가도 계속 돎.", + "[Run analysis] = save ground values → 2D · 3D (all sections · dry · wet) · rock · area · report in turn. Unchanged steps are skipped · about 25–30 min per 1.2 km · keeps running on other pages.", + ], + E02_Run_Running: ["해석 중", "Running"], + E02_Run_Cancel: ["끊기", "Stop"], + E02_Run_Stopping: [ + "끊는 중 — 지금 조각이 끝나면 멈춥니다.", + "Stopping — halts after the current piece.", + ], + E02_Run_Done: ["해석을 마쳤습니다.", "Analysis finished."], + E02_Run_Cancelled: [ + "해석을 끊었습니다 — 끝난 단계는 저장됨.", + "Analysis stopped — finished steps are saved.", + ], + E02_Run_Step_2d: ["2D 측점 해석", "2D station analysis"], + E02_Run_Step_3d: ["3D 구간 해석", "3D section analysis"], + E02_Run_Step_rock: ["암반 해석", "Rock analysis"], + E02_Run_Step_area: ["광역 위험", "Area hazard"], + E02_Run_Step_report: ["보고서", "Report"], + E02_Run_State_wait: ["기다림", "Waiting"], + E02_Run_State_run: ["도는 중", "Running"], + E02_Run_State_done: ["끝남", "Done"], + E02_Run_State_skip: ["최신 · 건너뜀", "Up to date · skipped"], + E02_Run_State_error: ["실패", "Failed"], + E02_Run_State_cancel: ["끊음", "Stopped"], + /* --- 입력 검사(코드 = 파이썬 check_ground 와 같은 낱말) --- */ E02_Err_number: ["숫자를 넣으세요", "Enter a number"], E02_Err_range: ["범위 밖", "Out of range"], diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 2051255c8..48138007e 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -25,9 +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_RepSave: ["대표 단면 저장", "Save Representative"], - E03_RepSaved: ["대표 단면을 저장했습니다.", "Representative sections saved."], - E03_RepUnsaved: ["대표 단면 바뀜 — 저장 안 됨", "Representative changes not saved"], + E03_View3d_Terrain: ["설계 지형", "Design terrain"], E03_Loading: ["불러오는 중…", "Loading…"], E03_NotYet: [ "아직 해석하지 않았습니다 — [해석]을 누르세요.", diff --git a/ui_template/ui_template_locale_e04.ts b/ui_template/ui_template_locale_e04.ts index 49c898651..73d26b435 100644 --- a/ui_template/ui_template_locale_e04.ts +++ b/ui_template/ui_template_locale_e04.ts @@ -39,7 +39,6 @@ export const ui_locales_e04 = { E04_Condition_wet: ["우기 (지표까지 포화)", "Wet (saturated to surface)"], E04_Defaults: ["기본값", "Defaults"], E04_Analyze: ["해석", "Analyze"], - E04_Reanalyze: ["다시 해석", "Re-analyze"], E04_Cancel: ["끊기", "Stop"], E04_Running: ["해석 중", "Analyzing"], E04_Cancelled: ["해석을 끊었습니다.", "Analysis stopped."], @@ -48,6 +47,8 @@ export const ui_locales_e04 = { E04_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."], E04_Saved: ["결과 있음", "Saved"], E04_Stale: ["옛 결과", "Outdated"], + E04_Short_dry: ["건기", "Dry"], + E04_Short_wet: ["우기", "Wet"], /* --- 가운데 지도 · 3D --- */ E04_View_Plan: ["평면 지도", "Plan map"], 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 · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",