From 8f7bb8e7e68b955b5121d20358fe52d09fae9799 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 19:57:52 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(564-1):=20E02=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=EC=97=90=20=ED=95=B4=EC=84=9D=20=EC=B2=B4=EC=9D=B8=20=E2=80=94?= =?UTF-8?q?=20=EC=A7=80=EB=B0=98=20=EC=A0=80=EC=9E=A5=20=E2=86=92=202D=20?= =?UTF-8?q?=E2=86=92=203D=20=EA=B1=B4=EA=B8=B0=20=C2=B7=20=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=E2=86=92=20=EC=95=94=EB=B0=98=20=E2=86=92=20?= =?UTF-8?q?=EA=B4=91=EC=97=AD=20=E2=86=92=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=C2=B7=20=EC=A7=84=ED=96=89=20=C2=B7=20=EB=81=8A=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 E02_StabGround_Chain.py — 페이지 해석 함수 그대로 부름 · 지문 같은 단계 건너뜀 · 단계 실패 격리 · 페이지 _JOBS 꽂음 · 3D 진행률 = 구간 × 조건 수로 나눔 - E02 Router 길 셋(POST /stab/run · GET /stab/run/progress · POST /stab/run/cancel) · PUT 과 같은 저장 함수 - 3D 열쇠 = section_key(start, end, condition) 꼴(564-2 뒤 실제 연결) · 단계 상태 적기 자리 = 565-1 뒤 - 새 시험 test_564_1_e02_chain.py(차례 · 비율 · 건너뜀 · 끊기 · 실패 격리 · 3D 조각 · 길) Co-Authored-By: Claude Opus 5.5 --- E02_StabGround/E02_StabGround_Chain.py | 288 +++++++++++++++++++++++ E02_StabGround/E02_StabGround_Router.py | 43 +++- resources/tester/test_564_1_e02_chain.py | 235 ++++++++++++++++++ 3 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 E02_StabGround/E02_StabGround_Chain.py create mode 100644 resources/tester/test_564_1_e02_chain.py diff --git a/E02_StabGround/E02_StabGround_Chain.py b/E02_StabGround/E02_StabGround_Chain.py new file mode 100644 index 000000000..9c75c7f8c --- /dev/null +++ b/E02_StabGround/E02_StabGround_Chain.py @@ -0,0 +1,288 @@ +"""E02 [해석 시작] 체인 (요구 390 1 · PLAN 564-1). + +차례 = 2D → 3D(모든 구간 · 건기 · 우기) → 암반 → 광역 → 보고서. 계산 자리 = ③ 서버 단독. +각 페이지 해석 함수를 그대로 부름(그 파일 안 고침) · 단계마다 입력 지문이 저장본과 같으면 건너뜀 +· 결과는 각 페이지 저장 자리에 영구 저장 · 한 단계가 실패해도 다음 단계로. +도는 동안 그 페이지 진행 표(`_JOBS`)에 같은 일감을 꽂음 → E03 · E04 · E07 화면이 「해석 중」 +· 그 페이지 [해석] 은 409. [끊기] = 다음 확인 때 멈춤(3D 는 엔진 안 · 그 밖은 단계 사이). + +진행 꼴 = {running, percent, stage, stage_name, steps:[{key, name, state, seconds}], + message, error, cancelled} · state = wait · run · done · skip · error · cancel +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any +from uuid import UUID + +logger = logging.getLogger(__name__) + +#: 단계 — (열쇠, 이름, 비율 몫) · 몫 = 1.2 km 실측 시간 비(2D 1~3 분 · 3D 건기 · 우기 약 25 분) +STEPS: tuple[tuple[str, str, float], ...] = ( + ("2d", "2D 측점 해석", 10.0), + ("3d", "3D 구간 해석", 84.0), + ("rock", "암반 해석", 2.0), + ("area", "광역 위험", 2.0), + ("report", "보고서", 2.0), +) +CONDITIONS_3D = ("dry", "wet") + +Report = Callable[[float, str], None] # (단계 안 비율 0~1, 글) +Stopped = Callable[[], bool] +Step = Callable[[Report, Stopped], Awaitable[str]] # → "done" | "skip" + +_JOBS: dict[str, dict[str, Any]] = {} +_STOPS: dict[str, bool] = {} +_TASKS: set[asyncio.Task] = set() + + +def idle() -> dict[str, Any]: + return { + "running": False, + "percent": 0, + "stage": None, + "stage_name": None, + "steps": [{"key": k, "name": n, "state": "wait", "seconds": None} for k, n, _ in STEPS], + "message": None, + "error": None, + "cancelled": False, + } + + +def _mark_stage(project_id: str, key: str, state: str) -> None: + """대시보드 단계 상태 — ponytail: 지금은 안 적음 · 565-1 함수가 들어오면 여기서 부름.""" + + +async def run_chain( + job: dict[str, Any], steps: dict[str, Step], stopped: Stopped, project_id: str = "" +) -> None: + """STEPS 차례로 돎 — job 을 제자리에서 고침(진행 GET 이 그대로 읽음).""" + total = sum(w for _, _, w in STEPS) + base = 0.0 + errors: list[str] = [] + for index, (key, name, weight) in enumerate(STEPS): + row = job["steps"][index] + if stopped(): + job.update(cancelled=True, message="끊음") + row["state"] = "cancel" + break + job.update(stage=key, stage_name=name, message=name) + row["state"] = "run" + _mark_stage(project_id, key, "run") + + def report(frac: float, message: str, base: float = base, weight: float = weight) -> None: + frac = min(1.0, max(0.0, float(frac))) + job.update(percent=min(99, int(100 * (base + weight * frac) / total)), message=message) + + started = time.perf_counter() + try: + row["state"] = await steps[key](report, stopped) + except InterruptedError: + row["state"] = "cancel" + job.update(cancelled=True, message="끊음") + break + except Exception as error: # noqa: BLE001 — 단계 실패 격리 · 까닭은 그대로 올림 + logger.exception("E02 체인 %s 실패: project_id=%s", key, project_id) + row["state"] = "error" + errors.append(f"{name}: {getattr(error, 'detail', None) or error}") + finally: + row["seconds"] = round(time.perf_counter() - started, 1) + _mark_stage(project_id, key, row["state"]) + base += weight + job["percent"] = min(99, int(100 * base / total)) + job["error"] = " · ".join(errors) or None + if not job["cancelled"]: + job.update(percent=100, message="끝") + job.update(stage=None, stage_name=None) + + +# --------------------------------------------------------------------------- +# 실제 단계 — 페이지 함수 부르기(늦은 import · E03 · E06 라우터가 E02 라우터를 import 함) +# --------------------------------------------------------------------------- + + +def _plug(jobs: dict[str, dict[str, Any]], pid: str, extra: dict[str, Any]) -> dict[str, Any]: + """그 페이지 진행 표에 체인 일감을 꽂음 — 페이지 화면 「해석 중」 · 페이지 [해석] 409.""" + job = {"running": True, "percent": 0, "message": "한 번에 해석", "error": None, **extra} + jobs[pid] = job + return job + + +def build_steps(project_id: UUID, project_root: Path) -> dict[str, Step]: + from E01_StabRegister import E01_StabRegister_Source as source + from E03_StabSection import E03_StabSection_Router as e03 + from E04_Stab3D import E04_Stab3D_Router as e04 + from E04_Stab3D import E04_Stab3D_Run as run3d + from E05_StabReport import E05_StabReport_Build as e05_build + from E05_StabReport import E05_StabReport_Router as e05 + from E05_StabReport import E05_StabReport_Store as e05_store + from E06_StabRock import E06_StabRock_Router as e06 + from E07_StabArea import E07_StabArea_Router as e07 + + pid = str(project_id) + root = project_root + + async def step_2d(report: Report, _stopped: Stopped) -> str: + plan = await asyncio.to_thread(e03.plan_run, root, e03._ground(root)) + if e03.read_derived(root, e03.KIND_SUMMARY, plan["fp"]): + return "skip" + page = _plug(e03._JOBS, pid, {"computed": 0}) + + def relay(percent: int, message: str) -> None: + page.update(percent=int(percent), message=message) + report(percent / 100, f"2D {message}") + + try: + page["computed"] = await asyncio.to_thread(e03.run_all, root, plan, relay) + page.update(percent=100, message="끝") + finally: + page["running"] = False + return "done" + + def search_settings() -> dict[str, Any]: + """3D 탐색 설정 = 가장 최근 저장본 설정 · 없으면 기본값(구간 · 조건 빼고).""" + files = sorted(run3d.results_dir(root).glob("*.json"), key=lambda p: p.stat().st_mtime_ns) + saved = run3d.load(root, files[-1].stem) if files else None + fields = e04.SearchSettings.model_fields + values = {k: v for k, v in ((saved or {}).get("settings") or {}).items() if k in fields} + return e04.SearchSettings(**values).model_dump() + + async def step_3d(report: Report, stopped: Stopped) -> str: + src = e04._source_root(root) + stations = await asyncio.to_thread(source.stations, src) + sections = run3d.auto_sections(stations) + search = await asyncio.to_thread(search_settings) + units = [(s, c) for s in sections for c in CONDITIONS_3D] + computed = 0 + for index, (section, condition) in enumerate(units): + if stopped(): + raise InterruptedError + settings = { + "start_m": round(section["start_m"], 2), + "end_m": round(section["end_m"], 2), + "condition": condition, + **search, + } + key = run3d.section_key(settings["start_m"], settings["end_m"], condition) + label = f"3D {index + 1} / {len(units)} 구간" + finger = await asyncio.to_thread(e04._current_fingerprint, root, src, settings) + saved = await asyncio.to_thread(run3d.load, root, key) + if saved and saved["fingerprint"] == finger: + report((index + 1) / len(units), label) + continue + page = _plug(e04._JOBS, pid, {**e04._IDLE, "running": True, "key": key}) + e04._STOPS[pid] = False + + def relay(percent: int, message: str, index: int = index, page=page) -> None: + page.update(percent=max(0, min(99, int(percent))), message=message) + report((index + percent / 100) / len(units), label) + + try: + page["summary"] = await asyncio.to_thread( + run3d.analyze, + root, + src, + settings, + finger, + relay, + lambda: stopped() or e04._STOPS.get(pid, False), + ) + page.update(percent=100, message="끝") + except InterruptedError: + page.update(cancelled=True, message="끊음") + raise + finally: + page["running"] = False + computed += 1 + return "done" if computed else "skip" + + async def step_rock(_report: Report, _stopped: Stopped) -> str: + out = await asyncio.to_thread(e06._run, root) + return "skip" if out["status"] == "fresh" else "done" + + async def step_area(report: Report, _stopped: Stopped) -> str: + src = e07._source_root(root) + saved = await asyncio.to_thread(e07.load, root) + base = (saved or {}).get("settings") or e07.defaults(root) + settings = e07.AreaRequest(**base).model_dump(exclude={"force"}) + finger = e07.fingerprint(source.manifest(src)["source_fingerprint"], settings) + if saved and saved["fingerprint"] == finger: + return "skip" + page = _plug(e07._JOBS, pid, {}) + + def relay(percent: int, message: str) -> None: + page.update(percent=max(0, min(99, int(percent))), message=message) + report(percent / 100, f"광역 {message}") + + try: + await asyncio.to_thread(e07.run, root, src, settings, finger, relay) + page.update(percent=100, message="끝") + finally: + page["running"] = False + return "done" + + async def step_report(_report: Report, _stopped: Stopped) -> str: + async with e05._lock(project_id): + book, meta = e05_store.load(root) + fresh = await asyncio.to_thread(e05_build.analysis_fingerprint, root) + if book is not None and meta and meta.get("fingerprint") == fresh: + return "skip" + await asyncio.to_thread(e05_store.refresh, root) + return "done" + + return { + "2d": step_2d, + "3d": step_3d, + "rock": step_rock, + "area": step_area, + "report": step_report, + } + + +def busy(project_id: UUID) -> bool: + """체인 · 페이지 해석 하나라도 도는지 — 한 프로젝트에 체인 하나만.""" + from E03_StabSection import E03_StabSection_Router as e03 + from E04_Stab3D import E04_Stab3D_Router as e04 + from E07_StabArea import E07_StabArea_Router as e07 + + pid = str(project_id) + return any( + (jobs.get(pid) or {}).get("running") for jobs in (_JOBS, e03._JOBS, e04._JOBS, e07._JOBS) + ) + + +def start(project_id: UUID, project_root: Path) -> dict[str, Any]: + """뒤에서 체인 시작 — 부르는 쪽이 busy 를 먼저 봄.""" + pid = str(project_id) + job = {**idle(), "running": True, "message": "시작"} + _JOBS[pid] = job + _STOPS[pid] = False + steps = build_steps(project_id, project_root) + + async def work() -> None: + try: + await run_chain(job, steps, lambda: _STOPS.get(pid, False), pid) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E02 chain {pid}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return job + + +def progress(project_id: UUID) -> dict[str, Any]: + return dict(_JOBS.get(str(project_id)) or idle()) + + +def cancel(project_id: UUID) -> bool: + pid = str(project_id) + running = bool((_JOBS.get(pid) or {}).get("running")) + if running: + _STOPS[pid] = True + return running diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py index aee593430..66fe01cc6 100644 --- a/E02_StabGround/E02_StabGround_Router.py +++ b/E02_StabGround/E02_StabGround_Router.py @@ -5,6 +5,8 @@ - `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json` · 측점(B06 암 경계)은 서버 값 - `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) +- `POST …/stab/run` — [해석 시작] 지반 저장 → 한 번에 해석 체인(`E02_StabGround_Chain` · PLAN 564-1) + · `GET …/stab/run/progress` · `POST …/stab/run/cancel` 초기값 = E 생성 직후 마스터 기본값 + 복사본 B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음. 공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. @@ -29,6 +31,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M from E01_StabRegister.E01_StabRegister_Source import db_snapshot, missing_stations, source_dir +from E02_StabGround import E02_StabGround_Chain as chain from E02_StabGround import E02_StabGround_Model as model router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"]) @@ -130,20 +133,25 @@ async def get_ground( } -@router.put("/{project_id}/stab/ground") -async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: - root, _ = await _project(project_id) +def _save(root: Path, payload: dict[str, Any]) -> dict[str, Any]: + """검사 뒤 작업본 쓰기 — 틀리면 422.""" defaults = model.load_defaults() - errors = model.check_ground(payload.ground, defaults["limits"]) + errors = model.check_ground(payload, defaults["limits"]) if errors: raise HTTPException(status_code=422, detail={"errors": errors}) - ground = model.clean(payload.ground) + ground = model.clean(payload) # 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음 base = _read_working(root) or ensure_initial(root) ground["stations"] = base.get("stations", []) ground["version"] = defaults["version"] atomic_write_json(model.ground_path(root), ground) - return {"ground": ground} + return ground + + +@router.put("/{project_id}/stab/ground") +async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: + root, _ = await _project(project_id) + return {"ground": _save(root, payload.ground)} @router.post("/{project_id}/stab/ground/reset") @@ -152,3 +160,26 @@ async def reset_ground(project_id: UUID) -> dict[str, Any]: initial = ensure_initial(root) atomic_write_json(model.ground_path(root), initial) return {"ground": model.upgrade(initial, model.load_defaults())} + + +@router.post("/{project_id}/stab/run") +async def run_chain(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: + """[해석 시작] — 지반 저장 → 체인(뒤에서) · 진행은 `GET …/stab/run/progress`.""" + root, _ = await _project(project_id) + if chain.busy(project_id): + raise HTTPException(status_code=409, detail="이미 해석 중입니다.") + ground = _save(root, payload.ground) + return {"ground": ground, **chain.start(project_id, root)} + + +@router.get("/{project_id}/stab/run/progress") +async def run_progress(project_id: UUID) -> dict[str, Any]: + await _project(project_id) + return chain.progress(project_id) + + +@router.post("/{project_id}/stab/run/cancel") +async def run_cancel(project_id: UUID) -> dict[str, Any]: + """[끊기] — 다음 확인 때 멈춤 · 끝난 단계 저장본은 그대로.""" + await _project(project_id) + return {"stopping": chain.cancel(project_id)} diff --git a/resources/tester/test_564_1_e02_chain.py b/resources/tester/test_564_1_e02_chain.py new file mode 100644 index 000000000..3f97b87e1 --- /dev/null +++ b/resources/tester/test_564_1_e02_chain.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +"""564-1 E02 [해석 시작] 체인 — 가짜 단계로 차례 · 비율 · 건너뜀 · 끊기 · 실패 격리 · +3D 단계 = 구간 × 건기 · 우기 · 지문 같은 조각 건너뜀 · 구간 수로 나눈 진행률 · +길 셋(지반 저장 → 시작 · 두 번째 409 · 끊기). +""" + +import asyncio +import copy +import sys +from pathlib import Path +from uuid import UUID + +import pytest +from fastapi import HTTPException + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from E01_StabRegister import E01_StabRegister_Source as source # noqa: E402 +from E02_StabGround import E02_StabGround_Chain as chain # noqa: E402 +from E02_StabGround import E02_StabGround_Model as model # noqa: E402 +from E02_StabGround import E02_StabGround_Router as router # noqa: E402 +from E04_Stab3D import E04_Stab3D_Router as e04 # noqa: E402 +from E04_Stab3D import E04_Stab3D_Run as run3d # noqa: E402 + +PID = UUID("9872e9a7-e483-427b-a6b2-72cdbc063bc3") +KEYS = [k for k, _, _ in chain.STEPS] +TOTAL = sum(w for _, _, w in chain.STEPS) + + +def _fake(log, states=None, fail=(), interrupt=(), stop_after=None, flag=None): + """가짜 단계 — 부른 차례 · 단계 안 반쯤 진행 · 정한 상태 · 실패 · 끊김.""" + states = states or {} + + def make(key): + async def step(report, stopped): + log.append(key) + report(0.5, f"{key} 반") + if key in fail: + raise RuntimeError(f"{key} 망가짐") + if key in interrupt: + raise InterruptedError + if key == stop_after: + flag[0] = True + return states.get(key, "done") + + return step + + return {k: make(k) for k in KEYS} + + +class _Job(dict): + """update 마다 진행률을 적어 둠(뒤로 안 가는지 봄).""" + + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self.seen = [] + + def update(self, *a, **kw): + super().update(*a, **kw) + self.seen.append(self["percent"]) + + +def _run(steps, stopped=lambda: False): + job = _Job({**chain.idle(), "running": True}) + asyncio.run(chain.run_chain(job, steps, stopped)) + return job, job.seen + + +def test_차례_비율_건너뜀(): + log = [] + job, seen = _run(_fake(log, states={"rock": "skip", "report": "skip"})) + assert log == KEYS + assert [s["state"] for s in job["steps"]] == ["done", "done", "skip", "done", "skip"] + assert all(s["seconds"] is not None for s in job["steps"]) + assert job["percent"] == 100 and job["message"] == "끝" and job["error"] is None + assert job["stage"] is None and not job["cancelled"] + assert seen == sorted(seen) # 진행률은 뒤로 안 감 + # 3D 반쯤 = 2D 몫 + 3D 몫 반(몫 = STEPS 비율) + half_3d = int(100 * (chain.STEPS[0][2] + chain.STEPS[1][2] / 2) / TOTAL) + assert half_3d in seen + + +def test_끊기_다음_단계_안_돎(): + log, flag = [], [False] + job, _ = _run(_fake(log, stop_after="3d", flag=flag), stopped=lambda: flag[0]) + assert log == ["2d", "3d"] + assert [s["state"] for s in job["steps"]] == ["done", "done", "cancel", "wait", "wait"] + assert job["cancelled"] and job["percent"] < 100 + + +def test_단계_안에서_끊김(): + log = [] + job, _ = _run(_fake(log, interrupt={"3d"})) + assert log == ["2d", "3d"] + assert [s["state"] for s in job["steps"]][:3] == ["done", "cancel", "wait"] + assert job["cancelled"] and job["message"] == "끊음" + + +def test_실패는_그_단계만(): + log = [] + job, _ = _run(_fake(log, fail={"2d", "area"})) + assert log == KEYS + assert [s["state"] for s in job["steps"]] == ["error", "done", "done", "error", "done"] + assert "2D 측점 해석: 2d 망가짐" in job["error"] and "광역 위험" in job["error"] + assert job["percent"] == 100 + + +# --------------------------------------------------------------------------- +# 3D 단계 — 구간 × 조건 · 지문 같으면 건너뜀 · 구간 수로 나눈 진행률 · E04 [끊기] +# --------------------------------------------------------------------------- + +STATIONS = [{"chainage_m": float(c)} for c in range(0, 320, 20)] # 0~300 m → 구간 셋 + + +@pytest.fixture +def fake_3d(tmp_path, monkeypatch): + calls = [] + fresh = {"0.00_100.00_dry"} # 이 조각만 저장본 지문이 지금과 같음 + monkeypatch.setattr(e04, "_source_root", lambda root: root) + monkeypatch.setattr(source, "stations", lambda src: STATIONS) + monkeypatch.setattr(e04, "_current_fingerprint", lambda *a: "fp") + # 564-2 열쇠 꼴(조건 들어감) — 그 전 판에서도 시험이 돌게 가짜로 + monkeypatch.setattr(run3d, "section_key", lambda s, e, c="dry": f"{s:.2f}_{e:.2f}_{c}") + monkeypatch.setattr( + run3d, "load", lambda root, key: {"fingerprint": "fp"} if key in fresh else None + ) + + def analyze(root, src, settings, finger, on_progress, should_stop): + calls.append((settings["start_m"], settings["end_m"], settings["condition"])) + assert (e04._JOBS.get(str(PID)) or {}).get("running") # E04 화면 「해석 중」 + on_progress(50, "탐색") + if should_stop(): + raise InterruptedError + return {"min_fs": 1.5} + + monkeypatch.setattr(run3d, "analyze", analyze) + e04._JOBS.pop(str(PID), None) + return tmp_path, calls + + +def test_3D_구간_조건_건너뜀_진행률(fake_3d): + root, calls = fake_3d + step = chain.build_steps(PID, root)["3d"] + seen = [] + state = asyncio.run(step(lambda f, m: seen.append((round(f, 4), m)), lambda: False)) + sections = run3d.auto_sections(STATIONS) + assert len(sections) == 3 + units = len(sections) * len(chain.CONDITIONS_3D) + assert state == "done" and len(calls) == units - 1 # 지문 같은 조각 하나 건너뜀 + assert calls[0] == (0.0, 100.0, "wet") and {c for *_, c in calls} == {"dry", "wet"} + assert seen[0] == (round(1 / units, 4), f"3D 1 / {units} 구간") # 건너뛴 조각 = 바로 한 칸 + assert (round(1.5 / units, 4), f"3D 2 / {units} 구간") in seen # 둘째 조각 반쯤 + assert [f for f, _ in seen] == sorted(f for f, _ in seen) + assert not e04._JOBS[str(PID)]["running"] + + +def test_3D_E04_끊기(fake_3d): + root, calls = fake_3d + step = chain.build_steps(PID, root)["3d"] + + def report(_f, _m): + e04._STOPS[str(PID)] = True # E04 화면 [끊기] + + with pytest.raises(InterruptedError): + asyncio.run(step(report, lambda: False)) + assert len(calls) == 1 and e04._JOBS[str(PID)]["cancelled"] + + +def test_3D_모두_최신이면_건너뜀(fake_3d, monkeypatch): + root, calls = fake_3d + monkeypatch.setattr(run3d, "load", lambda root, key: {"fingerprint": "fp"}) + step = chain.build_steps(PID, root)["3d"] + assert asyncio.run(step(lambda f, m: None, lambda: False)) == "skip" and not calls + + +# --------------------------------------------------------------------------- +# 길 — [해석 시작] = 지반 저장 → 시작 · 도는 중 두 번째 409 · 끊기 +# --------------------------------------------------------------------------- + + +def test_길_저장_시작_409_끊기(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY"} + + monkeypatch.setattr(router, "_project", fake_project) + gate = {} + + def fake_steps(_pid, _root): + async def slow(report, stopped): + await gate["open"].wait() + return "done" + + async def quick(report, stopped): + return "skip" + + return {k: (slow if k == "2d" else quick) for k in KEYS} + + monkeypatch.setattr(chain, "build_steps", fake_steps) + ground = model.build_initial(model.load_defaults(), []) + ground["materials"][0]["c"] = 7.0 + + async def scenario(): + gate["open"] = asyncio.Event() + first = await router.run_chain(PID, router.GroundRequest(ground=copy.deepcopy(ground))) + assert first["running"] and first["ground"]["materials"][0]["c"] == 7.0 + assert model.ground_path(tmp_path).is_file() # 지반 저장 먼저 + await asyncio.sleep(0) + with pytest.raises(HTTPException) as busy: + await router.run_chain(PID, router.GroundRequest(ground=copy.deepcopy(ground))) + assert busy.value.status_code == 409 + assert (await router.run_progress(PID))["stage"] == "2d" + assert (await router.run_cancel(PID))["stopping"] is True + gate["open"].set() + for _ in range(50): + await asyncio.sleep(0) + return await router.run_progress(PID) + + done = asyncio.run(scenario()) + assert not done["running"] and done["cancelled"] + assert [s["state"] for s in done["steps"]][:2] == ["done", "cancel"] + + +def test_틀린_지반은_시작_안_함(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY"} + + monkeypatch.setattr(router, "_project", fake_project) + chain._JOBS.pop(str(PID), None) + ground = model.build_initial(model.load_defaults(), []) + ground["materials"][0]["gamma_t"] = 9.9 + with pytest.raises(HTTPException) as bad: + asyncio.run(router.run_chain(PID, router.GroundRequest(ground=ground))) + assert bad.value.status_code == 422 and not chain._JOBS.get(str(PID)) From f747e41e289141a28f45ea70cd2ac069fad03cd6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 19:57:56 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(E04):=203D=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=97=B4=EC=87=A0=EC=97=90=20=EC=A1=B0=EA=B1=B4=20=C2=B7=20?= =?UTF-8?q?=EC=98=9B=20=EC=9D=B4=EB=A6=84=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=ED=8C=90=20=3D=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=95=B4=EC=8B=9C=20(PLAN=20564-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - section_key(start, end, condition) → 0.00_100.00_dry 꼴 · 건기 → 우기가 서로 덮지 않음 - 옛 시작_끝.json 은 그 안 조건 열쇠로 읽음(load · saved_summaries) - ENGINE_VERSION 대신 engine_hash()(엔진 파일 넷 해시) — 판 올리기 잊음 없음 - 시험 533_3 router(건기 → 우기 둘 다 남음 · 옛 이름 · 엔진 해시) · 534_3 견본 파일 새 이름 Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Router.py | 7 +- E04_Stab3D/E04_Stab3D_Run.py | 82 +++++++++++++++++------ resources/tester/test_533_3_e04_router.py | 43 ++++++++++-- resources/tester/test_534_3_e05_report.py | 2 +- 4 files changed, 105 insertions(+), 29 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index 2416a95e8..374ba4eab 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -5,7 +5,7 @@ - `POST …/stab/analyze3d/cancel` — 도는 해석 끊기(다시 시작 = analyze3d 다시) - `GET …/stab/progress3d` — 진행률 - `GET …/stab/result3d` — 자동 구간 · 노선 · 측점 · 저장본 요약(+ 지문이 지금과 다른지) -- `GET …/stab/result3d?key=` — 한 구간 저장본(래스터 · 임계 활동면) +- `GET …/stab/result3d?key=` — 한 구간 · 조건 저장본(래스터 · 임계 활동면) · 열쇠 = `시작_끝_조건` 계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`(지정받은 회사 = GET 만 · 그 밖 403). """ @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio import logging +import re from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -126,7 +127,7 @@ async def analyze3d(project_id: UUID, payload: Analyze3dRequest) -> dict[str, An project_root = await _project_root(project_id) root = _source_root(project_root) settings = _settings(payload) - key = run.section_key(settings["start_m"], settings["end_m"]) + key = run.section_key(settings["start_m"], settings["end_m"], settings["condition"]) finger = await asyncio.to_thread(_current_fingerprint, project_root, root, settings) saved = await asyncio.to_thread(run.load, project_root, key) if not payload.force and saved and saved["fingerprint"] == finger: @@ -229,7 +230,7 @@ async def result3d( overview = await asyncio.to_thread(_overview, project_root, root) editable = await _editable(project_id, session) return {"status": "success", "editable": editable, **overview} - if not all(ch.isdigit() or ch in "._" for ch in key): + if not re.fullmatch(r"\d+\.\d{2}_\d+\.\d{2}(_(dry|wet))?", key): raise HTTPException(status_code=400, detail="구간 열쇠가 틀렸습니다.") doc = await asyncio.to_thread(run.load, project_root, key) if doc is None: diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index fcd600263..2b40fcaaf 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -1,8 +1,9 @@ """E04 3D 해석 한 번 — 입력 모으기 · 입력 지문 · 엔진 부르기 · 파생 저장본 (PLAN 533-3). 계산 자리 = ③ 서버 단독. 엔진(탐색 · 기둥 솔버 · 합성 지형) 주인 = 533-1 · 2 — 여기는 부르기만. -저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 입력 지문(복사본 지문 · E02 지반 · -요청 · 엔진 판)이 같으면 다시 셈하지 않음. +저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 열쇠 = `시작_끝_조건` +(예 `0.00_100.00_wet` · 옛 `시작_끝` 파일은 그 안 조건으로 읽음) · 입력 지문(복사본 지문 · +E02 지반 · 요청 · 엔진 판 = 엔진 파일 해시)이 같으면 다시 셈하지 않음. 래스터 꼴(화면 `logic/E04_Stab3D_Raster.ts` 와 같은 열쇠): grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열 @@ -22,6 +23,7 @@ import math import time from collections.abc import Callable from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Any @@ -34,7 +36,13 @@ from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine -ENGINE_VERSION = "e04-4" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_FILES = ( + "E04_Stab3D_Engine_Column.py", + "E04_Stab3D_Engine_Grid.py", + "E04_Stab3D_Engine_Search.py", + "E04_Stab3D_Run.py", +) +CONDITIONS = ("dry", "wet") STAGE_DIR = "E04_Stab3D" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리) @@ -48,8 +56,23 @@ def results_dir(project_root: Path) -> Path: return Path(project_root) / STAGE_DIR / "results" -def section_key(start_m: float, end_m: float) -> str: - return f"{start_m:.2f}_{end_m:.2f}" +@lru_cache(maxsize=1) +def engine_hash() -> str: + """엔진 판 = 엔진 코드 지문 — 코드가 바뀌면 지문이 달라져 다시 셈.""" + digest = hashlib.sha1() + for name in ENGINE_FILES: + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def section_key(start_m: float, end_m: float, condition: str = "dry") -> str: + return f"{start_m:.2f}_{end_m:.2f}_{condition}" + + +def _doc_key(doc: dict[str, Any]) -> str: + summary = doc["summary"] + condition = summary.get("condition") or doc["settings"].get("condition") or "dry" + return section_key(float(summary["start_m"]), float(summary["end_m"]), condition) def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]: @@ -85,7 +108,7 @@ def fingerprint( "source": source_fingerprint, "ground": ground, "settings": settings, - "engine": ENGINE_VERSION, + "engine": engine_hash(), }, ensure_ascii=False, sort_keys=True, @@ -308,7 +331,7 @@ def pack( ) return { "fingerprint": finger, - "engine": ENGINE_VERSION, + "engine": engine_hash(), "settings": settings, "computed_at": datetime.now().isoformat(timespec="seconds"), "elapsed_s": round(elapsed_s, 2), @@ -346,24 +369,37 @@ def save(project_root: Path, key: str, result: dict[str, Any]) -> None: def load(project_root: Path, key: str) -> dict[str, Any] | None: - path = results_dir(project_root) / f"{key}.json" - return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + """열쇠 파일 → 없으면 옛 이름(`시작_끝`) 파일이 같은 조건일 때.""" + folder = results_dir(project_root) + path = folder / f"{key}.json" + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + base, _, condition = key.rpartition("_") + legacy = folder / f"{base}.json" + if condition in CONDITIONS and legacy.is_file(): + doc = json.loads(legacy.read_text(encoding="utf-8")) + if _doc_key(doc) == key: + return doc + return None def saved_summaries(project_root: Path) -> list[dict[str, Any]]: - """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용.""" - out = [] + """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용. + + 열쇠 = 구간 + 조건 · 같은 열쇠면 새 이름 파일이 옛 이름 파일을 이김.""" + found: dict[str, dict[str, Any]] = {} for path in sorted(results_dir(project_root).glob("*.json")): doc = json.loads(path.read_text(encoding="utf-8")) - out.append( - { - "key": path.stem, - "fingerprint": doc["fingerprint"], - "settings": doc["settings"], - **doc["summary"], - } - ) - return out + key = _doc_key(doc) + if key in found and path.stem != key: + continue + found[key] = { + "key": key, + "fingerprint": doc["fingerprint"], + "settings": doc["settings"], + **doc["summary"], + } + return [found[k] for k in sorted(found)] def analyze( @@ -385,5 +421,9 @@ def analyze( ground = read_ground(project_root) raw = run_engine(root, around, ground, settings, on_progress, should_stop) result = pack(raw, picked, settings, finger, time.perf_counter() - started) - save(project_root, section_key(settings["start_m"], settings["end_m"]), result) + save( + project_root, + section_key(settings["start_m"], settings["end_m"], settings["condition"]), + result, + ) return result["summary"] diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index 0e7793b5d..1b418129e 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -107,7 +107,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): monkeypatch.setattr(run, "run_engine", counted) first = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json() - assert first == {"status": "accepted", "key": "0.00_100.00"} + assert first == {"status": "accepted", "key": "0.00_100.00_dry"} done = _wait(client) assert done["error"] is None and done["percent"] == 100 assert done["summary"]["cells"] == 5 and done["summary"]["verdict"] == "ng" @@ -133,7 +133,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): _wait(client) assert len(calls) == 3 - doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).json() + doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"}).json() grid = doc["grid"] assert len(doc["f"]) == len(doc["z"]) == len(doc["v"]) == grid["nx"] * grid["ny"] f = np.array([np.nan if v is None else v for v in doc["f"]]) @@ -143,7 +143,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): assert doc["stale"] is False over = client.get(f"/api/projects/{PID}/stab/result3d").json() - assert [s["key"] for s in over["saved"]] == ["0.00_100.00"] and over["editable"] is True + assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry"] and over["editable"] is True assert len(over["line"]) == len(STATIONS) and over["sections"][0]["end_m"] == 100.0 # E02 지반을 바꾸면 저장본이 「옛 것」 (project / "E02_StabGround").mkdir() @@ -179,7 +179,9 @@ def test_cancel_and_restart(client, monkeypatch): body = _wait(client) assert body["cancelled"] is True and body["error"] is None assert ( - client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).status_code + client.get( + f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"} + ).status_code == 404 ) # 다시 시작 @@ -191,6 +193,39 @@ def test_cancel_and_restart(client, monkeypatch): assert body["cancelled"] is False and body["percent"] == 100 +def test_dry_then_wet_both_kept(client, project, monkeypatch): + """건기 → 우기 = 다른 열쇠 · 앞 결과가 덮이지 않음 · 옛 이름(조건 없음) 파일도 읽음.""" + monkeypatch.setattr(run, "run_engine", _fake_engine) + for condition in ("dry", "wet"): + body = client.post( + f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": condition} + ).json() + assert body == {"status": "accepted", "key": f"0.00_100.00_{condition}"} + _wait(client) + over = client.get(f"/api/projects/{PID}/stab/result3d").json() + assert [(s["key"], s["condition"]) for s in over["saved"]] == [ + ("0.00_100.00_dry", "dry"), + ("0.00_100.00_wet", "wet"), + ] + folder = run.results_dir(project) + # 옛 이름 — 우기 파일을 조건 없는 이름으로 옮기면 우기 열쇠로 읽힘 + (folder / "0.00_100.00_wet.json").rename(folder / "0.00_100.00.json") + over = client.get(f"/api/projects/{PID}/stab/result3d").json() + assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry", "0.00_100.00_wet"] + url = f"/api/projects/{PID}/stab/result3d" + assert client.get(url, params={"key": "0.00_100.00_wet"}).json()["stale"] is False + again = client.post( + f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": "wet"} + ).json() + assert again["status"] == "cached" + + +def test_engine_hash_in_fingerprint(monkeypatch): + before = run.fingerprint("a", None, {}) + monkeypatch.setattr(run, "engine_hash", lambda: "changed") + assert run.fingerprint("a", None, {}) != before + + def test_bad_requests(client): url = f"/api/projects/{PID}/stab/analyze3d" assert client.post(url, json={"start_m": 100, "end_m": 50}).status_code == 400 diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 41da1f402..f130b9a86 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -170,7 +170,7 @@ def project(tmp_path, monkeypatch) -> Path: path.write_text(json.dumps(ground), encoding="utf-8") r3 = root / "E04_Stab3D" / "results" r3.mkdir(parents=True) - (r3 / "0.00_200.00.json").write_text( + (r3 / "0.00_200.00_wet.json").write_text( json.dumps( { "fingerprint": "3d-1", From 7d5f516b84fd2c66191df43fb64de83c44c3eabf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 19:58:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(E03):=20=EC=B8=A1=EC=A0=90=20No.+?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=C2=B7=20=EC=9C=84=ED=97=98=20=EB=9D=A0=20?= =?UTF-8?q?=ED=99=95=EB=8C=80=20=C2=B7=20=EC=9D=B4=EB=8F=99=20=C2=B7=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EA=B8=80=20=C2=B7=20900=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EA=B2=B9=EC=B9=A8=20=C2=B7=20=EB=8C=80=ED=91=9C=20?= =?UTF-8?q?=EB=8B=A8=EB=A9=B4=20=EB=B0=94=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?(PLAN=20566-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 목록 · 횡단 카드 제목 = 공용 stationLabel(누가, 측점 목록 추정 간격, 소수 한 자리) - 평면 위험 띠 = 공용 attachSvgZoomPan(휠 · 가운데 끌기 · 두 번 누름 원배율) · 측점 글 솎음(logic labelStride · planLabelKeys) · 선 굵기 · 글 크기 고정 · 배율은 다시 그려도 유지 - .e03-page 행 셋(auto auto minmax) — 화면 높이 900 에서 띠 · 본문 겹침 고침 - 대표 단면 별 = 누르면 바로 PUT representative · 뒤에서 E05 보고서 갱신 길(GET report · POST refresh) · [대표 단면 저장] 단추 · 캐시 뺌 - 시험 532_3 logic TS 에 솎음 걸음 · 글 열쇠 Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_UI_List.ts | 7 +- E03_StabSection/E03_StabSection_UI_Page.ts | 110 +++++++------ E03_StabSection/E03_StabSection_UI_Plan.ts | 148 +++++++++++++----- E03_StabSection/E03_StabSection_UI_Style.css | 9 +- .../logic/E03_StabSection_Logic.ts | 25 +++ resources/tester/test_532_3_e03_section.py | 7 + ui_template/ui_template_locale_e03.ts | 3 - 7 files changed, 224 insertions(+), 85 deletions(-) diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index 9cfcf82ed..db51f4c4d 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -9,6 +9,7 @@ import { createIconButton } from "@ui/ui_template_button"; import { el } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { stationLabel } from "../common_util/common_util_station"; import { e03Table } from "./E03_StabSection_UI_Table"; import { listRows, @@ -29,7 +30,9 @@ export interface ListDraw { sort: SortMode; ngOnly: boolean; active: string | null; - /** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */ + /** 측점 간격(m) — 측점 글 「No.+거리」 의 분모 */ + interval: number; + /** 사용자 켬/끔 — 서버 값(별 누름 = 바로 저장) */ representative: (row: StationRow) => { on: boolean; user: boolean | null }; editable: boolean; } @@ -81,7 +84,7 @@ export function buildList( ); verdict.classList.add("e03-verdict", `e03-verdict--${v}`); const station = el("span", { - text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key, + text: stationLabel(row.chainage_m, draw.interval, 1), children: row.walls ? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })] : [], diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index b7baa1711..4d5621753 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -24,7 +24,8 @@ import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; -import { buildPlan } from "./E03_StabSection_UI_Plan"; +import { buildPlan, type PlanView } from "./E03_StabSection_UI_Plan"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts"; import { buildResult } from "./E03_StabSection_UI_Result"; import { @@ -84,7 +85,6 @@ async function api(path: string, init?: RequestInit): Promise { return body; } -const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); export function renderE03StabSection(root: HTMLElement): void { @@ -111,9 +111,9 @@ export function renderE03StabSection(root: HTMLElement): void { const slopeRecs = new Map(); // 측점 열쇠 → 마지막 가정 const wallRecs = new Map(); const supportRecs = new Map(); - let repCache: Record = JSON.parse( - sessionStorage.getItem(repKey(pid)) ?? "{}", - ); + let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390) + const planView: PlanView = {}; + let repSaving: Promise = Promise.resolve(); // 별 누름마다 차례로 저장 // ---- 좌측 ---- const condField = createSelectField({ @@ -158,15 +158,10 @@ export function renderE03StabSection(root: HTMLElement): void { variant: "ghost", onClick: () => void show3d(), }); - const repButton = createButton({ - label: t("E03_RepSave"), - variant: "ghost", - onClick: () => void saveRep(), - }); const controls = el("div", { className: "e03-panel__row" }); controls.append(condField.root, sortField.root); const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(repButton, view3dButton, analyzeButton); + actions.append(view3dButton, analyzeButton); panel.append(controls, ngBox, status, listHost, actions); // ---- 본문 ---- @@ -187,11 +182,8 @@ export function renderE03StabSection(root: HTMLElement): void { const repOf = (row: StationRow): { on: boolean; user: boolean | null } => { const server = row.representative ?? { auto: [], user: null, on: false }; - if (!(row.key in repCache)) return { on: server.on, user: server.user }; - const user = repCache[row.key]; - return { on: user === null ? server.auto.length > 0 : user, user }; + return { on: server.on, user: server.user }; }; - const repDirty = (): boolean => Object.keys(repCache).length > 0; const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active); @@ -217,10 +209,8 @@ export function renderE03StabSection(root: HTMLElement): void { if (!result.fresh) lines.push(t("E03_Stale")); } if (result && !result.editable) lines.push(t("E03_ReadOnly")); - if (repDirty()) lines.push(t("E03_RepUnsaved")); status.textContent = lines.join("\n"); analyzeButton.disabled = !result?.editable; - repButton.disabled = !result?.editable || !repDirty(); view3dButton.disabled = result?.status !== "ok"; }; @@ -235,22 +225,12 @@ export function renderE03StabSection(root: HTMLElement): void { sort, ngOnly, active, + interval, representative: repOf, editable: result.editable, }, (key) => void select(key), - (key) => { - const row = result!.stations.find((r) => r.key === key)!; - const now = repOf(row).on; - const auto = (row.representative?.auto.length ?? 0) > 0; - // 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌 - const next = !now === auto ? null : !now; - if (next === (row.representative?.user ?? null)) delete repCache[key]; - else repCache[key] = next; - sessionStorage.setItem(repKey(pid), JSON.stringify(repCache)); - paintList(); - paintStatus(); - }, + (key) => void toggleRep(key), ), ); }; @@ -286,7 +266,7 @@ export function renderE03StabSection(root: HTMLElement): void { }); const title = el("h3", { className: "e03-card__title", - text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, + text: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, }); const flags = el("div", { className: "e03-panel__row" }); flags.append( @@ -399,7 +379,16 @@ export function renderE03StabSection(root: HTMLElement): void { const paintPlan = (): void => { planHost.replaceChildren( - result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"), + result + ? buildPlan( + result.stations, + condition, + active, + interval, + planView, + (key) => void select(key), + ) + : el("div"), ); }; @@ -438,7 +427,10 @@ export function renderE03StabSection(root: HTMLElement): void { condition, ); const keys = result.stations.map((r) => r.key); - repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k))); + interval = inferStationInterval( + result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m), + ); + delete planView.zoom; // 다시 읽음 = 띠 원배율 if (!active || !keys.includes(active)) { // 처음 = 그 조건 가장 낮은 안전율 측점 const worst = result.stations @@ -488,20 +480,50 @@ export function renderE03StabSection(root: HTMLElement): void { } } - async function saveRep(): Promise { - try { + /** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */ + function toggleRep(key: string): Promise { + repSaving = repSaving.then(async () => { + const row = result?.stations.find((r) => r.key === key); + if (!row) return; + const server = row.representative ?? { auto: [], user: null, on: false }; + const auto = server.auto.length > 0; + const next = !server.on === auto ? null : !server.on; const overrides: Record = {}; - for (const row of result?.stations ?? []) { - const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null); - if (user !== null) overrides[row.key] = user; + for (const r of result?.stations ?? []) { + const user = r.key === key ? next : (r.representative?.user ?? null); + if (user !== null) overrides[r.key] = user; } - await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); - repCache = {}; - sessionStorage.removeItem(repKey(pid)); - showToast(t("E03_RepSaved"), "success"); - await load(); - } catch (error) { - showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + try { + await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); + row.representative = { ...server, user: next, on: next === null ? auto : next }; + paintList(); + void refreshReport(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + }); + return repSaving; + } + + /** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */ + let reportRun = 0; + async function refreshReport(): Promise { + const run = ++reportRun; + await sleep(800); // 별을 잇달아 누르면 마지막 한 번만 + if (run !== reportRun) return; + try { + const state = await api<{ book: unknown; rev: string | null }>( + `/projects/${pid}/stab/report`, + ); + if (!state.book || run !== reportRun) return; + const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, { + method: "POST", + body: JSON.stringify({ rev: state.rev }), + }); + const over = body.overwritten ?? []; + if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000); + } catch { + /* 보고서는 E05 를 열 때 stale 로 알림 */ } } diff --git a/E03_StabSection/E03_StabSection_UI_Plan.ts b/E03_StabSection/E03_StabSection_UI_Plan.ts index cb38e360c..cb1bacaea 100644 --- a/E03_StabSection/E03_StabSection_UI_Plan.ts +++ b/E03_StabSection/E03_StabSection_UI_Plan.ts @@ -3,14 +3,20 @@ * E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이 * 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점. * 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`. + * 확대 · 이동 = 공용 `attachSvgZoomPan`(휠 · 가운데 끌기 · 두 번 누름 원배율 · 요구 390) · 측점 글 솎음 + * = logic `labelStride` · `planLabelKeys` · 배율 · 폭은 바깥 `PlanView` 가 쥠(다시 지어도 살아남음). * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; +import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; +import { stationLabel } from "../common_util/common_util_station"; import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { bandSegments, + labelStride, planFit, + planLabelKeys, verdictOf, type Condition, type Point, @@ -21,11 +27,20 @@ import { svgEl } from "./E03_StabSection_UI_Section"; const H = 96; const PAD = 10; +const LABEL_GAP = 64; // 측점 글 사이 최소 화면 간격(px) + +/** 띠 배율 · 그때 폭 — 폭이 바뀌면 원배율로 */ +export interface PlanView { + zoom?: ZoomPanState; + width?: number; +} export function buildPlan( rows: StationRow[], condition: Condition, active: string | null, + interval: number, + state: PlanView, onSelect: (key: string) => void, ): HTMLElement { const box = card("e03-plan"); @@ -49,47 +64,110 @@ export function buildPlan( const host = el("div"); box.append(host); + const ro = new ResizeObserver(() => draw()); const draw = (): void => { + if (!host.isConnected) return void ro.disconnect(); // 다시 지은 띠 — 옛 것은 손 뗌 const W = Math.max(host.clientWidth, 300); const segs = bandSegments(rows, condition); const all: Point[] = segs.flatMap((s) => s.points); const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" }); - if (all.length) { - const fit = planFit(all, W, H, PAD); - const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; - for (const seg of segs) { - svg.append( - svgEl("polyline", { - points: seg.points.map((p) => scr(p).join(",")).join(" "), - class: `e03-plan__seg e03-plan__seg--${seg.verdict}`, - "data-verdict": seg.verdict, - }), - ); - } - const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null); - const picked = centers.find((r) => r.key === active); - if (picked) { - const [x, y] = scr([picked.center![0]!, picked.center![1]!]); - svg.append( - svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }), - ); - } - svg.addEventListener("click", (ev) => { - const box2 = svg.getBoundingClientRect(); - const px = ((ev.clientX - box2.left) / box2.width) * W; - const py = ((ev.clientY - box2.top) / box2.height) * H; - let best: StationRow | null = null; - let dist = Infinity; - for (const r of centers) { - const [x, y] = scr([r.center![0]!, r.center![1]!]); - const d = Math.hypot(x - px, y - py); - if (d < dist) [best, dist] = [r, d]; - } - if (best && dist < 30) onSelect(best.key); - }); - } host.replaceChildren(svg); + if (!all.length) return; + const fit = planFit(all, W, H, PAD); + const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; + const layer = svgEl("g", { "data-role": "plan-layer" }) as SVGGElement; + for (const seg of segs) { + layer.append( + svgEl("polyline", { + points: seg.points.map((p) => scr(p).join(",")).join(" "), + class: `e03-plan__seg e03-plan__seg--${seg.verdict}`, + "data-verdict": seg.verdict, + }), + ); + } + const centers = rows + .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) + .sort((a, b) => a.chainage_m - b.chainage_m); + const at = (r: StationRow): Point => scr([r.center![0]!, r.center![1]!]); + // 측점 간격 하나의 화면 길이(원배율) — 중심 이은 길이 ÷ 누가 길이 × 간격 + const span = + centers.length > 1 ? centers[centers.length - 1].chainage_m - centers[0].chainage_m : 0; + let len = 0; + for (let i = 1; i < centers.length; i += 1) { + const [a, b] = [at(centers[i - 1]), at(centers[i])]; + len += Math.hypot(b[0] - a[0], b[1] - a[1]); + } + const intervalPx = span > 0 ? (len / span) * interval : 0; + const maxScale = Math.min(500, Math.max(8, Math.ceil(240 / Math.max(intervalPx, 1e-3)))); + const picked = centers.find((r) => r.key === active); + const pick = picked + ? svgEl("circle", { + cx: at(picked)[0], + cy: at(picked)[1], + r: 6, + class: "e03-plan__pick", + "data-key": picked.key, + }) + : null; + const labels = svgEl("g", { class: "e03-plan__labels" }) as SVGGElement; + layer.append(labels, ...(pick ? [pick] : [])); + svg.append(layer); + let stride = 0; + const apply = (s: ZoomPanState): void => { + state.zoom = s; + labels.style.setProperty("--e03-label-k", String(1 / s.scale)); // 글 · 표시는 확대 안 함 + pick?.setAttribute("r", String(6 / s.scale)); + const next = intervalPx > 0 ? labelStride(intervalPx * s.scale, LABEL_GAP) : 100; + if (next === stride) return; + stride = next; + const keep = new Set(planLabelKeys(centers, interval, stride, active)); + labels.replaceChildren( + ...centers + .filter((r) => keep.has(r.key)) + .map((r) => + svgEl( + "text", + { + x: at(r)[0], + y: at(r)[1], + dy: "-0.9em", + class: "e03-label e03-plan__label", + "data-key": r.key, + }, + stationLabel(r.chainage_m, interval, 1), + ), + ), + ); + }; + const initial = state.width === W ? state.zoom : undefined; + state.width = W; + attachSvgZoomPan({ + svg, + layer, + widthPx: W, + heightPx: H, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, + wheelZoom: true, + maxScale, + initial, + onChange: apply, + }); + apply(initial ?? { scale: 1, tx: 0, ty: 0 }); + svg.addEventListener("click", (ev) => { + const box2 = svg.getBoundingClientRect(); + const px = ((ev.clientX - box2.left) / box2.width) * W; + const py = ((ev.clientY - box2.top) / box2.height) * H; + const s = state.zoom ?? { scale: 1, tx: 0, ty: 0 }; + let best: StationRow | null = null; + let dist = Infinity; + for (const r of centers) { + const [x, y] = at(r); + const d = Math.hypot(s.scale * x + s.tx - px, s.scale * y + s.ty - py); + if (d < dist) [best, dist] = [r, d]; + } + if (best && dist < 30) onSelect(best.key); + }); }; - new ResizeObserver(draw).observe(host); + ro.observe(host); return box; } diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index f0de0ff7b..e1225726f 100644 --- a/E03_StabSection/E03_StabSection_UI_Style.css +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -78,7 +78,7 @@ /* ---- 틀 ---- */ .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 +207,7 @@ .e03-plan__seg { fill: none; stroke-width: 7; + vector-effect: non-scaling-stroke; stroke-linecap: butt; stroke-linejoin: round; cursor: pointer; @@ -228,6 +229,12 @@ fill: var(--e03-plot); stroke: var(--e03-critical); stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.e03-plan__label { + text-anchor: middle; + pointer-events: none; } .e03-legend { diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index c869cb37a..969d97871 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -190,6 +190,31 @@ export function planFit( return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 }; } +/** 평면 띠 측점 글 솎음 걸음 — 측점 간격 화면 길이(px) × 걸음이 minPx 넘는 가장 작은 것 */ +export const PLAN_LABEL_STRIDES = [1, 2, 5, 10, 25, 50, 100] as const; + +export function labelStride(intervalPx: number, minPx: number): number { + return PLAN_LABEL_STRIDES.find((s) => s * intervalPx >= minPx) ?? 100; +} + +/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘 */ +export function planLabelKeys( + rows: StationRow[], + interval: number, + stride: number, + active: string | null, +): string[] { + const step = interval > 0 ? interval : 1; + return rows + .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) + .filter((r) => { + if (r.key === active || stride === 1) return true; + const n = Math.round(r.chainage_m / step); + return Math.abs(r.chainage_m - n * step) < 0.05 && n % stride === 0; + }) + .map((r) => r.key); +} + /* ---- 안전율 색 ------------------------------------------------------------ */ /** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */ diff --git a/resources/tester/test_532_3_e03_section.py b/resources/tester/test_532_3_e03_section.py index 7f1a08727..17d0cd9cd 100644 --- a/resources/tester/test_532_3_e03_section.py +++ b/resources/tester/test_532_3_e03_section.py @@ -411,6 +411,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 +544,10 @@ def test_logic_TS(): ) assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"] assert out["zAt"] == [2.5, 0] + # 평면 띠 측점 글 솎음 — 간격 px × 걸음 ≥ 64 · 본 측점 중 걸음 배수 · 고른 측점은 늘 + assert out["stride"] == [1, 2, 10, 100] + assert out["labels"] == [["a", "b", "c", "d", "e"], ["a", "c", "e"], ["a"]] + assert out["labelsActive"] == ["a", "e"] def test_엔진_지문은_코드_내용(): diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 2051255c8..29befffc6 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -25,9 +25,6 @@ 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_Loading: ["불러오는 중…", "Loading…"], E03_NotYet: [ "아직 해석하지 않았습니다 — [해석]을 누르세요.", From ae24e29d124b982573cf6d2229db1665271ee8aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 19:58:56 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(562-1):=20D03=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=EC=85=88=20=EC=A7=9D=20=E2=80=94?= =?UTF-8?q?=20=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EC=9B=90=EC=A7=80=EB=B0=98=20?= =?UTF-8?q?=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=C2=B7=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=20=EC=96=91=EA=B0=81=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=20=EC=9C=84=20=EA=B1=B0=EB=A6=AC=20=E2=86=94?= =?UTF-8?q?=20=EC=A0=90=20=C2=B7=20=EC=B8=A1=EC=A0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/D03_PlanAnalysis_Draw.ts ↔ D03_PlanAnalysis_Draw.py 같은 이름 짝 · 높이는 인자 heightAt - 거울 시험 test_562_1_plan_draw.py — 두 쪽 값 대조 + 손셈 값 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Draw.py | 253 ++++++++++++++++++ .../logic/D03_PlanAnalysis_Draw.ts | 236 ++++++++++++++++ resources/tester/test_562_1_plan_draw.py | 203 ++++++++++++++ 3 files changed, 692 insertions(+) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Draw.py create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts create mode 100644 resources/tester/test_562_1_plan_draw.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py b/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py new file mode 100644 index 000000000..d30968ecf --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py @@ -0,0 +1,253 @@ +"""D 예정노선 수동 직선 그리기 셈 — [저장] 정본 쪽 (PLAN 562-1 · 요구 389 12 · 13). + +구간 길이 · 원지반 기울기 · 길이 고정 · 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. +높이는 인자 `height_at(x, y)`(nan = 지형 밖). + +⚠ 파이썬·TS 짝 — `logic/D03_PlanAnalysis_Draw.ts` 같은 이름(camel). 한쪽을 고치면 다른 쪽도 고치고 +거울 시험 `resources/tester/test_562_1_plan_draw.py`. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Sequence + +Vertex = tuple[float, float] +HeightAt = Callable[[float, float], float] + +# 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수 — 짝 TS 상수 +SLOPE_STEP_M = 1 +BISECT = 40 +COMPASS_SAMPLES = 360 +EPS = 1e-9 + + +def _finite(value: float) -> bool: + return math.isfinite(value) + + +def segment_length(a: Sequence[float], b: Sequence[float]) -> float: + """수평 길이(m).""" + dx = b[0] - a[0] + dy = b[1] - a[1] + return math.sqrt(dx * dx + dy * dy) + + +def ground_slope_pct(a: Sequence[float], b: Sequence[float], height_at: HeightAt) -> float | None: + """원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · None = 지형 밖 · 길이 0.""" + length = segment_length(a, b) + if length < EPS: + return None + za = height_at(a[0], a[1]) + zb = height_at(b[0], b[1]) + if not _finite(za) or not _finite(zb): + return None + return ((zb - za) / length) * 100 + + +def fixed_length_point( + start: Sequence[float], cursor: Sequence[float], length_m: float +) -> Vertex | None: + """길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 None.""" + d = segment_length(start, cursor) + if d < EPS: + return None + return ( + start[0] + ((cursor[0] - start[0]) / d) * length_m, + start[1] + ((cursor[1] - start[1]) / d) * length_m, + ) + + +def fixed_slope_point( + start: Sequence[float], + cursor: Sequence[float], + slope_pct: float, + height_at: HeightAt, + max_m: float = 1000, +) -> Vertex | None: + """기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) · + `max_m` 안에 없거나 지형 밖에 닿으면 None.""" + d = segment_length(start, cursor) + z0 = height_at(start[0], start[1]) + if d < EPS or not _finite(z0): + return None + ux = (cursor[0] - start[0]) / d + uy = (cursor[1] - start[1]) / d + grade = abs(slope_pct) / 100 + + def at(t: float) -> Vertex: + return (start[0] + ux * t, start[1] + uy * t) + + def f(t: float) -> float: + p = at(t) + return abs(height_at(p[0], p[1]) - z0) - grade * t + + lo = 0.0 + flo = math.nan + i = 1 + while i * SLOPE_STEP_M <= max_m: + t = i * SLOPE_STEP_M + ft = f(t) + if not _finite(ft): + return None + if ft == 0: + return at(t) + if i > 1 and (flo < 0) != (ft < 0): + hi = t + for _ in range(BISECT): + mid = (lo + hi) / 2 + fm = f(mid) + if not _finite(fm): + return None + if (fm < 0) == (flo < 0): + lo = mid + flo = fm + else: + hi = mid + return at((lo + hi) / 2) + lo = t + flo = ft + i += 1 + return None + + +def compass_candidates( + start: Sequence[float], length_m: float, slope_pct: float, height_at: HeightAt +) -> list[Vertex]: + """둘 다(양각기) — 반지름 `length_m` 원 위에서 |기울기| = 목표인 후보들 + (방위 표본 · 이분 · 동쪽부터 반시계).""" + z0 = height_at(start[0], start[1]) + if not length_m > EPS or not _finite(z0): + return [] + rise = (abs(slope_pct) / 100) * length_m + step = (2 * math.pi) / COMPASS_SAMPLES + + def at(angle: float) -> Vertex: + return (start[0] + math.cos(angle) * length_m, start[1] + math.sin(angle) * length_m) + + def h(angle: float) -> float: + p = at(angle) + return abs(height_at(p[0], p[1]) - z0) - rise + + values = [h(k * step) for k in range(COMPASS_SAMPLES)] + out: list[Vertex] = [] + for k in range(COMPASS_SAMPLES): + hk = values[k] + hn = values[(k + 1) % COMPASS_SAMPLES] + if not _finite(hk) or not _finite(hn): + continue + if hk == 0: + out.append(at(k * step)) + continue + if hn == 0 or (hk < 0) == (hn < 0): + continue + lo = k * step + hi = lo + step + flo = hk + ok = True + for _ in range(BISECT): + mid = (lo + hi) / 2 + fm = h(mid) + if not _finite(fm): + ok = False + break + if (fm < 0) == (flo < 0): + lo = mid + flo = fm + else: + hi = mid + if ok: + out.append(at((lo + hi) / 2)) + return out + + +def compass_point( + start: Sequence[float], + cursor: Sequence[float], + length_m: float, + slope_pct: float, + height_at: HeightAt, +) -> Vertex | None: + """둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 None.""" + best: Vertex | None = None + best_d = math.inf + for p in compass_candidates(start, length_m, slope_pct, height_at): + d = segment_length(p, cursor) + if d < best_d: + best = p + best_d = d + return best + + +def line_length(line: Sequence[Sequence[float]]) -> float: + """선 길이(m) — 앞에서부터 더함.""" + total = 0.0 + for i in range(1, len(line)): + total += segment_length(line[i - 1], line[i]) + return total + + +def point_at_distance(line: Sequence[Sequence[float]], m: float) -> Vertex: + """선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상.""" + if m <= 0: + return (line[0][0], line[0][1]) + acc = 0.0 + for i in range(1, len(line)): + a = line[i - 1] + b = line[i] + seg = segment_length(a, b) + if seg > 0 and acc + seg >= m: + t = (m - acc) / seg + return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + acc += seg + return (line[-1][0], line[-1][1]) + + +def distance_along(line: Sequence[Sequence[float]], point: Sequence[float]) -> float: + """점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간).""" + best = 0.0 + best_d = math.inf + acc = 0.0 + for i in range(1, len(line)): + a = line[i - 1] + b = line[i] + seg = segment_length(a, b) + t = 0.0 + if seg > 0: + t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / ( + seg * seg + ) + t = min(1.0, max(0.0, t)) + p = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + d = segment_length(p, point) + if d < best_d: + best_d = d + best = acc + seg * t + acc += seg + return best + + +def slice_line(line: Sequence[Sequence[float]], from_m: float, to_m: float) -> list[Vertex]: + """선 조각 — `from_m`~`to_m` 사이(끝점 + 그 사이 꺾임점).""" + lo = min(from_m, to_m) + hi = max(from_m, to_m) + out = [point_at_distance(line, lo)] + acc = 0.0 + for i in range(1, len(line) - 1): + acc += segment_length(line[i - 1], line[i]) + if lo + EPS < acc < hi - EPS: + out.append((line[i][0], line[i][1])) + out.append(point_at_distance(line, hi)) + return out + + +def station_chainages(length_m: float, interval_m: float = 20) -> list[float]: + """측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터).""" + out: list[float] = [0] + k = 1 + while k * interval_m < length_m - 1e-6: + out.append(k * interval_m) + k += 1 + if length_m > 1e-6: + out.append(length_m) + return out diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts new file mode 100644 index 000000000..daa3a7fcd --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_Draw.ts + * D 예정노선 수동 직선 그리기 셈(PLAN 562-1 · 요구 389 12 · 13) — 구간 길이 · 원지반 기울기 · 길이 고정 · + * 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. 높이는 인자 `heightAt(x, y)`(NaN = 지형 밖). + * DOM · fetch · sessionStorage 없음. + * + * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Draw.py` 같은 이름(snake). 한쪽을 고치면 다른 쪽도 고치고 거울 시험 + * `resources/tester/test_562_1_plan_draw.py`. 화면은 그리는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. + * ========================================================================== */ + +export type Vertex = [number, number]; +export type HeightAt = (x: number, y: number) => number; + +/** 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수. */ +const SLOPE_STEP_M = 1; +const BISECT = 40; +const COMPASS_SAMPLES = 360; +const EPS = 1e-9; + +/** 수평 길이(m). */ +export function segmentLength(a: Vertex, b: Vertex): number { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + return Math.sqrt(dx * dx + dy * dy); +} + +/** 원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · null = 지형 밖 · 길이 0. */ +export function groundSlopePct(a: Vertex, b: Vertex, heightAt: HeightAt): number | null { + const length = segmentLength(a, b); + if (length < EPS) return null; + const za = heightAt(a[0], a[1]); + const zb = heightAt(b[0], b[1]); + if (!Number.isFinite(za) || !Number.isFinite(zb)) return null; + return ((zb - za) / length) * 100; +} + +/** 길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 null. */ +export function fixedLengthPoint(from: Vertex, cursor: Vertex, lengthM: number): Vertex | null { + const d = segmentLength(from, cursor); + if (d < EPS) return null; + return [ + from[0] + ((cursor[0] - from[0]) / d) * lengthM, + from[1] + ((cursor[1] - from[1]) / d) * lengthM, + ]; +} + +/** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) · + * `maxM` 안에 없거나 지형 밖에 닿으면 null. */ +export function fixedSlopePoint( + from: Vertex, + cursor: Vertex, + slopePct: number, + heightAt: HeightAt, + maxM: number = 1000, +): Vertex | null { + const d = segmentLength(from, cursor); + const z0 = heightAt(from[0], from[1]); + if (d < EPS || !Number.isFinite(z0)) return null; + const ux = (cursor[0] - from[0]) / d; + const uy = (cursor[1] - from[1]) / d; + const grade = Math.abs(slopePct) / 100; + const at = (t: number): Vertex => [from[0] + ux * t, from[1] + uy * t]; + const f = (t: number): number => { + const p = at(t); + return Math.abs(heightAt(p[0], p[1]) - z0) - grade * t; + }; + let lo = 0; + let flo = NaN; + for (let i = 1; i * SLOPE_STEP_M <= maxM; i += 1) { + const t = i * SLOPE_STEP_M; + const ft = f(t); + if (!Number.isFinite(ft)) return null; + if (ft === 0) return at(t); + if (i > 1 && flo < 0 !== ft < 0) { + let hi = t; + for (let k = 0; k < BISECT; k += 1) { + const mid = (lo + hi) / 2; + const fm = f(mid); + if (!Number.isFinite(fm)) return null; + if (fm < 0 === flo < 0) { + lo = mid; + flo = fm; + } else hi = mid; + } + return at((lo + hi) / 2); + } + lo = t; + flo = ft; + } + return null; +} + +/** 둘 다(양각기) — 반지름 `lengthM` 원 위에서 |기울기| = 목표인 후보들(방위 표본 · 이분 · 동쪽부터 + * 반시계). */ +export function compassCandidates( + from: Vertex, + lengthM: number, + slopePct: number, + heightAt: HeightAt, +): Vertex[] { + const z0 = heightAt(from[0], from[1]); + if (!(lengthM > EPS) || !Number.isFinite(z0)) return []; + const rise = (Math.abs(slopePct) / 100) * lengthM; + const step = (2 * Math.PI) / COMPASS_SAMPLES; + const at = (angle: number): Vertex => [ + from[0] + Math.cos(angle) * lengthM, + from[1] + Math.sin(angle) * lengthM, + ]; + const h = (angle: number): number => { + const p = at(angle); + return Math.abs(heightAt(p[0], p[1]) - z0) - rise; + }; + const values: number[] = []; + for (let k = 0; k < COMPASS_SAMPLES; k += 1) values.push(h(k * step)); + const out: Vertex[] = []; + for (let k = 0; k < COMPASS_SAMPLES; k += 1) { + const hk = values[k]; + const hn = values[(k + 1) % COMPASS_SAMPLES]; + if (!Number.isFinite(hk) || !Number.isFinite(hn)) continue; + if (hk === 0) { + out.push(at(k * step)); + continue; + } + if (hn === 0 || hk < 0 === hn < 0) continue; + let lo = k * step; + let hi = lo + step; + let flo = hk; + let ok = true; + for (let i = 0; i < BISECT; i += 1) { + const mid = (lo + hi) / 2; + const fm = h(mid); + if (!Number.isFinite(fm)) { + ok = false; + break; + } + if (fm < 0 === flo < 0) { + lo = mid; + flo = fm; + } else hi = mid; + } + if (ok) out.push(at((lo + hi) / 2)); + } + return out; +} + +/** 둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 null. */ +export function compassPoint( + from: Vertex, + cursor: Vertex, + lengthM: number, + slopePct: number, + heightAt: HeightAt, +): Vertex | null { + let best: Vertex | null = null; + let bestD = Infinity; + for (const p of compassCandidates(from, lengthM, slopePct, heightAt)) { + const d = segmentLength(p, cursor); + if (d < bestD) { + best = p; + bestD = d; + } + } + return best; +} + +/** 선 길이(m) — 앞에서부터 더함. */ +export function lineLength(line: ReadonlyArray): number { + let total = 0; + for (let i = 1; i < line.length; i += 1) total += segmentLength(line[i - 1], line[i]); + return total; +} + +/** 선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상. */ +export function pointAtDistance(line: ReadonlyArray, m: number): Vertex { + if (m <= 0) return [line[0][0], line[0][1]]; + let acc = 0; + for (let i = 1; i < line.length; i += 1) { + const a = line[i - 1]; + const b = line[i]; + const seg = segmentLength(a, b); + if (seg > 0 && acc + seg >= m) { + const t = (m - acc) / seg; + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + } + acc += seg; + } + const last = line[line.length - 1]; + return [last[0], last[1]]; +} + +/** 점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간). */ +export function distanceAlong(line: ReadonlyArray, point: Vertex): number { + let best = 0; + let bestD = Infinity; + let acc = 0; + for (let i = 1; i < line.length; i += 1) { + const a = line[i - 1]; + const b = line[i]; + const seg = segmentLength(a, b); + let t = 0; + if (seg > 0) { + t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (seg * seg); + t = Math.min(1, Math.max(0, t)); + } + const p: Vertex = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + const d = segmentLength(p, point); + if (d < bestD) { + bestD = d; + best = acc + seg * t; + } + acc += seg; + } + return best; +} + +/** 선 조각 — `fromM`~`toM` 사이(끝점 + 그 사이 꺾임점). */ +export function sliceLine(line: ReadonlyArray, fromM: number, toM: number): Vertex[] { + const lo = Math.min(fromM, toM); + const hi = Math.max(fromM, toM); + const out: Vertex[] = [pointAtDistance(line, lo)]; + let acc = 0; + for (let i = 1; i < line.length - 1; i += 1) { + acc += segmentLength(line[i - 1], line[i]); + if (acc > lo + EPS && acc < hi - EPS) out.push([line[i][0], line[i][1]]); + } + out.push(pointAtDistance(line, hi)); + return out; +} + +/** 측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터). */ +export function stationChainages(lengthM: number, intervalM: number = 20): number[] { + const out = [0]; + for (let k = 1; k * intervalM < lengthM - 1e-6; k += 1) out.push(k * intervalM); + if (lengthM > 1e-6) out.push(lengthM); + return out; +} diff --git a/resources/tester/test_562_1_plan_draw.py b/resources/tester/test_562_1_plan_draw.py new file mode 100644 index 000000000..e42bff53d --- /dev/null +++ b/resources/tester/test_562_1_plan_draw.py @@ -0,0 +1,203 @@ +"""D03 수동 그리기 셈 거울(PLAN 562-1) — 파이썬 `D03_PlanAnalysis_Draw` ↔ +TS `logic/D03_PlanAnalysis_Draw.ts`. + +같은 높이 함수(`hill` · x > 800 = 지형 밖)로 두 쪽 값이 같은지 + 손셈 값. + +./venv/Scripts/python.exe -m pytest resources/tester/test_562_1_plan_draw.py -q +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw # noqa: E402 + +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def hill(x: float, y: float) -> float: + if x > 800: + return math.nan + return 0.0005 * x * x + 3 * math.sin(y / 40) + + +def plane(x: float, y: float) -> float: + return 0.1 * x + + +LINE = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m + +# (함수 이름, 인자) — 높이 자리는 "hill" · "plane" 글자로 넘김 +CASES = [ + ("segmentLength", [[1, 2], [4, 6]]), + ("groundSlopePct", [[0, 0], [100, 30], "hill"]), + ("groundSlopePct", [[100, 30], [0, 0], "hill"]), + ("groundSlopePct", [[700, 0], [900, 0], "hill"]), + ("groundSlopePct", [[5, 5], [5, 5], "hill"]), + ("fixedLengthPoint", [[10, 10], [13, 14], 20]), + ("fixedLengthPoint", [[10, 10], [10, 10], 20]), + ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill"]), + ("fixedSlopePoint", [[0, 0], [50, 0], -9, "hill"]), + ("fixedSlopePoint", [[100, 30], [130, 70], 12, "hill"]), + ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill", 100]), + ("fixedSlopePoint", [[600, 0], [700, 0], 1, "hill"]), + ("compassCandidates", [[0, 0], 50, 5, "plane"]), + ("compassCandidates", [[200, 10], 60, 15, "hill"]), + ("compassCandidates", [[0, 0], 50, 50, "plane"]), + ("compassPoint", [[0, 0], [10, 40], 50, 5, "plane"]), + ("compassPoint", [[0, 0], [10, 40], 50, 50, "plane"]), + ("lineLength", [LINE]), + ("pointAtDistance", [LINE, -5]), + ("pointAtDistance", [LINE, 75]), + ("pointAtDistance", [LINE, 500]), + ("distanceAlong", [LINE, [40, 70]]), + ("distanceAlong", [LINE, [-10, -10]]), + ("sliceLine", [LINE, 25, 160]), + ("sliceLine", [LINE, 160, 25]), + ("stationChainages", [210]), + ("stationChainages", [40, 20]), + ("stationChainages", [0]), +] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as draw from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js"; +const heights = { + hill: (x, y) => (x > 800 ? NaN : 0.0005 * x * x + 3 * Math.sin(y / 40)), + plane: (x, y) => 0.1 * x, +}; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = cases.map(([name, args]) => + draw[name](...args.map((a) => (typeof a === "string" ? heights[a] : a))), +); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + +_HEIGHTS = {"hill": hill, "plane": plane} +_PY = { + "segmentLength": draw.segment_length, + "groundSlopePct": draw.ground_slope_pct, + "fixedLengthPoint": draw.fixed_length_point, + "fixedSlopePoint": draw.fixed_slope_point, + "compassCandidates": draw.compass_candidates, + "compassPoint": draw.compass_point, + "lineLength": draw.line_length, + "pointAtDistance": draw.point_at_distance, + "distanceAlong": draw.distance_along, + "sliceLine": draw.slice_line, + "stationChainages": draw.station_chainages, +} + + +def _py(name: str, args: list) -> object: + return _PY[name](*(_HEIGHTS[a] if isinstance(a, str) else a for a in args)) + + +def _close(a: object, b: object) -> bool: + if a is None or b is None: + return a is b + if isinstance(a, (int, float)): + return isinstance(b, (int, float)) and abs(a - b) < 1e-6 + return len(a) == len(b) and all(_close(x, y) for x, y in zip(a, b)) # type: ignore[arg-type] + + +@pytest.fixture(scope="module") +def ts_out(tmp_path_factory: pytest.TempPathFactory) -> list: + tmp = tmp_path_factory.mktemp("draw") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + job = tmp / "job.json" + result = tmp / "out.json" + job.write_text(json.dumps(CASES), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_ts_matches_python(ts_out: list) -> None: + for (name, args), ts in zip(CASES, ts_out): + assert _close(ts, _py(name, args)), (name, args, ts, _py(name, args)) + + +def test_hand_values() -> None: + assert draw.segment_length((1, 2), (4, 6)) == 5 + # 오르막 + · 내리막 − · 지형 밖 · 길이 0 + up = draw.ground_slope_pct((0, 0), (100, 0), hill) + assert up == pytest.approx(5.0) + assert draw.ground_slope_pct((100, 0), (0, 0), hill) == pytest.approx(-5.0) + assert draw.ground_slope_pct((700, 0), (900, 0), hill) is None + assert draw.ground_slope_pct((5, 5), (5, 5), hill) is None + assert draw.fixed_length_point((10, 10), (13, 14), 20) == pytest.approx((22, 26)) + # z = 0.0005 x² · x0 = 0 → 평균 기울기 0.0005 t = 9 % 첫 자리 t = 180 + # 부호 무관 · 한도 100 m = 없음 + assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill) == pytest.approx((180, 0), abs=1e-6) + assert draw.fixed_slope_point((0, 0), (50, 0), -9, hill) == pytest.approx((180, 0), abs=1e-6) + assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill, 100) is None + # 지형 밖에 닿음 + assert draw.fixed_slope_point((600, 0), (700, 0), 1, hill) is None + # 평면 10 % · 반지름 50 · 5 % → cos θ = ±0.5 · 60 · 120 · 240 · 300° + got = draw.compass_candidates((0, 0), 50, 5, plane) + angles = sorted(round(math.degrees(math.atan2(y, x)) % 360, 6) for x, y in got) + assert angles == [60, 120, 240, 300] + assert draw.compass_candidates((0, 0), 50, 50, plane) == [] + near = draw.compass_point((0, 0), (10, 40), 50, 5, plane) + assert near == pytest.approx((25, 25 * math.sqrt(3)), abs=1e-6) + assert draw.compass_point((0, 0), (10, 40), 50, 50, plane) is None + + +def test_line_values() -> None: + assert draw.line_length(LINE) == 210 + assert draw.point_at_distance(LINE, -5) == (0, 0) + assert draw.point_at_distance(LINE, 75) == (30, 65) + assert draw.point_at_distance(LINE, 500) == (90, 180) + assert draw.distance_along(LINE, (40, 70)) == 80 + assert draw.distance_along(LINE, (-10, -10)) == 0 + assert draw.slice_line(LINE, 25, 160) == pytest.approx( + [(15, 20), (30, 40), (30, 100), (60, 140)] + ) + assert draw.slice_line(LINE, 160, 25) == draw.slice_line(LINE, 25, 160) + assert draw.station_chainages(210) == [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 210] + assert draw.station_chainages(40, 20) == [0, 20, 40] + assert draw.station_chainages(0) == [0] From 70d3a69847c0480fe89ed48c946559b76b8f0ac2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 19:58:56 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(B04):=20.cpg=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EB=8F=84=EC=97=BD=20dbf=20=3D=20cp949=20=EB=A1=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EB=B3=91=ED=95=A9=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=97=B4=EC=87=A0=EC=97=90=20=ED=8C=8C=EC=84=9C=20=ED=8C=90=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20(PLAN=20561-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도엽 파서 — zip 안 .cpg 가 없으면 encoding cp949 (latin-1 로 읽어 「등고수치」 칸 이름이 깨지던 흠) - 병합 열쇠 {"sheets", "parser"} — B04 · D03 둘 다 · 옛 깨진 병합본은 다음 받기 때 다시 지음 - 새 지킴 시험 test_561_1 — .cpg 없는 cp949 견본 → 등고수치 읽힘 - 재현(4250c7bc tmp 사본) — 2026 구간 지형면 0 셀 → 201,088 셀 · 등고수치 빈 선 5,280 → 0 Co-Authored-By: Claude Opus 5.5 --- B04_PreProcess/B04_PreProcess_Engine.py | 9 ++- .../B04_PreProcess_Engine_SheetParser.py | 8 +- D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py | 4 +- resources/tester/test_561_1_sheet_encoding.py | 74 +++++++++++++++++++ 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_561_1_sheet_encoding.py diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 53f567821..6e18bfc88 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -526,9 +526,14 @@ def download_geodata( # 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함). prune_sheets(sheet_store, sheet_grid) - # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록)가 같으면 스킵 + from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION + + # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록 · 파서 판)가 같으면 스킵 merge_key_path = processed_dir / "sheet_merge.key.json" - merge_key = {"sheets": sorted(str(item) for item in sheet_result["available"])} + merge_key = { + "sheets": sorted(str(item) for item in sheet_result["available"]), + "parser": PARSER_VERSION, + } if sheet_result["available"] and ( rebuild or not any(processed_dir.glob("도엽_*.geojson")) diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py b/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py index ff575eeeb..8d1ef96dd 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py @@ -37,6 +37,10 @@ SHEET_LAYERS: dict[str, tuple[str, str]] = { # 산출 파일 접두사 (기존 processed/의 국가 GIS geojson과 구분) _OUTPUT_PREFIX = "도엽" +# 파서 판 번호 — 병합 캐시 열쇠에 넣음 · 읽는 법이 바뀌면 올려 옛 병합본을 다시 지음 +PARSER_VERSION = 2 +# .cpg 없는 도엽 dbf 의 글자 인코딩(국토지리정보원 배포본) +DBF_FALLBACK_ENCODING = "cp949" def _read_layer_from_zip( @@ -57,7 +61,9 @@ def _read_layer_from_zip( with tempfile.TemporaryDirectory() as td: for n in wanted: (Path(td) / Path(n).name).write_bytes(zf.read(n)) - gdf = gpd.read_file(Path(td) / f"{member_base}.shp") + has_cpg = any(Path(n).suffix.lower() == ".cpg" for n in wanted) + options = {} if has_cpg else {"encoding": DBF_FALLBACK_ENCODING} + gdf = gpd.read_file(Path(td) / f"{member_base}.shp", **options) if gdf.empty: return None diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index 7d3be6c57..d50044880 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -26,7 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_MapSheet import ( sheet5k_to_bounds, sheets_for_points, ) -from B04_PreProcess.B04_PreProcess_Engine_SheetParser import parse_and_merge_sheets +from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION, parse_and_merge_sheets from B04_PreProcess.B04_PreProcess_Engine_SheetStore import ( ensure_sheets, get_project_map_sheets_dir, @@ -114,7 +114,7 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st key_path = processed / "sheet_merge.key.json" old = (_read_json(key_path) or {}).get("sheets") or [] sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)}) - key = {"sheets": sheets} + key = {"sheets": sheets, "parser": PARSER_VERSION} if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))): parse_and_merge_sheets(store, sheets, processed) atomic_write_json(key_path, key) diff --git a/resources/tester/test_561_1_sheet_encoding.py b/resources/tester/test_561_1_sheet_encoding.py new file mode 100644 index 000000000..3ced79cfd --- /dev/null +++ b/resources/tester/test_561_1_sheet_encoding.py @@ -0,0 +1,74 @@ +"""PLAN 561-1 — `.cpg` 없는 도엽 dbf = cp949 로 읽음 · 병합 열쇠에 파서 판 번호. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_561_1_sheet_encoding.py -q + +`.cpg` 없는 dbf 를 그냥 읽으면 latin-1 → 칸 이름 「등고수치」가 깨져 등고 높이가 빔 +(D 프로젝트 4250c7bc 「등고 라인 94단을 격자에 굽어 0셀」). +""" + +from __future__ import annotations + +import sys +import zipfile +from pathlib import Path + +import geopandas as gpd +from shapely.geometry import LineString + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B04_PreProcess.B04_PreProcess_Engine_SheetParser as parser # noqa: E402 + +BASE = "N3L_F0010000" + + +def _sheet_zip(tmp_path: Path, with_cpg: bool) -> Path: + """등고선 두 줄짜리 도엽 zip — dbf 는 cp949 · `.cpg` 는 고름.""" + shp_dir = tmp_path / ("cpg" if with_cpg else "nocpg") + shp_dir.mkdir() + gdf = gpd.GeoDataFrame( + {"등고수치": [100.0, 105.0], "구분": ["주곡선", "주곡선"]}, + geometry=[LineString([(0, 0), (10, 0)]), LineString([(0, 5), (10, 5)])], + crs="EPSG:5186", + ) + gdf.to_file(shp_dir / f"{BASE}.shp", encoding="cp949") + cpg = shp_dir / f"{BASE}.cpg" + if with_cpg: + cpg.write_text("CP949", encoding="ascii") + else: + cpg.unlink(missing_ok=True) + zip_path = tmp_path / f"{shp_dir.name}.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in shp_dir.iterdir(): + zf.write(f, f.name) + return zip_path + + +def _read(zip_path: Path) -> gpd.GeoDataFrame: + gdf = parser._read_layer_from_zip(zip_path, "N3L", "F0010000", "EPSG:5186") + assert gdf is not None + return gdf + + +def test_no_cpg_reads_cp949(tmp_path): + gdf = _read(_sheet_zip(tmp_path, with_cpg=False)) + assert "등고수치" in gdf.columns + assert sorted(gdf["등고수치"].tolist()) == [100.0, 105.0] + assert set(gdf["구분"]) == {"주곡선"} + + +def test_with_cpg_unchanged(tmp_path): + gdf = _read(_sheet_zip(tmp_path, with_cpg=True)) + assert sorted(gdf["등고수치"].tolist()) == [100.0, 105.0] + + +def test_merge_keys_carry_parser_version(): + assert parser.PARSER_VERSION >= 2 + for rel in ( + "B04_PreProcess/B04_PreProcess_Engine.py", + "D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py", + ): + text = (ROOT / rel).read_text(encoding="utf-8") + assert '"parser": PARSER_VERSION' in text, rel From 205bc275cd9d1edf17f036f89dd39b7c6bd9a4e1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:00:22 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(E04):=20=ED=99=94=EB=A9=B4=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0=EC=97=90=20=EC=A1=B0=EA=B1=B4=20=C2=B7=20=EA=B1=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=9A=B0=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EC=97=B4=20?= =?UTF-8?q?=EB=95=8C=20=EC=A0=80=EC=9E=A5=20=EC=84=A4=EC=A0=95=20=EB=90=98?= =?UTF-8?q?=EC=82=B4=EB=A6=BC=20=C2=B7=20[=EB=8B=A4=EC=8B=9C=20=ED=95=B4?= =?UTF-8?q?=EC=84=9D]=20=EB=BA=8C=20(PLAN=20564-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구간 목록 = 구간마다 건기 · 우기 안전율 둘 다 · 고른 조건 결과를 엶 - 캐시가 없으면 저장된 결과의 탐색 설정으로 칸을 채움 - [다시 해석] 뺌 — 엔진이 바뀌면 지문(엔진 파일 해시)이 바뀌어 [해석]이 다시 셈 - [기본값]이 조건을 건기로 되돌리지 않음 Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_UI_Page.ts | 84 ++++++++++++++++----------- E04_Stab3D/E04_Stab3D_UI_Settings.ts | 4 +- ui_template/ui_template_locale_e04.ts | 3 +- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index e0a3dbbff..ef7c04817 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -4,7 +4,8 @@ * 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기] * 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면) * 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법) - * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage. + * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage + * · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음). * ========================================================================== */ import "./E04_Stab3D_UI_Style.css"; @@ -44,6 +45,7 @@ interface SavedSummary { f_min: number; volume: number; method: string; + condition: string; stale: boolean; } @@ -67,7 +69,10 @@ interface Progress { } const cacheKey = (projectId: string): string => `e04_settings:${projectId}`; -const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; +const CONDITIONS = ["dry", "wet"] as const; +const rangeKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; +const sectionKey = (start: number, end: number, condition: string): string => + `${rangeKey(start, end)}_${condition}`; async function api(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -218,16 +223,11 @@ export function renderE04Stab3D(root: HTMLElement): void { const analyzeButton = createButton({ label: t("E04_Analyze"), variant: "filled", - onClick: () => void analyze(false), + onClick: () => void analyze(), }); - const reanalyzeButton = createButton({ - label: t("E04_Reanalyze"), - variant: "ghost", - onClick: () => void analyze(true), - }); - analyzeButton.disabled = reanalyzeButton.disabled = true; + analyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(reanalyzeButton, analyzeButton); + actions.append(analyzeButton); const readOnly = note(t("E04_ReadOnly"), "e04-hint"); readOnly.hidden = true; panel.append( @@ -252,40 +252,46 @@ export function renderE04Stab3D(root: HTMLElement): void { let shownKey = ""; settings.onChange(() => { remember(); - const { start_m, end_m } = settings.read(); + const { start_m, end_m, condition } = settings.read(); map.setRange(start_m, end_m); paintSections(); - if (shownKey !== sectionKey(start_m, end_m)) void openResult(); + if (shownKey !== sectionKey(start_m, end_m, condition)) void openResult(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); - analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + analyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } function paintSections(): void { if (!overview) return; - const { start_m, end_m } = settings.read(); + const { start_m, end_m, condition } = settings.read(); const savedOf = new Map(overview.saved.map((s) => [s.key, s])); - const custom = overview.saved.filter( - (s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key), - ); - const items = [ - ...overview.sections, - ...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })), - ]; + const items = new Map(overview.sections.map((x) => [rangeKey(x.start_m, x.end_m), x])); + for (const s of overview.saved) { + const key = rangeKey(s.start_m, s.end_m); + if (!items.has(key)) items.set(key, { start_m: s.start_m, end_m: s.end_m }); + } sectionList.replaceChildren( - ...items.map((section) => { - const key = sectionKey(section.start_m, section.end_m); - const saved = savedOf.get(key); + ...[...items].map(([key, section]) => { + const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`)); + const saved = savedOf.get(`${key}_${condition}`); const row = createListRow({ label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`, - trailing: saved - ? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}` - : "", + trailing: byCondition + .flatMap((x) => + x + ? [ + `${t(`E04_Short_${x.condition}` as LocaleKey)} ${x.f_min.toFixed(2)}${ + x.stale ? ` ${t("E04_Stale")}` : "" + }`, + ] + : [], + ) + .join(" · "), stripe: saved ? stepColor(FS_STEPS, saved.f_min) : "transparent", - active: key === sectionKey(start_m, end_m), + active: key === rangeKey(start_m, end_m), onClick: () => settings.setRange(section.start_m, section.end_m), }); row.classList.add("e04-section"); @@ -364,15 +370,19 @@ export function renderE04Stab3D(root: HTMLElement): void { resultBody.replaceChildren(list, ...notes); } + const currentKey = (): string => { + const { start_m, end_m, condition } = settings.read(); + return sectionKey(start_m, end_m, condition); + }; + async function openResult(): Promise { - const { start_m, end_m } = settings.read(); - const key = sectionKey(start_m, end_m); + const key = currentKey(); shownKey = key; const saved = overview?.saved.some((s) => s.key === key); result = saved ? await api(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null) : null; - if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜 + if (key !== currentKey()) return; // 그새 바뀜 map.setResult(result); if (mode === "3d") view3d.show(result, overview?.line ?? [], layer); paintResult(); @@ -405,13 +415,13 @@ export function renderE04Stab3D(root: HTMLElement): void { } } - async function analyze(force: boolean): Promise { + async function analyze(): Promise { const values = settings.read(); const { start_m, end_m, condition, ...search } = values; try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, { method: "POST", - body: JSON.stringify({ start_m, end_m, condition, search, force }), + body: JSON.stringify({ start_m, end_m, condition, search }), }); if (body.status === "cached") { showToast(t("E04_Cached"), "info"); @@ -446,7 +456,7 @@ export function renderE04Stab3D(root: HTMLElement): void { ...ov.defaults, start_m: first?.start_m ?? 0, end_m: first?.end_m ?? 100, - condition: "dry", + condition: ov.saved[0]?.condition ?? "dry", }, ); map.setRoute(ov.line, ov.stations); @@ -455,6 +465,12 @@ export function renderE04Stab3D(root: HTMLElement): void { paintSections(); stations2d = await fetch2d(projectId); await openResult(); + // 캐시가 없으면 저장된 결과의 탐색 설정을 되살림 + const opened = result as Result3d | null; // openResult 가 바꿈(흐름 좁힘 풀기) + if (!cached && opened?.settings) { + settings.write(opened.settings as Partial); + remember(); + } const p = await api(`/projects/${projectId}/stab/progress3d`).catch(() => null); if (p?.running) void poll(); })(); diff --git a/E04_Stab3D/E04_Stab3D_UI_Settings.ts b/E04_Stab3D/E04_Stab3D_UI_Settings.ts index 215201303..e27573cfd 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Settings.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Settings.ts @@ -98,8 +98,8 @@ export function buildSettingsCards() { variant: "ghost", size: "sm", onClick: () => { - const range = { start_m: read().start_m, end_m: read().end_m }; - write({ ...read(), ...defaults, ...range, condition: "dry" }); + const { start_m, end_m, condition } = read(); // 구간 · 조건은 그대로 + write({ ...read(), ...defaults, start_m, end_m, condition }); changed(); }, }), diff --git a/ui_template/ui_template_locale_e04.ts b/ui_template/ui_template_locale_e04.ts index 49c898651..73d26b435 100644 --- a/ui_template/ui_template_locale_e04.ts +++ b/ui_template/ui_template_locale_e04.ts @@ -39,7 +39,6 @@ export const ui_locales_e04 = { E04_Condition_wet: ["우기 (지표까지 포화)", "Wet (saturated to surface)"], E04_Defaults: ["기본값", "Defaults"], E04_Analyze: ["해석", "Analyze"], - E04_Reanalyze: ["다시 해석", "Re-analyze"], E04_Cancel: ["끊기", "Stop"], E04_Running: ["해석 중", "Analyzing"], E04_Cancelled: ["해석을 끊었습니다.", "Analysis stopped."], @@ -48,6 +47,8 @@ export const ui_locales_e04 = { E04_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."], E04_Saved: ["결과 있음", "Saved"], E04_Stale: ["옛 결과", "Outdated"], + E04_Short_dry: ["건기", "Dry"], + E04_Short_wet: ["우기", "Wet"], /* --- 가운데 지도 · 3D --- */ E04_View_Plan: ["평면 지도", "Plan map"], From bef1e8c15b323a269ab7496105cf4264e4344cb9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:00:59 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(564-3):=20E02=20[=ED=95=B4=EC=84=9D=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91]=20=C2=B7=20=EC=A7=84=ED=96=89=20=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20=EB=8B=A8=EA=B3=84=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?[=EB=81=8A=EA=B8=B0]=20=E2=80=94=20[=EC=A0=80=EC=9E=A5]=20?= =?UTF-8?q?=EB=BA=8C=20=C2=B7=20=EA=B0=94=EB=8B=A4=20=EC=99=80=EB=8F=84=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=9D=B4=EC=96=B4=20=EB=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [저장] 자리에 [해석 시작](POST /stab/run = 검사 · 저장 → 체인) · 잠금판은 저장 순간만 - 진행 막대(ui_template_progress) · 단계 줄 다섯(ui_template_list_row · 상태 띠 · 걸린 초) · [끊기] - 열 때 GET /stab/run/progress — 도는 중이면 이어서 봄 · 끝난 결과면 단계 줄만 - locale_e 글 · 저장 안 됨 글 = 「해석 전」 Co-Authored-By: Claude Opus 5.5 --- E02_StabGround/E02_StabGround_UI_Page.ts | 149 ++++++++++++++++++--- E02_StabGround/E02_StabGround_UI_Style.css | 17 +++ ui_template/ui_template_locale_e.ts | 34 ++++- 3 files changed, 181 insertions(+), 19 deletions(-) diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index a483e2d71..a32bae7c5 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -2,8 +2,10 @@ * E02_StabGround_UI_Page.ts * E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow). * GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지. - * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) · - * [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝). + * 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 · + * 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른 + * 페이지에 갔다 와도 이어 보임) · [끊기] · [초기화] = POST …/reset(초기값 덮어씀). + * 검사 = logic/E02_StabGround_Check(파이썬 짝). * 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05). * ========================================================================== */ @@ -18,6 +20,8 @@ import { showToast, } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { createListRow } from "@ui/ui_template_list_row"; +import { createProgressBar } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; @@ -38,8 +42,33 @@ interface GroundResponse { missing_stations?: number[]; } +interface RunStep { + key: string; + state: string; + seconds: number | null; +} + +/** 체인 진행(`E02_StabGround_Chain` 진행 꼴) */ +interface RunProgress { + running: boolean; + percent: number; + steps: RunStep[]; + message: string | null; + error: string | null; + cancelled: boolean; +} + const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; +/** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */ +const STEP_TONE: Record = { + run: "var(--color-accent)", + done: "var(--color-success)", + skip: "var(--color-success)", + error: "var(--color-danger)", + cancel: "var(--color-warning)", +}; + /** 검사 열쇠 → 범위 열쇠(limits) */ function limitKey(path: string): string { const last = path.split(".").pop() ?? ""; @@ -56,13 +85,13 @@ function errorText(error: CheckError, defaults: GroundDefaults): string { return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text; } -async function request(path: string, init?: RequestInit): Promise { +async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); - const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown }; + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; if (!response.ok) { const detail = body.detail; if (typeof detail === "string") throw new Error(detail); @@ -76,29 +105,95 @@ export function renderE02StabGround(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const main = el("div", { className: "e02-page" }); const status = el("div", { className: "e02-status" }); - const saveButton = createButton({ - label: t("Common_Btn_Save"), + const runButton = createButton({ + label: t("E02_Run"), variant: "filled", - onClick: () => void save(), + onClick: () => void runChain(), }); const resetButton = createButton({ label: t("Common_Btn_Reset"), variant: "danger-ghost", onClick: () => void reset(), }); - saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 + runButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(resetButton, saveButton); + actions.append(resetButton, runButton); + const progressBar = createProgressBar(); + const progressText = createText(""); + const stepList = el("div", { className: "e02-run__steps" }); + const cancelButton = createButton({ + label: t("E02_Run_Cancel"), + variant: "danger-ghost", + size: "sm", + onClick: () => void cancelChain(), + }); + const runBox = el("div", { className: "e02-run" }); + runBox.append(progressBar.root, progressText, stepList, cancelButton); + runBox.hidden = true; const panel = el("div", { className: "ui-sidebar-panel e02-panel" }); - panel.append(hook(createText(t("E02_Note")), "e02-hint"), status, actions); + panel.append( + hook(createText(t("E02_Note")), "e02-hint"), + hook(createText(t("E02_Run_Hint")), "e02-hint"), + status, + runBox, + actions, + ); mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel }); if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty")); let view: E02View | null = null; let refreshStations = (): void => {}; let dirty = false; + let running = false; let missing: number[] = []; + const paintButtons = (): void => { + runButton.disabled = resetButton.disabled = !view?.editable || running; + }; + + const paintRun = (p: RunProgress): void => { + running = p.running; + runBox.hidden = !p.running && p.steps.every((s) => s.state === "wait"); + progressBar.set(p.percent / 100, `${p.percent}%`); + progressText.textContent = p.running ? `${t("E02_Run_Running")} · ${p.message ?? ""}` : ""; + stepList.replaceChildren( + ...p.steps.map((s) => + createListRow({ + label: t(`E02_Run_Step_${s.key}` as LocaleKey), + trailing: [ + t(`E02_Run_State_${s.state}` as LocaleKey), + s.seconds !== null && s.state !== "wait" ? `${s.seconds} s` : "", + ] + .filter(Boolean) + .join(" · "), + stripe: STEP_TONE[s.state] ?? "transparent", + active: s.state === "run", + size: 28, + }), + ), + ); + cancelButton.hidden = !p.running; + paintButtons(); + }; + + /** 체인이 끝날 때까지 — 다른 페이지로 가면 멈춤(체인은 서버에서 계속 · 다시 오면 이어 봄) */ + async function poll(): Promise { + while (root.isConnected) { + const p = await request(`/projects/${projectId}/stab/run/progress`).catch( + () => null, + ); + if (!p) return; + paintRun(p); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else if (p.cancelled) showToast(t("E02_Run_Cancelled"), "info"); + else showToast(t("E02_Run_Done"), "success"); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + const paintStatus = (errors: CheckError[]): void => { const lines: string[] = []; if (view && !view.editable) lines.push(t("E02_ReadOnly")); @@ -158,12 +253,13 @@ export function renderE02StabGround(root: HTMLElement): void { view.ground = ground; view.defaults = body.defaults; view.editable = body.editable ?? false; - saveButton.disabled = resetButton.disabled = !view.editable; + paintButtons(); render(); }; - async function save(): Promise { - if (!view?.editable) return; + /** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */ + async function runChain(): Promise { + if (!view?.editable || running) return; const errors = mark(); if (errors.length) { showToast( @@ -174,16 +270,30 @@ export function renderE02StabGround(root: HTMLElement): void { } showLoadingOverlay(); try { - const body = await request(`/projects/${projectId}/stab/ground`, { - method: "PUT", + const body = await request(`/projects/${projectId}/stab/run`, { + method: "POST", body: JSON.stringify({ ground: view.ground }), }); - adopt({ ...body, defaults: view.defaults, editable: view.editable }, false); - showToast(t("E02_Saved"), "success"); + adopt({ ground: body.ground, defaults: view.defaults, editable: view.editable }, false); + paintRun(body); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + return; + } finally { + hideLoadingOverlay(); + } + void poll(); + } + + async function cancelChain(): Promise { + cancelButton.disabled = true; + try { + await request(`/projects/${projectId}/stab/run/cancel`, { method: "POST" }); + showToast(t("E02_Run_Stopping"), "info"); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } finally { - hideLoadingOverlay(); + cancelButton.disabled = false; } } @@ -246,6 +356,9 @@ export function renderE02StabGround(root: HTMLElement): void { }, }; adopt(body, true); + const p = await request(`/projects/${projectId}/stab/run/progress`); + paintRun(p); + if (p.running) void poll(); } catch (error) { main.replaceChildren( hook( diff --git a/E02_StabGround/E02_StabGround_UI_Style.css b/E02_StabGround/E02_StabGround_UI_Style.css index bade5d2fd..0bf50e4be 100644 --- a/E02_StabGround/E02_StabGround_UI_Style.css +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -121,3 +121,20 @@ gap: 2px; align-items: center; } + +/* [해석 시작] 진행 — 막대 · 단계 줄 · [끊기] */ +.e02-run { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e02-run[hidden] { + display: none; +} + +.e02-run__steps { + display: flex; + flex-direction: column; +} diff --git a/ui_template/ui_template_locale_e.ts b/ui_template/ui_template_locale_e.ts index d262b9d94..b05652da1 100644 --- a/ui_template/ui_template_locale_e.ts +++ b/ui_template/ui_template_locale_e.ts @@ -114,7 +114,10 @@ export const ui_locales_e = { E02_Col_Points: ["점 (offset · 깊이)", "Points (offset · depth)"], /* --- 상태 · 알림 --- */ - E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E02_Unsaved: [ + "해석 전 — 바뀐 값은 [해석 시작] 때 저장됩니다.", + "Not analyzed — changes are saved on [Run analysis].", + ], E02_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E02_Saved: ["저장했습니다.", "Saved."], E02_ResetDone: ["초기값으로 되돌렸습니다.", "Restored to initial values."], @@ -127,6 +130,35 @@ export const ui_locales_e = { E02_UserLayer: ["사용자 지층 — 출처 없음", "User layer — no source"], E02_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + /* --- [해석 시작] 체인(PLAN 564-3) --- */ + E02_Run: ["해석 시작", "Run analysis"], + E02_Run_Hint: [ + "[해석 시작] = 지반 값 저장 → 2D · 3D(모든 구간 · 건기 · 우기) · 암반 · 광역 · 보고서를 차례로 셈. 입력이 같은 단계는 건너뜀 · 1.2 km 약 25~30 분 · 다른 페이지로 가도 계속 돎.", + "[Run analysis] = save ground values → 2D · 3D (all sections · dry · wet) · rock · area · report in turn. Unchanged steps are skipped · about 25–30 min per 1.2 km · keeps running on other pages.", + ], + E02_Run_Running: ["해석 중", "Running"], + E02_Run_Cancel: ["끊기", "Stop"], + E02_Run_Stopping: [ + "끊는 중 — 지금 조각이 끝나면 멈춥니다.", + "Stopping — halts after the current piece.", + ], + E02_Run_Done: ["해석을 마쳤습니다.", "Analysis finished."], + E02_Run_Cancelled: [ + "해석을 끊었습니다 — 끝난 단계는 저장됨.", + "Analysis stopped — finished steps are saved.", + ], + E02_Run_Step_2d: ["2D 측점 해석", "2D station analysis"], + E02_Run_Step_3d: ["3D 구간 해석", "3D section analysis"], + E02_Run_Step_rock: ["암반 해석", "Rock analysis"], + E02_Run_Step_area: ["광역 위험", "Area hazard"], + E02_Run_Step_report: ["보고서", "Report"], + E02_Run_State_wait: ["기다림", "Waiting"], + E02_Run_State_run: ["도는 중", "Running"], + E02_Run_State_done: ["끝남", "Done"], + E02_Run_State_skip: ["최신 · 건너뜀", "Up to date · skipped"], + E02_Run_State_error: ["실패", "Failed"], + E02_Run_State_cancel: ["끊음", "Stopped"], + /* --- 입력 검사(코드 = 파이썬 check_ground 와 같은 낱말) --- */ E02_Err_number: ["숫자를 넣으세요", "Enter a number"], E02_Err_range: ["범위 밖", "Out of range"], From cf7db35a5457d1fdb3f219a10dddb6461c829c65 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 20:09:14 +0900 Subject: [PATCH 8/8] =?UTF-8?q?test(564-1):=20=EC=B2=B4=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=203D=20=EC=97=B4=EC=87=A0=20=3D=20564-2=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20section=5Fkey(=EA=B0=80=EC=A7=9C=20?= =?UTF-8?q?=EB=BA=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_564_1_e02_chain.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/resources/tester/test_564_1_e02_chain.py b/resources/tester/test_564_1_e02_chain.py index 3f97b87e1..edeb43f8f 100644 --- a/resources/tester/test_564_1_e02_chain.py +++ b/resources/tester/test_564_1_e02_chain.py @@ -121,8 +121,6 @@ def fake_3d(tmp_path, monkeypatch): 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 )