From d02f7cf6e28514f8bbc46739f9c6c9966333e47b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 14:24:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=EC=97=90=EC=84=9C=20=EB=91=90=20?= =?UTF-8?q?=EC=A0=90=20=EC=82=AC=EC=9D=B4=20=EA=B1=B0=EB=A6=AC=C2=B7?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shift+클릭으로 노선 위 두 점을 찍으면 구간 길이와 종단기울기 표기 (계획서 0-9 ⑤) - 지반고 통로 `POST /route/elevations` 추가 — 종·횡단과 같은 sampler 를 읽기만 함 - 찍는 순간에만 서버를 부름, 끄는 동안에는 안 부름 - 700줄 규정에 맞춰 구간 재기와 [확인] 처리를 `_Measure.ts`·`_Apply.ts` 로 분리 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX --- B05_Profile/B05_Profile_Api_Replan.ts | 16 +++ B05_Profile/B05_Profile_Router_Terrain.py | 94 ++++++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit.ts | 82 ++++++-------- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 81 ++++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 47 ++++++++ .../B05_Profile_UI_RouteEdit_Measure.ts | 104 ++++++++++++++++++ .../B05_Profile_UI_RouteEdit_Render.ts | 65 +++++++++++ main.py | 4 +- 8 files changed, 441 insertions(+), 52 deletions(-) create mode 100644 B05_Profile/B05_Profile_Router_Terrain.py create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 5a51e581..5581a8e1 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -132,6 +132,22 @@ export async function replanRoute( ); } +/** 점 묶음의 **지반고**를 묻는다(계획서 0-9 ⑤·⑧). + * + * 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안 + * 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. */ +export async function fetchRouteElevations( + projectId: string, + points: Array<[number, number]>, +): Promise> { + const payload = await requestJson<{ z: Array }>( + `/projects/${projectId}/route/elevations`, + { method: "POST", body: JSON.stringify({ points }) }, + 60000, + ); + return payload.z; +} + /** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ export async function resetRoutePlan(projectId: string): Promise { return requestJson( diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py new file mode 100644 index 00000000..31e1b353 --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -0,0 +1,94 @@ +"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로. + +편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어 +**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가 +있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지 +않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다. + +표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다. +두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다. + + POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 +""" + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +import numpy as np +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_surface_confirmation import get_surface_confirmation_params +from common_util.common_util_surface_sampler import build_surface_sampler +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"]) + +_MODELS_SUBDIR = Path("B04_PreProcess") / "models" +#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 — +#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다. +MAX_POINTS = 4000 + + +class ElevationRequest(BaseModel): + """사업지 좌표계(m) 점 묶음 [[x, y], …].""" + + points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS) + + +def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]): + """확정 지표면에서 표고를 읽는다. 모델을 못 열면 None.""" + try: + sampler = build_surface_sampler( + project_root / _MODELS_SUBDIR, + str(params["source_filter"]), + str(params["method"]), + bool(params["smooth"]), + ) + except (FileNotFoundError, KeyError, OSError, ValueError) as exc: + logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc) + return None + z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64)) + return z, valid + + +@router.post("/{project_id}/route/elevations", response_model=None) +async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse: + """점 묶음의 지반고(m)와 유효 여부를 돌려준다. + + 지표면 밖이거나 자료가 없는 자리는 `valid=false` 로 나가고 표고는 `null` 이다 — + **임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 그 자리를 「모름」으로 낸다. + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + if not stored: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, + ) + params = await get_surface_confirmation_params(connection, str(project_id)) + + project_root = Path(resolve_stored_project_path(stored)) + sampled = await asyncio.to_thread(_sample, project_root, params, request.points) + if sampled is None: + return JSONResponse( + status_code=409, + content={ + "status": "error", + "message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.", + }, + ) + z, valid = sampled + return { + "status": "success", + "project_id": str(project_id), + "z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)], + "valid": [bool(ok) for ok in valid], + } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 350a9760..57322c7b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -25,11 +25,11 @@ import { } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; import { showToast } from "@ui/ui_template_elements"; import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; -import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; +import { fetchRoutePlan } from "./B05_Profile_Api_Replan"; +import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; import { buildEditedPolyline, dragHandleTo as curveDragTo, @@ -43,6 +43,7 @@ import { nodeAtScreen, segmentAtScreen, } from "./B05_Profile_UI_RouteEdit_Input"; +import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; import { centerDirectionOf, createCurveLabel, @@ -99,7 +100,8 @@ export async function openRouteEditModal( 계획노선 편집 노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 · - 노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대 + 노드 오른쪽 클릭 = 삭제 · Shift+클릭 = 두 점 사이 거리·기울기 · + 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대 @@ -160,6 +162,19 @@ export async function openRouteEditModal( let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ let pickedContour = -1; + /** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */ + const measure = createMeasureTool({ + projectId, + stationIntervalM, + line: () => (plannedLine.length ? plannedLine : planned), + // 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다. + toScreen: (vertex) => toScreen(vertex), + isClosed: () => closed, + onChange: () => { + status.textContent = `${routeHead()} — ${measure.hint()}`; + draw(); + }, + }); let view: ViewState = { width: 0, height: 0, @@ -239,6 +254,7 @@ export async function openRouteEditModal( otherSheets, pickedContour, contourStepM: contourStepM(), + measure: measure.points(), expected, plannedLine, planned, @@ -450,6 +466,11 @@ export async function openRouteEditModal( const rect = canvas.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; + if (event.shiftKey) { + // 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤). + void measure.pick(px, py); + return; + } // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. @@ -588,54 +609,13 @@ export async function openRouteEditModal( draw, }); - async function runHeavy(label: string, task: () => Promise): Promise { - busy.hidden = false; - // ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번: - // 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를 - // 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2). - const message = busy.querySelector("span")!; - const started = Date.now(); - const tick = (): void => { - const seconds = Math.round((Date.now() - started) / 1000); - message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`; - }; - tick(); - const timer = window.setInterval(tick, 1000); - try { - await task(); - // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). - clearDrafts(projectId); - clearResults(projectId); - showToast("노선을 다시 계산했습니다.", "success"); - close(); - await onApplied(); - } catch (error) { - busy.hidden = true; - showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error"); - } finally { - window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다 - } - } - - overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => { - if (planned.length < 2) { - showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); - return; - } - void runHeavy("계획노선 반영", () => - replanRoute( - projectId, - planned.map(([x, y], index) => ({ - x, - y, - curve: curveOn[index] !== false, - radius_m: curveRadius[index] ?? null, - })), - ), - ); - }); - overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { - void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); + bindRouteApply({ + overlay, + busy, + projectId, + nodes: () => ({ planned, curveOn, curveRadius }), + close, + onApplied, }); // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts new file mode 100644 index 00000000..853cb56d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -0,0 +1,81 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Apply.ts + * 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시. + * + * 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는 + * 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이 + * 멈춘 것인지 도는 것인지 알 수 있게 한다. + * ========================================================================== */ + +import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; +import { showToast } from "@ui/ui_template_elements"; +import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; + +type Vertex = [number, number]; + +export interface RouteApplyParams { + overlay: HTMLElement; + /** 화면 전체를 덮는 대기 막. 안에 `` 한 개가 글을 받는다. */ + busy: HTMLElement; + projectId: string; + /** 지금 편집값 — 누른 순간에 읽는다. */ + nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; + /** 성공하면 모달을 닫고 화면을 다시 읽는다. */ + close: () => void; + onApplied: () => void | Promise; +} + +/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */ +export function bindRouteApply(params: RouteApplyParams): void { + const { overlay, busy, projectId } = params; + + async function runHeavy(label: string, task: () => Promise): Promise { + busy.hidden = false; + // ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번: + // 87.3 · 90.0 · 93.9 · 95.4초). + const message = busy.querySelector("span")!; + const started = Date.now(); + const tick = (): void => { + const seconds = Math.round((Date.now() - started) / 1000); + message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`; + }; + tick(); + const timer = window.setInterval(tick, 1000); + try { + await task(); + // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). + clearDrafts(projectId); + clearResults(projectId); + showToast("노선을 다시 계산했습니다.", "success"); + params.close(); + await params.onApplied(); + } catch (error) { + busy.hidden = true; + showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error"); + } finally { + window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다 + } + } + + overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => { + const { planned, curveOn, curveRadius } = params.nodes(); + if (planned.length < 2) { + showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); + return; + } + void runHeavy("계획노선 반영", () => + replanRoute( + projectId, + planned.map(([x, y], index) => ({ + x, + y, + curve: curveOn[index] !== false, + radius_m: curveRadius[index] ?? null, + })), + ), + ); + }); + overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { + void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); + }); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 89555c43..f7f557bf 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -181,6 +181,53 @@ export function segmentAtScreen( return best; } +/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */ +export interface RoutePointHit { + /** 사업지 좌표(m). */ + point: [number, number]; + /** 시점에서 노선을 따라간 거리(m). */ + chainageM: number; +} + +/** + * 노선(그려지는 폴리라인) 위에서 **클릭에 가장 가까운 점**과 그 누가거리. 멀면 null. + * + * 직선·곡선을 가리지 않는다(계획서 0-9 ⑤) — 원호도 이미 정점으로 펴져 있어 같은 선분 훑기로 + * 잡힌다. 누가거리는 선분 길이를 누적해 구하므로 노선 길이 표기와 같은 값을 본다. + */ +export function routePointAtScreen( + line: Array<[number, number]>, + toScreen: ScreenOf, + px: number, + py: number, + maxPx: number, +): RoutePointHit | null { + let best: RoutePointHit | null = null; + let bestDistance = maxPx; + let travelled = 0; + for (let index = 0; index < line.length - 1; index += 1) { + const from = line[index]; + const to = line[index + 1]; + const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]); + const [ax, ay] = toScreen(from); + const [bx, by] = toScreen(to); + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); + const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); + if (distance < bestDistance) { + bestDistance = distance; + best = { + point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t], + chainageM: travelled + segmentM * t, + }; + } + travelled += segmentM; + } + return best; +} + /** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. * * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts new file mode 100644 index 00000000..089611ad --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -0,0 +1,104 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Measure.ts + * 계획노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤). + * + * Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든 + * 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. + * + * 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는 + * 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를 + * 끄는 동안에는 한 번도 부르지 않는다. + * ========================================================================== */ + +import { fetchRouteElevations } from "./B05_Profile_Api_Replan"; +import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input"; +import { formatStation } from "./B05_Profile_Util_Station"; + +type Vertex = [number, number]; + +/** 구간 재기로 노선을 짚었다고 볼 거리(px). */ +const MEASURE_HIT_PX = 14; + +interface MeasurePoint extends RoutePointHit { + /** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */ + z: number | null; +} + +export interface MeasureToolParams { + projectId: string; + /** 규칙 측점 간격(m) — 측점 표기에 쓴다. */ + stationIntervalM: number; + /** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */ + line: () => Vertex[]; + toScreen: (vertex: Vertex) => [number, number]; + /** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */ + isClosed: () => boolean; + /** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */ + onChange: () => void; +} + +export interface MeasureTool { + /** 찍힌 자리(0~2개) — 그리기가 쓴다. */ + points: () => Vertex[]; + /** 상태줄에 낼 한 줄. */ + hint: () => string; + /** Shift+클릭 한 번. 두 점이 차면 지반고를 한 번만 물어 온다. */ + pick: (px: number, py: number) => Promise; +} + +export function createMeasureTool(params: MeasureToolParams): MeasureTool { + /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ + let picked: MeasurePoint[] = []; + + const hint = (): string => { + if (picked.length === 0) return "Shift+클릭으로 두 점을 찍으면 거리와 기울기가 보입니다."; + const first = picked[0]; + if (picked.length === 1) { + return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`; + } + const second = picked[1]; + const span = Math.abs(second.chainageM - first.chainageM); + const head = + `구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` + + `${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`; + if (first.z === null || second.z === null || span <= 1e-6) { + return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`; + } + // 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다. + const rise = second.z - first.z; + return ( + `${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` + + ` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%` + ); + }; + + return { + points: () => picked.map((entry) => entry.point), + hint, + async pick(px, py) { + const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX); + if (!hit) { + picked = []; // 노선을 빗나가면 재던 것을 접는다. + params.onChange(); + return; + } + picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; + params.onChange(); + if (picked.length < 2) return; + const asked = picked; + try { + const heights = await fetchRouteElevations( + params.projectId, + asked.map((entry) => entry.point), + ); + if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다. + asked.forEach((entry, index) => { + entry.z = heights[index] ?? null; + }); + } catch { + /* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */ + } + params.onChange(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index afddf7a4..b3175d89 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -36,6 +36,8 @@ const CONTOUR_BAND_M = 300; const CURVE_HANDLE_PX = 5; /** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */ const OUTWARD_PX = 26; +/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */ +const MEASURE_COLOR = "#22c55e"; /** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */ export function polylineLengthM(points: ReadonlyArray): number { @@ -77,6 +79,8 @@ export interface RouteEditScene { picked: number; /** 규칙 측점 간격(m). */ stationIntervalM: number; + /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리(계획서 0-9 ⑤). */ + measure: ReadonlyArray; } export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void { @@ -201,6 +205,67 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.restore(); drawStationMarks(context, scene, line); + drawMeasureMarks(context, scene, line); +} + +/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */ +function drawMeasureMarks( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, +): void { + if (scene.measure.length === 0) return; + context.save(); + if (scene.measure.length >= 2) { + const span = spanBetween(line, scene.measure[0], scene.measure[1]); + if (span.length >= 2) { + context.strokeStyle = MEASURE_COLOR; + context.lineWidth = 4; + context.beginPath(); + span.forEach((vertex, index) => { + const [x, y] = scene.toScreen(vertex); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + } + } + context.lineWidth = 2.4; + context.strokeStyle = MEASURE_COLOR; + context.font = "bold 11px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + scene.measure.forEach((vertex, index) => { + const [x, y] = scene.toScreen(vertex); + context.fillStyle = "rgba(255,255,255,0.95)"; + context.beginPath(); + context.arc(x, y, 7, 0, Math.PI * 2); + context.fill(); + context.stroke(); + context.fillStyle = "#14532d"; + context.fillText(index === 0 ? "a" : "b", x, y); + }); + context.restore(); +} + +/** 두 점 사이의 노선 조각 — 가장 가까운 정점부터 정점까지. 어디를 쟀는지 보이기만 하면 된다. */ +function spanBetween(line: ReadonlyArray, from: Vertex, to: Vertex): Vertex[] { + const nearest = (target: Vertex): number => { + let best = 0; + let bestDistance = Infinity; + line.forEach((vertex, index) => { + const distance = Math.hypot(vertex[0] - target[0], vertex[1] - target[1]); + if (distance < bestDistance) { + bestDistance = distance; + best = index; + } + }); + return best; + }; + const start = nearest(from); + const end = nearest(to); + const [low, high] = start <= end ? [start, end] : [end, start]; + return [from, ...line.slice(low, high + 1), to]; } /** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */ diff --git a/main.py b/main.py index a3967f44..30bb8b3b 100644 --- a/main.py +++ b/main.py @@ -48,15 +48,16 @@ from B05_Profile.B05_Profile_Router import router as b05_route_router from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router +from B05_Profile.B05_Profile_Router_Terrain import router as b05_route_terrain_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router -from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router from B06_Section.B06_Section_Router_Confirm import ( router as b06_section_confirm_router, ) from B06_Section.B06_Section_Router_HaulPlan import ( router as b06_section_haul_plan_router, ) +from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_Standard import router as b07_standard_router @@ -537,6 +538,7 @@ app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) +app.include_router(b05_route_terrain_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b06_section_stations_router, dependencies=protected_with_company)