Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> = { 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(
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* =============================================================================
|
||||
* D02_PlanBase_UI_Page.ts
|
||||
* D02 1단계 기준 지정(요구서 345 3-2 · PLAN 529-3) — 메인 = 카카오맵 · 좌 = 연도 지점 표.
|
||||
* 지점 = 경계점 목록 P0..Pn(연도 i = P(i) → P(i+1)) · 직전 종점 = 다음 시점이 모양 그대로.
|
||||
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점.
|
||||
* 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
|
||||
* · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장).
|
||||
* 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · [>] = 그냥 D03.
|
||||
@@ -34,13 +33,11 @@ interface ServerPoint extends LonLat {
|
||||
y: number;
|
||||
}
|
||||
interface PointsView {
|
||||
year_start: number;
|
||||
year_end: number;
|
||||
epsg: number | null;
|
||||
years: Array<{ year: number; start: ServerPoint | null; end: ServerPoint | null }>;
|
||||
start: ServerPoint | null;
|
||||
end: ServerPoint | null;
|
||||
}
|
||||
|
||||
const YEAR_COLORS = ["#E60046", "#1F6FEB", "#1A9E55", "#D97706", "#8B5CF6", "#0891B2"];
|
||||
const KOREA_CENTER: [number, number] = [127.8, 36.3];
|
||||
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
|
||||
|
||||
@@ -58,10 +55,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** 서버 연도 행 → 경계점 목록(연도 수 + 1) */
|
||||
/** 서버 두 점 → [시점, 종점] */
|
||||
function boundaryPoints(view: PointsView): Array<LonLat | null> {
|
||||
const pick = (p: ServerPoint | null): LonLat | null => (p ? { lon: p.lon, lat: p.lat } : null);
|
||||
return [pick(view.years[0]?.start ?? null), ...view.years.map((y) => pick(y.end))];
|
||||
return [pick(view.start), pick(view.end)];
|
||||
}
|
||||
|
||||
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
@@ -88,13 +85,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
let active: number | null = null; // 지도 누름이 놓을 지점
|
||||
let editable = false; // 산림청만 고침
|
||||
|
||||
const years = (): number[] => (view ? view.years.map((y) => y.year) : []);
|
||||
const pointName = (i: number): string => {
|
||||
const ys = years();
|
||||
if (i === 0) return `${ys[0]} ${t("D02_Start")}`;
|
||||
if (i === ys.length) return `${ys[i - 1]} ${t("D02_End")}`;
|
||||
return `${ys[i - 1]} ${t("D02_End")} · ${ys[i]} ${t("D02_Start")}`;
|
||||
};
|
||||
const pointName = (i: number): string => t(i === 0 ? "D02_Start" : "D02_End");
|
||||
const firstEmpty = (): number | null => {
|
||||
const i = points.findIndex((p) => !p);
|
||||
return i < 0 ? null : i;
|
||||
@@ -126,19 +117,17 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
const pins: MapPin[] = points.flatMap((p, i) =>
|
||||
p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [],
|
||||
);
|
||||
const lines: Array<Array<[number, number]>> = [];
|
||||
const colors: string[] = [];
|
||||
years().forEach((_, i) => {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
if (!a || !b) return;
|
||||
lines.push([
|
||||
[a.lat, a.lon],
|
||||
[b.lat, b.lon],
|
||||
]);
|
||||
colors.push(YEAR_COLORS[i % YEAR_COLORS.length]);
|
||||
});
|
||||
map.set(pins, lines, colors, focus);
|
||||
const [a, b] = points;
|
||||
const lines: Array<Array<[number, number]>> =
|
||||
a && b
|
||||
? [
|
||||
[
|
||||
[a.lat, a.lon],
|
||||
[b.lat, b.lon],
|
||||
],
|
||||
]
|
||||
: [];
|
||||
map.set(pins, lines, undefined, focus);
|
||||
}
|
||||
|
||||
function drawTable(): void {
|
||||
@@ -186,17 +175,13 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
lat,
|
||||
],
|
||||
});
|
||||
const color = YEAR_COLORS[Math.min(i, years().length - 1) % YEAR_COLORS.length];
|
||||
row.style.setProperty("--d02-year-color", color);
|
||||
return row;
|
||||
});
|
||||
table.replaceChildren(...rows);
|
||||
const saved = view?.years.find((y) => y.start);
|
||||
const saved = view?.start;
|
||||
info.textContent = view?.epsg
|
||||
? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` +
|
||||
(saved?.start
|
||||
? ` · ${saved.year} X ${saved.start.x.toFixed(3)} Y ${saved.start.y.toFixed(3)}`
|
||||
: "")
|
||||
(saved ? ` · X ${saved.x.toFixed(3)} Y ${saved.y.toFixed(3)}` : "")
|
||||
: t("D02_Not_Saved");
|
||||
}
|
||||
|
||||
@@ -214,9 +199,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
if (!view) return false;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = {
|
||||
years: years().map((year, i) => ({ year, start: points[i], end: points[i + 1] })),
|
||||
};
|
||||
const body = { start: points[0], end: points[1] };
|
||||
adopt(
|
||||
await request<PointsView>(`/projects/${projectId}/plan/points`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<GroundResponse> {
|
||||
async function request<T = GroundResponse>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<void> {
|
||||
while (root.isConnected) {
|
||||
const p = await request<RunProgress>(`/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<void> {
|
||||
if (!view?.editable) return;
|
||||
/** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */
|
||||
async function runChain(): Promise<void> {
|
||||
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<GroundResponse & RunProgress>(`/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<void> {
|
||||
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<RunProgress>(`/projects/${projectId}/stab/run/progress`);
|
||||
paintRun(p);
|
||||
if (p.running) void poll();
|
||||
} catch (error) {
|
||||
main.replaceChildren(
|
||||
hook(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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") } })]
|
||||
: [],
|
||||
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return body;
|
||||
}
|
||||
|
||||
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
|
||||
const sleep = (ms: number): Promise<void> => 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<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
|
||||
const wallRecs = new Map<string, WallRecord>();
|
||||
const supportRecs = new Map<string, SupportRecord>();
|
||||
let repCache: Record<string, boolean | null> = JSON.parse(
|
||||
sessionStorage.getItem(repKey(pid)) ?? "{}",
|
||||
);
|
||||
let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390)
|
||||
const planView: PlanView = {};
|
||||
let repSaving: Promise<void> = 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<void> {
|
||||
try {
|
||||
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
|
||||
function toggleRep(key: string): Promise<void> {
|
||||
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<string, boolean | null> = {};
|
||||
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<void> {
|
||||
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 로 알림 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* ---- 안전율 색 ------------------------------------------------------------ */
|
||||
|
||||
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<void> {
|
||||
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<Result3d>(`/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<void> {
|
||||
async function analyze(): Promise<void> {
|
||||
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<Settings>);
|
||||
remember();
|
||||
}
|
||||
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
|
||||
if (p?.running) void poll();
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Page.ts
|
||||
* E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow).
|
||||
* 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석]
|
||||
* 좌 = 절리군 표 · 해석 설정 · [초기화] [해석]
|
||||
* 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도)
|
||||
* 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표
|
||||
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset ·
|
||||
* [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET.
|
||||
* 조작 = sessionStorage 캐시만(자동저장 없음) · [초기화] = POST …/reset ·
|
||||
* [해석] = 저장(고침이 있으면 PUT) → POST …/analyze(서버 단독) → 결과 GET · 저장 단추 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./E06_StabRock_UI_Style.css";
|
||||
@@ -88,11 +88,6 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
// ---- 좌 ----
|
||||
const cards = el("div", { className: "e06-cards" });
|
||||
const status = el("div", { className: "e06-status" });
|
||||
const saveButton = createButton({
|
||||
label: t("Common_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: () => void save(),
|
||||
});
|
||||
const resetButton = createButton({
|
||||
label: t("Common_Btn_Reset"),
|
||||
variant: "danger-ghost",
|
||||
@@ -103,9 +98,9 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
variant: "filled",
|
||||
onClick: () => void analyze(),
|
||||
});
|
||||
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true;
|
||||
resetButton.disabled = analyzeButton.disabled = true;
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(resetButton, saveButton, analyzeButton);
|
||||
actions.append(resetButton, analyzeButton);
|
||||
panel.append(note(t("E06_Note")), cards, status, actions);
|
||||
attachCollapsible(cards);
|
||||
|
||||
@@ -274,7 +269,7 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
if (!cached) sessionStorage.removeItem(cacheKey(projectId));
|
||||
view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints;
|
||||
dirty = Boolean(cached);
|
||||
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable;
|
||||
resetButton.disabled = analyzeButton.disabled = !view.editable;
|
||||
renderCards();
|
||||
};
|
||||
|
||||
@@ -292,20 +287,6 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
if (await putJoints()) {
|
||||
showToast(t("E06_Saved"), "success");
|
||||
await loadResult();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
if (!view?.editable) return;
|
||||
const ok = await showConfirmDialog(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률
|
||||
* 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례
|
||||
* 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점)
|
||||
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage.
|
||||
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage
|
||||
* · 없으면 지난 결과 조건 + 물성은 지금 E02 값.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./E07_StabArea_UI_Style.css";
|
||||
@@ -239,15 +240,10 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
const analyzeButton = createButton({
|
||||
label: t("E07_Analyze"),
|
||||
variant: "filled",
|
||||
onClick: () => void analyze(false),
|
||||
});
|
||||
const reanalyzeButton = createButton({
|
||||
label: t("E07_Reanalyze"),
|
||||
variant: "ghost",
|
||||
onClick: () => void analyze(true),
|
||||
onClick: () => void analyze(),
|
||||
});
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(defaultsButton, reanalyzeButton, analyzeButton);
|
||||
actions.append(defaultsButton, analyzeButton);
|
||||
const readOnly = note(t("E07_ReadOnly"), "e07-hint");
|
||||
readOnly.hidden = true;
|
||||
panel.append(
|
||||
@@ -295,24 +291,31 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
remember();
|
||||
});
|
||||
fields.get("band_m")!.input.addEventListener("input", paintScenario);
|
||||
material.select.addEventListener("change", () => {
|
||||
const m = overview?.materials.find((x) => x.id === material.select.value);
|
||||
if (!m) return;
|
||||
const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v));
|
||||
/** E02 물성 값(범위 위 = 기본값과 같은 폭) — 없는 id 면 빈 것 */
|
||||
function materialValues(id: unknown): Settings {
|
||||
const m = overview?.materials.find((x) => x.id === id);
|
||||
if (!m) return {};
|
||||
const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c);
|
||||
const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi);
|
||||
set("gamma_t", m.gamma_t);
|
||||
set("gamma_sat", m.gamma_sat);
|
||||
set("c", m.c);
|
||||
set("c_hi", m.c + rangeC);
|
||||
set("phi", m.phi);
|
||||
set("phi_hi", m.phi + rangePhi);
|
||||
return {
|
||||
gamma_t: m.gamma_t,
|
||||
gamma_sat: m.gamma_sat,
|
||||
c: m.c,
|
||||
c_hi: m.c + rangeC,
|
||||
phi: m.phi,
|
||||
phi_hi: m.phi + rangePhi,
|
||||
};
|
||||
}
|
||||
|
||||
material.select.addEventListener("change", () => {
|
||||
const values = materialValues(material.select.value);
|
||||
for (const [key, v] of Object.entries(values)) fields.get(key)!.input.value = String(v);
|
||||
remember();
|
||||
});
|
||||
|
||||
function paintButtons(): void {
|
||||
const editable = Boolean(overview?.editable);
|
||||
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
|
||||
analyzeButton.disabled = !editable || running || !overview;
|
||||
readOnly.hidden = !overview || editable;
|
||||
}
|
||||
|
||||
@@ -445,11 +448,11 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function analyze(force: boolean): Promise<void> {
|
||||
async function analyze(): Promise<void> {
|
||||
try {
|
||||
const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...read(), force }),
|
||||
body: JSON.stringify(read()),
|
||||
});
|
||||
if (body.status === "cached") {
|
||||
showToast(t("E07_Cached"), "info");
|
||||
@@ -478,8 +481,12 @@ export function renderE07StabArea(root: HTMLElement): void {
|
||||
const cached = JSON.parse(
|
||||
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
|
||||
) as Settings | null;
|
||||
// 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움
|
||||
write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) });
|
||||
// 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값
|
||||
const last = ov.result?.settings as Settings | undefined;
|
||||
write({
|
||||
...ov.defaults,
|
||||
...(cached ?? { ...last, ...materialValues(last?.material_id ?? ov.defaults.material_id) }),
|
||||
});
|
||||
map.setRoute(ov.line, ov.stations);
|
||||
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
|
||||
if (p?.running) void poll();
|
||||
|
||||
@@ -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)';
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_엔진_지문은_코드_내용():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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(상세 창 고르기 중) = 고르기 취소",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: [
|
||||
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",
|
||||
|
||||
Reference in New Issue
Block a user