From b076e006a76349987e7fc9f89684f6a4c884897e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 14:09:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=B3=A1=EC=84=A0=20R=C2=B7L=20=ED=95=98=ED=95=9C?= =?UTF-8?q?=EC=9D=84=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=A7=89?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 임도 종류별 하한 표를 config 에 둠, 작업임도는 규정이 없어 0(제한 없음) - 기본 반지름과 하한을 갈라 둠 — 한 값이면 하한 0 이 반지름 0 이 되어 곡선이 안 그려짐 - `/route/plan` 이 `limit_radius_m`·`limit_curve_length_m` 를 함께 내림 - 반지름 칸·곡선 길이 칸·손잡이 끌기가 하한에서 멈추고, 노드 이동은 그 걸음을 되돌림 - 이미 하한을 밑돌던 자리는 그대로 두고 지키던 자리가 넘어가는 것만 막음 - 시험 `resources/tester/test_plan_curve_limits.py` 추가 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX --- B05_Profile/B05_Profile_Api_Replan.ts | 6 +- B05_Profile/B05_Profile_Engine_Grade.py | 31 +++++++++ B05_Profile/B05_Profile_Router_Replan.py | 37 ++++++++-- B05_Profile/B05_Profile_UI_RouteEdit.ts | 29 +++++++- B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts | 69 +++++++++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 34 +++++++-- config/config_system_design.py | 25 +++++++ resources/tester/test_plan_curve_limits.py | 45 ++++++++++++ 8 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 resources/tester/test_plan_curve_limits.py diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 4f6c4539..5a51e581 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -56,8 +56,12 @@ export interface RoutePlanResponse { nodes: RoutePlanNode[]; /** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */ curves: RoutePlanCurve[]; - /** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */ + /** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */ min_radius_m: number; + /** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */ + limit_radius_m?: number; + /** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */ + limit_curve_length_m?: number; curve_count: number; violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index e250d8f4..706bc263 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal") return float(speeds[terrain]) +def plan_radius_limit_m( + grade_class: str, + design_speed_kph: int | None = None, + terrain_type: str = "normal", +) -> float: + """계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음. + + 위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다 + (2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None) + 법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에 + 곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다. + """ + table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"] + override = table.get(grade_class) + if override is not None: + return float(override) + return legal_plan_radius_min_m( + resolve_design_speed(grade_class, design_speed_kph), terrain_type + ) + + +def plan_curve_length_limit_m(grade_class: str) -> float: + """평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음. + + 법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라 + 실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정). + """ + table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"] + return float(table.get(grade_class) or 0.0) + + def _pick(*candidates: Any) -> Any: """요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다.""" for value in candidates: diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index cfbe16d5..50bc3103 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str: async def _min_plan_radius_m(project_id: UUID) -> float: - """이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다. + """기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것.""" + criteria = await _plan_criteria(project_id) + return criteria[0] + + +async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]: + """이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값. + + 기본 반지름은 곡선을 만들 때 쓰는 값이고, 하한 둘은 **화면이 못 넘게 막는** 값이다 + (2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 곡선이 + 아예 안 그려지므로 반드시 갈라 둔다. + + 기본 반지름은 임도 종류·설계속도·지형으로 고른다. 값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 산식은 이미 `B05_Profile_Engine_Grade.legal_plan_radius_min_m` 에 있다 — 여기서 다시 짜지 않는다. @@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float: """ import aiomysql - from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed + from B05_Profile.B05_Profile_Engine_Grade import ( + legal_plan_radius_min_m, + plan_curve_length_limit_m, + plan_radius_limit_m, + resolve_design_speed, + ) from common_util.common_util_workflow_state import get_workflow_state grade_class, design_speed, terrain = "work", None, "special" @@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float: terrain = str(params["terrain_type"]) except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다 logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id) - return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain) + return ( + legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain), + plan_radius_limit_m(grade_class, design_speed, terrain), + plan_curve_length_limit_m(grade_class), + ) def _nodes_path(path: Path) -> Path: @@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: if not expected: expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root)) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root)) initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root)) @@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: # (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다. "curves": saved_curves or [curve.as_dict() for curve in outline.curves], "min_radius_m": round(radius_m, 2), + # **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음 + # (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정). + "limit_radius_m": round(radius_limit_m, 2), + "limit_curve_length_m": round(arc_limit_m, 2), "curve_count": len(saved_curves) if saved_curves else outline.curve_count, "violation_count": outline.violation_count, "edited": bool(working), @@ -468,7 +493,7 @@ async def replan_route( # 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다. await asyncio.to_thread(_ensure_expected_route, project_root) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) # 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다. # 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자). @@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) project_root, stored_path = paths await asyncio.to_thread(_ensure_expected_route, project_root) - radius_m = await _min_plan_radius_m(project_id) + radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id) await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) working_path = planned_route_working_path(project_root) if working_path.is_file(): diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 444833cf..350a9760 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -50,8 +50,11 @@ import { } from "./B05_Profile_UI_RouteEdit_Label"; import { applyArcLocks, + applyCurveLimits, + curveShortfalls, curveSummary, flattenServerPlan, + shortfallCrossed, type CurveLock, } from "./B05_Profile_UI_RouteEdit_Edits"; import { @@ -134,6 +137,9 @@ export async function openRouteEditModal( /** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */ let nodeInfo: EditedNode[] = []; let minRadiusM = 0; + /** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */ + let limitRadiusM = 0; + let limitArcM = 0; /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ let curveInfo: EditedCurve[] = []; /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ @@ -284,6 +290,8 @@ export async function openRouteEditModal( function markEdited(): void { // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. applyArcLocks(planned, curveLock, curveArc, curveRadius); + // 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④). + applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM); const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); plannedLine = built.vertices; curveInfo = built.curves; @@ -394,6 +402,8 @@ export async function openRouteEditModal( arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, lock: curveLock[picked] ?? null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + limitRadiusM, + limitArcM, }); } @@ -477,7 +487,8 @@ export async function openRouteEditModal( const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { planned[node] = moved.apex; - curveRadius[node] = Math.round(moved.radius * 100) / 100; + // 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④). + curveRadius[node] = Math.max(limitRadiusM, Math.round(moved.radius * 100) / 100); curveOn[node] = true; picked = node; // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 @@ -491,9 +502,23 @@ export async function openRouteEditModal( return; } if (dragNode >= 0) { + // 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로 + // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). + const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM); + const previous = planned[dragNode]; dragMoved = true; planned[dragNode] = toMetric(px, py); markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) { + // 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다. + planned[dragNode] = previous; + markEdited(); + status.textContent = + `${routeHead()} — 하한에 걸려 더 못 옮깁니다` + + `(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`; + draw(); + return; + } // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`; @@ -626,6 +651,8 @@ export async function openRouteEditModal( // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). const nodes = plan.nodes ?? []; minRadiusM = plan.min_radius_m ?? 0; + limitRadiusM = plan.limit_radius_m ?? 0; + limitArcM = plan.limit_curve_length_m ?? 0; // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); planned = flat.planned; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts index a21e6987..55dcd2db 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts @@ -48,6 +48,75 @@ export function applyArcLocks( } } +/** + * **하한을 지키도록 지정 반지름을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정). + * + * L = R·Δ 이므로 「곡선 길이 하한」은 그 자리에서 「반지름 하한 L/Δ」과 같은 말이다. 두 하한 + * 중 큰 쪽으로 올린다. **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을 + * 쓰고 있고, 여기서 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다. + */ +export function applyCurveLimits( + planned: Vertex[], + curveOn: ReadonlyArray, + curveRadius: Array, + limitRadiusM: number, + limitArcM: number, +): void { + if (limitRadiusM <= 0 && limitArcM <= 0) return; + for (let seat = 1; seat < planned.length - 1; seat += 1) { + if (curveOn[seat] === false) continue; + const current = curveRadius[seat]; + if (current === null || current === undefined) continue; + const deflection = deflectionRad( + innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]), + ); + const byArc = limitArcM > 0 && deflection > 1e-9 ? limitArcM / deflection : 0; + const floor = Math.max(limitRadiusM, byArc); + if (floor > 0 && current < floor) curveRadius[seat] = floor; + } +} + +/** + * 노드마다 하한을 **얼마나 밑돌고 있나**(m). 다 지키고 있으면 0. + * + * 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면 + * 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미 + * 하한을 밑돌던 옛 노선도 그대로 고칠 수 있어야 하기 때문이다(2026-09-12). + * + * ⚠ 가장 큰 값 하나로 견주면 안 된다. 크게 밑도는 자리가 이미 있으면 **다른 자리가 새로 + * 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로 + * 지나갔다). 자리마다 따로 견준다. + */ +export function curveShortfalls( + nodes: ReadonlyArray, + limitRadiusM: number, + limitArcM: number, +): number[] { + return nodes.map((node) => { + if (node.radius_m === null || (limitRadiusM <= 0 && limitArcM <= 0)) return 0; + let short = 0; + if (limitRadiusM > 0) short = Math.max(short, limitRadiusM - node.radius_m); + if (limitArcM > 0) { + short = Math.max(short, limitArcM - node.radius_m * deflectionRad(node.inner_angle_deg)); + } + return Math.max(0, short); + }); +} + +/** + * 하한을 지키던 자리가 **이번 걸음에 처음으로 무너졌나**. 자리 수가 달라지면(넣기·지우기) + * 안 따진다. + * + * ⚠ 「조금이라도 나빠졌으면 막기」로 두면 **이미 하한을 밑돌던 옛 노선을 아예 못 고친다** — + * 그 옆 노드를 1px 만 건드려도 밑돌던 값이 미세하게 더 내려가 첫 걸음부터 막혔다(2026-09-12 + * 실화면). 이미 무너진 자리는 그대로 두고(붉은 표시는 남는다), **지키고 있던 자리가 넘어가는 + * 것만** 막는다. + */ +export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean { + if (before.length !== after.length) return false; + return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6); +} + export interface CurveSummaryInput { nodeCount: number; curveOn: boolean[]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index 89f3f290..c6388f8d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -71,6 +71,9 @@ export interface CurveLabelState { arcLengthShown: number | null; lock: CurveLock; innerAngleDeg: number | null; + /** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ + limitRadiusM?: number; + limitArcM?: number; } export interface CurveLabelHandlers { @@ -148,15 +151,25 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ let manual: [number, number] | null = null; let anchor: [number, number] = [0, 0]; + /** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */ + let limitRadius = 0; + let limitArc = 0; /** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */ let limit: CurveLabelState["bounds"]; - const numberOf = (input: HTMLInputElement): number | null => { + /** 칸에서 읽은 값. **하한 아래는 하한에서 멈추고, 멈춘 값을 칸에 되적어 보인다** + * (2026-09-12 사용자 확정 「아예 못 넘게 막음」) — 조용히 바꾸면 왜 안 먹었는지 모른다. */ + const numberOf = (input: HTMLInputElement, floor: number): number | null => { const value = Number(input.value); - return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null; + if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null; + if (floor > 0 && value < floor) { + input.value = String(floor); + return floor; + } + return value; }; - radius.addEventListener("change", () => handlers.onRadius(numberOf(radius))); - arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc))); + radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius))); + arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc))); toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); @@ -242,6 +255,11 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { curveOn = state.curveOn; lock = state.lock; limit = state.bounds; + limitRadius = state.limitRadiusM ?? 0; + limitArc = state.limitArcM ?? 0; + // 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다. + radius.min = limitRadius > 0 ? String(limitRadius) : "1"; + arc.min = limitArc > 0 ? String(limitArc) : "1"; root.hidden = false; seatText.textContent = `${state.seat + 1}번째 꺾임점`; toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; @@ -258,8 +276,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { const inner = state.innerAngleDeg; const held = lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음"; + const floors = [ + limitRadius > 0 ? `R ≥ ${limitRadius}m` : "", + limitArc > 0 ? `L ≥ ${limitArc}m` : "", + ] + .filter(Boolean) + .join(" · "); info.textContent = state.curveOn - ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}${floors ? ` · ${floors}` : ""}` : "곡선 없음 — 직선이 그대로 꺾입니다"; place(state); // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. diff --git a/config/config_system_design.py b/config/config_system_design.py index 201716b1..1cc5dc79 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -496,6 +496,31 @@ FOREST_ROAD_PROFILE_CRITERIA = { # 배향곡선(Hair Pin) 중심선 반지름 하한(m, 별표2 Ⅰ.2.다.(2)). 이보다 급하면 **경고만** # 낸다 — 자동 보정·차단은 하지 않는다(2026-09-06 사용자 확정). "hairpin_min_radius_m": 10.0, + # 임도 종류별 **못 넘는 하한**(m) — 계획노선 편집 화면이 값을 막는 기준이다 + # (2026-09-12 사용자 확정). 위 `min_plan_radius_m` 은 **기본값·위반 표시 기준**이고 + # 여기는 **제한**이라 서로 다르다. 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 + # 곡선이 아예 안 그려진다. + # · None = 위 표(설계속도 × 지형)를 그대로 하한으로 쓴다. + # · 0.0 = 제한 없음. 작업임도는 별표2에 곡선반지름 규정이 없어 열어 둔다 — + # 값이 정해지면 **이 칸만** 고치면 서버·화면이 함께 따라간다. + # ⚠ `projects.road_type` 은 main|fire|work 로 들어온다(B02 스키마) — 계획선 등급 코드 + # trunk 와 같은 뜻이라 둘 다 적어 둔다. 없는 키는 None 과 같게(법정 표) 다뤄진다. + "plan_radius_limit_by_grade_m": { + "main": None, + "trunk": None, + "fire": None, + "work": 0.0, + "branch": None, + }, + # 평면 **곡선 길이(L)** 하한(m). 법령·교본에 값이 없어 지금은 전부 0(제한 없음)이다. + # 자리만 만들어 두고, 실무값이 정해지면 여기에 적는다(2026-09-12 사용자 확정). + "plan_curve_length_limit_by_grade_m": { + "main": 0.0, + "trunk": 0.0, + "fire": 0.0, + "work": 0.0, + "branch": 0.0, + }, # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 diff --git a/resources/tester/test_plan_curve_limits.py b/resources/tester/test_plan_curve_limits.py new file mode 100644 index 00000000..09eb0db1 --- /dev/null +++ b/resources/tester/test_plan_curve_limits.py @@ -0,0 +1,45 @@ +"""계획노선 곡선 **하한**(반지름·곡선 길이) 해석 시험. + +기본값(`legal_plan_radius_min_m`)과 **못 넘는 하한**(`plan_radius_limit_m`)은 다른 값이다 +(2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 작업임도의 하한 0 이 곧 반지름 0 이 되어 +곡선이 아예 안 그려지므로, 갈라져 있다는 것 자체를 시험으로 못박는다. +""" + +from B05_Profile.B05_Profile_Engine_Grade import ( + legal_plan_radius_min_m, + plan_curve_length_limit_m, + plan_radius_limit_m, + resolve_design_speed, +) + + +def _default(grade_class: str, terrain: str) -> float: + return legal_plan_radius_min_m(resolve_design_speed(grade_class, None), terrain) + + +def test_작업임도는_하한이_없다(): + """별표2에 작업임도 곡선반지름 규정이 없어 하한을 0(제한 없음)으로 열어 둔다.""" + assert plan_radius_limit_m("work", None, "normal") == 0.0 + assert plan_radius_limit_m("work", None, "special") == 0.0 + + +def test_작업임도도_기본_반지름은_그대로다(): + """하한이 0이어도 **곡선을 만들 때 쓰는 기본값**은 살아 있어야 한다.""" + assert _default("work", "normal") > 0 + + +def test_간선_산불진화는_법정표가_곧_하한이다(): + for grade_class in ("main", "trunk", "fire"): + for terrain in ("normal", "special"): + assert plan_radius_limit_m(grade_class, None, terrain) == _default(grade_class, terrain) + + +def test_모르는_임도종류는_법정표로_떨어진다(): + """칸이 없는 값이 와도 막지 않고 법정표를 하한으로 쓴다(가장 보수적인 쪽).""" + assert plan_radius_limit_m("알 수 없는 종류", None, "normal") == _default("work", "normal") + + +def test_곡선길이_하한은_아직_전부_0이다(): + """법령·교본에 평면 곡선 길이 하한 값이 없다 — 자리만 열어 둔 칸이다.""" + for grade_class in ("main", "trunk", "fire", "work", "branch", "없는종류"): + assert plan_curve_length_limit_m(grade_class) == 0.0 From d02f7cf6e28514f8bbc46739f9c6c9966333e47b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 14:24:02 +0900 Subject: [PATCH 2/5] =?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) From bd770fc351f680f13c866eb01fcbd11688fb0629 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 14:26:40 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(b06):=20=EA=B3=A1=EC=84=A0=EB=B6=80=20?= =?UTF-8?q?=ED=99=95=ED=8F=AD=EC=9D=84=20=EC=84=A4=EA=B3=84=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=ED=91=9C=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=EC=9E=A1=EA=B3=A0=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=EC=97=90=20=EB=85=B8=EA=B2=AC=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확폭을 측점마다 앞뒤 10m 세 점의 외접원 반경으로 다시 재던 것을 계획노선 곡선표 (시·종점·설계 반경)로 바꿈. 종전에는 같은 곡선 안에서도 측점마다 확폭이 갈리고, 곡선 밖 직선까지 확폭이 흘러나가며, 측점 간격보다 짧은 곡선은 통째로 빠졌음. 곡선 안은 설계 반경의 표값 한 값, 앞뒤 10m 는 0 으로 잇고, 그 밖은 확폭 없음. 곡선표가 없는 옛 프로젝트는 종전 방식으로 물러섬. 횡단도는 차도 끝에만 눈금이 있어 노면이 넓어져도 차도가 는 것인지 노견이 는 것인지 가릴 수 없었음. 노면 끝에도 옅은 눈금을 세우고 노폭 라벨에 노견 폭을 덧붙임. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR --- B05_Profile/B05_Profile_Engine_Sections.py | 39 +++++- .../B05_Profile_Engine_Sections_Core.py | 116 +++++++++++++++++- B06_Section/B06_Section_UI_Cross_Design.ts | 39 ++++-- .../B06_Section_UI_Style_Cross_Areas.css | 8 ++ resources/tester/test_b06_curve_widening.py | 61 +++++++++ 5 files changed, 249 insertions(+), 14 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 491a76fc..2920c5b3 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -37,7 +37,11 @@ from common_util.common_util_drainage_pipes import ( route_signature, ) from common_util.common_util_json import atomic_write_json -from common_util.common_util_route_geometry import RouteVertex +from common_util.common_util_route_geometry import ( + RouteVertex, + planned_route_initial_path, + planned_route_working_path, +) from common_util.common_util_surface_sampler import build_surface_sampler from config.config_system import ( DRAINAGE_CACHE_DIRNAME, @@ -66,6 +70,34 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[ return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords] +def _load_design_curves(project_root: Path) -> list[dict[str, Any]]: + """설계가 쓰는 계획노선의 **곡선표** — 없으면 빈 목록. + + 곡선표는 폴리라인 파일 옆에 같은 이름으로 선다(`…_curves.json`). 정점 목록만으로는 + 어디부터 어디까지가 한 곡선이고 반지름이 얼마인지 알 수 없어, 곡선부 확폭을 측점마다 + 반경을 다시 재는 방식으로 매기면 같은 곡선 안에서도 값이 갈린다(2026-09-12 실측). + + 수정본(`planned_route.csv`)이 있으면 **그 곡선표만** 쓴다 — 노선을 고쳤는데 초기본 + 곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고, + 받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다. + """ + route_path = planned_route_working_path(project_root) + if not route_path.is_file(): + route_path = planned_route_initial_path(project_root) + target = route_path.with_name(f"{route_path.stem}_curves.json") + if not target.is_file(): + return [] + try: + data = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target) + return [] + curves = data.get("curves") if isinstance(data, dict) else None + return ( + [curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else [] + ) + + def cross_filename(chainage_m: float) -> str: """측점 chainage에 대응하는 횡단면 파일명(단일 규칙).""" return f"cross_{int(round(float(chainage_m))):05d}m.json" @@ -361,6 +393,9 @@ def run_section_generation( replace(options or SectionGenerationOptions(), extra_stations=extras), source_snapshot={"filter": filter_key, "method": method, "smooth": smooth}, crs=crs, + # 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라 + # 종전의 측점별 실측 반경으로 물러선다. + design_curves=_load_design_curves(project_root), ) stage_root = project_root / _STAGE_SUBDIR @@ -473,6 +508,8 @@ def generate_irregular_sections( merged_options, source_snapshot={"filter": filter_key, "method": method, "smooth": smooth}, crs=crs, + # 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다. + design_curves=_load_design_curves(project_root), ) irregular_stations = [ station diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 9c1274e9..73b67199 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -213,6 +213,101 @@ def _curve_widenings( return widenings, sides +#: 곡선표의 시·종점이 노선 폴리라인에서 이만큼 떨어져 있으면 그 노선의 곡선이 아니라고 본다(m). +#: 노선을 잘라 쓰면(지표면 밖 트림) 곡선표에 남은 옛 곡선이 노선 밖에 뜬다. +DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0 + + +def _project_chainage( + points: np.ndarray, route_chainage: np.ndarray, xy: tuple[float, float] +) -> tuple[float, float]: + """점을 노선 폴리라인 위로 내려 (떨어진 거리 m, 누가거리 m).""" + starts = points[:-1, :2] + vectors = points[1:, :2] - starts + lengths2 = np.einsum("ij,ij->i", vectors, vectors) + safe = np.where(lengths2 > 1e-12, lengths2, 1.0) + target = np.asarray(xy, dtype=np.float64) + ratios = np.clip(np.einsum("ij,ij->i", target - starts, vectors) / safe, 0.0, 1.0) + feet = starts + vectors * ratios[:, None] + distances = np.hypot(feet[:, 0] - target[0], feet[:, 1] - target[1]) + index = int(np.argmin(distances)) + span = route_chainage[index + 1] - route_chainage[index] + return float(distances[index]), float(route_chainage[index] + span * ratios[index]) + + +def _design_curve_spans( + points: np.ndarray, + route_chainage: np.ndarray, + curves: list[dict[str, Any]], +) -> list[tuple[float, float, float, str]]: + """설계 곡선표 → [(시점 누가거리, 종점 누가거리, 반지름 m, 곡선 **바깥쪽**)]. + + 바깥쪽은 회전 방향으로 가른다 — 시점→교점→종점의 외적 z가 양수면 좌회전이라 안쪽이 + 좌측이고 바깥은 우측이다(`_plan_radii` 와 같은 규약). 노선에서 멀리 떨어진 곡선과 + 방향을 못 재는 곡선은 버린다. + """ + spans: list[tuple[float, float, float, str]] = [] + for curve in curves: + start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end") + radius = curve.get("radius_m") + if not (start and apex and end) or not isinstance(radius, (int, float)): + continue + start_gap, start_chainage = _project_chainage(points, route_chainage, tuple(start[:2])) + end_gap, end_chainage = _project_chainage(points, route_chainage, tuple(end[:2])) + if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M: + continue + cross = (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * ( + end[0] - apex[0] + ) + if abs(cross) < 1e-12: + continue + spans.append( + ( + min(start_chainage, end_chainage), + max(start_chainage, end_chainage), + float(radius), + "right" if cross > 0 else "left", + ) + ) + return sorted(spans) + + +def _design_curve_widenings( + station_chainage: np.ndarray, + spans: list[tuple[float, float, float, str]], +) -> tuple[list[float | None], list[str | None], list[float]]: + """설계 곡선표로 측점별 (평면 곡선반경, 바깥쪽, 확폭량)을 낸다. + + 곡선 **안**은 그 곡선의 설계 반경이 그대로 반경이고 확폭도 표값 한 값이다. 곡선 앞뒤 + `CURVE_WIDENING_TAPER_M` 구간은 0 으로 잇고(실무 관행 직선 테이퍼), 그 밖은 확폭이 + 없다. 곡선이 겹치면 **확폭이 큰 쪽**을 따르고, 반경은 작은 쪽(급한 쪽)을 남긴다. + """ + count = len(station_chainage) + radii: list[float | None] = [None] * count + sides: list[str | None] = [None] * count + widenings: list[float] = [0.0] * count + for start, end, radius, side in spans: + table = curve_widening_m(radius) + for index, value in enumerate(station_chainage): + chainage = float(value) + inside = start - 1e-9 <= chainage <= end + 1e-9 + if inside and (radii[index] is None or radius < radii[index]): + radii[index] = round(radius, 3) + if table <= 0.0: + continue + if inside: + amount = table + else: + distance = start - chainage if chainage < start else chainage - end + if distance > CURVE_WIDENING_TAPER_M: + continue + amount = round(table * (1.0 - distance / CURVE_WIDENING_TAPER_M), 4) + if amount > widenings[index]: + widenings[index] = amount + sides[index] = side + return radii, sides, widenings + + def generate_sections( polyline: np.ndarray | list[list[float]], sampler: SurfaceElevationSampler, @@ -220,6 +315,7 @@ def generate_sections( *, source_snapshot: dict[str, Any] | None = None, crs: str | None = None, + design_curves: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.""" options = options or SectionGenerationOptions() @@ -267,11 +363,21 @@ def generate_sections( # 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반 # 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세 # 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다. - plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total) - # 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다. - plan_widenings, plan_outer_sides = _curve_widenings( - station_chainage, plan_radii, plan_outer_sides - ) + # **설계 곡선표가 있으면 그것이 정본**이다(2026-09-12 사용자 지시). 측점마다 반경을 + # 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로 + # 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다). + # 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다. + spans = _design_curve_spans(points, route_chainage, design_curves or []) + if spans: + plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings( + station_chainage, spans + ) + else: + plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total) + # 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다. + plan_widenings, plan_outer_sides = _curve_widenings( + station_chainage, plan_radii, plan_outer_sides + ) offsets = np.arange( -options.cross_half_width_m, diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 1fc693c5..7a340cb1 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -721,32 +721,55 @@ export function appendCrossDesignOverlay( } // 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안 - // 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨. + // 구분이 안 되는 경계를 표시한다. const edges = design.carriageway_edges; if (edges) { - for (const edge of [edges.left, edges.right]) { - const cx = x(edge.offset_m); - const cy = toDisplayY(edge.elevation_m); + const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => { + const cx = x(offsetM); + const cy = toDisplayY(elevationM); const tick = document.createElementNS(SVG_NS, "line"); tick.setAttribute("x1", String(cx)); - tick.setAttribute("y1", String(cy - 3.6)); + tick.setAttribute("y1", String(cy - half)); tick.setAttribute("x2", String(cx)); - tick.setAttribute("y2", String(cy + 3.6)); - tick.setAttribute("class", "b06-chart__carriageway-tick"); + tick.setAttribute("y2", String(cy + half)); + tick.setAttribute("class", className); svg.append(tick); + }; + for (const edge of [edges.left, edges.right]) { + tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6); + } + // 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어 + // **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로 + // 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다. + const roadEdges = design.road_edges; + if (roadEdges) { + for (const edge of [roadEdges.left, roadEdges.right]) { + tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4); + } } // 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다. // 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다. const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0); const standardWidth = design.carriageway_standard_width_m; + const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null; + const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null; const label = document.createElementNS(SVG_NS, "text"); label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2)); label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6)); label.setAttribute("class", "b06-chart__carriageway-label"); - label.textContent = + const widthText = widened > 0.001 && typeof standardWidth === "number" ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})` : `노폭 ${design.carriageway_width_m.toFixed(2)}m`; + // 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다. + label.textContent = + shoulderLeft != null && shoulderRight != null + ? `${widthText} · 노견 ${ + Math.abs(shoulderLeft - shoulderRight) < 0.005 + ? `${shoulderLeft.toFixed(2)}m` + : `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m` + }` + : widthText; svg.append(label); } } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 4ee815e5..480c8b27 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -152,6 +152,14 @@ stroke-width: 1.2; } +/* 노견 바깥 끝 틱(2026-09-12) — 차도 틱보다 짧고 옅다. 차도 틱과 이 틱 사이가 노견이라, + 노면이 넓어졌을 때 차도가 늘었는지 노견이 늘었는지 눈으로 바로 갈린다. */ +.b06-chart__shoulder-tick { + stroke: var(--color-royal-amethyst); + stroke-width: 1; + opacity: 0.55; +} + /* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */ .b06-chart__carriageway-label { fill: var(--color-royal-amethyst); diff --git a/resources/tester/test_b06_curve_widening.py b/resources/tester/test_b06_curve_widening.py index b2be95b6..ae6be6f0 100644 --- a/resources/tester/test_b06_curve_widening.py +++ b/resources/tester/test_b06_curve_widening.py @@ -148,3 +148,64 @@ def test_widening_without_curve_stays_zero() -> None: widenings, sides = _curve_widenings(chainage, [None] * 3, [None] * 3) assert widenings == [0.0, 0.0, 0.0] assert sides == [None, None, None] + + +def test_design_curve_spans_from_curve_table() -> None: + """설계 곡선표의 시·종점이 노선 누가거리로 바뀌고, 회전 방향으로 바깥쪽이 갈린다.""" + from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_spans + + # ㄱ자 노선 — (0,0) → (100,0) → (100,100). 교점 (100,0) 에서 좌회전. + line = np.array([[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [100.0, 100.0, 0.0]]) + chainage = np.array([0.0, 100.0, 200.0]) + curves = [ + { + "start": [90.0, 0.0], + "apex": [100.0, 0.0], + "end": [100.0, 10.0], + "radius_m": 12.0, + } + ] + spans = _design_curve_spans(line, chainage, curves) + assert len(spans) == 1 + start, end, radius, side = spans[0] + assert abs(start - 90.0) < 1e-6 and abs(end - 110.0) < 1e-6 + assert radius == 12.0 + # 좌회전이면 안쪽이 좌측이라 바깥은 우측이다. + assert side == "right" + # 노선에서 멀리 떨어진 곡선은 버린다. + assert _design_curve_spans(line, chainage, [{**curves[0], "start": [90.0, 500.0]}]) == [] + + +def test_design_curve_widening_is_one_value_inside_the_curve() -> None: + """같은 곡선 안 측점은 설계 반경의 표값 하나를 쓰고, 앞뒤 10m 만 이어 준다.""" + from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_widenings + + # 곡선 100~140m, 설계 반경 16m(표값 1.5m). 측점 5m 간격. + spans = [(100.0, 140.0, 16.0, "left")] + chainage = np.arange(80.0, 165.0, 5.0) + radii, sides, widenings = _design_curve_widenings(chainage, spans) + table = dict(zip(chainage.tolist(), widenings, strict=True)) + # 곡선 안은 어디서나 같은 값 — 종전에는 측점마다 반경을 다시 재 값이 갈렸다. + for station in (100.0, 110.0, 120.0, 130.0, 140.0): + assert table[station] == 1.5, station + # 앞뒤 10m 는 0 으로 잇는다. + assert table[95.0] == 0.75 and table[145.0] == 0.75 + assert table[90.0] == 0.0 and table[150.0] == 0.0 + # 반경은 곡선 안에서만 남고, 테이퍼·직선 자리는 비어 있다. + by_index = dict(zip(chainage.tolist(), radii, strict=True)) + assert by_index[120.0] == 16.0 + assert by_index[95.0] is None and by_index[90.0] is None + # 방향은 곡선 것을 따라간다. + assert sides[chainage.tolist().index(95.0)] == "left" + + +def test_design_curve_widening_takes_the_wider_of_overlapping_curves() -> None: + """곡선이 겹치거나 붙어 있으면 확폭이 큰 쪽을 따른다.""" + from B05_Profile.B05_Profile_Engine_Sections_Core import _design_curve_widenings + + spans = [(100.0, 120.0, 35.0, "left"), (118.0, 140.0, 12.0, "right")] + chainage = np.array([110.0, 119.0, 130.0]) + _, sides, widenings = _design_curve_widenings(chainage, spans) + assert widenings[0] == 0.5 # R=35 → 0.5m + assert widenings[1] == 2.25 and sides[1] == "right" # 겹친 자리는 급한 곡선 값 + assert widenings[2] == 2.25 From 87ecf8a2e1efbef3e9066d919c7e6e804b021567 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 14:39:31 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(B09):=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EC=9D=84=20B03~B07=20=EA=B3=B5=ED=86=B5=20=ED=8B=80(?= =?UTF-8?q?=EC=A0=91=ED=9E=88=EB=8A=94=20=EC=83=81=EC=9E=90=C2=B7=EB=B0=94?= =?UTF-8?q?=EB=8B=A5=20=EA=B3=A0=EC=A0=95=20=EC=95=A1=EC=85=98=20=EC=A4=84?= =?UTF-8?q?)=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조건 상자 다섯을 `ui-collapsible ui-sidebar-section` 으로, 제목에 `ui-collapsible__title` 부여 - 자체 `b09-panel__actions` 를 공용 `ui-sidebar-actions` 로 바꿔 [계산]·[확정] 이 패널 바닥에 고정 - 패널 루트에 `attachCollapsible` 연결 — 제목 행 클릭으로 접힘·펼침 - 상자 꼴(패딩·모서리·배경)은 `.ui-sidebar-section` 이 붙은 것만 집어 기초자료 본문 표는 불변 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp --- B09_Estimation/B09_Estimation_UI_Page.ts | 34 ++++++++++++++++-------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 766587b8..fa5576f8 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -16,6 +16,7 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; import { drawBaseDataTab, drawFactorChoices, @@ -175,6 +176,14 @@ function injectStyles(): void { style.textContent = ` .b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); } .b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); } +/* 좌측 패널 상자 — B04~B07 과 같은 꼴(테두리는 공용 .ui-sidebar-section 이 전담). + .ui-sidebar-section 이 붙은 것만 집어 본문 기초자료 표의 같은 클래스는 안 건드린다. */ +.b09-panel__group.ui-sidebar-section { + margin: 0; + padding: calc(var(--spacing-8) + var(--spacing-4)); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); +} .b09-panel__legend { font-size: var(--font-size-xs, 12px); letter-spacing: .06em; color: var(--color-text-secondary); text-transform: uppercase; @@ -184,7 +193,6 @@ function injectStyles(): void { display: flex; justify-content: space-between; gap: var(--space-sm, 8px); border-bottom: 1px solid var(--color-border); padding: 2px 0; } -.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); } .b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } /* 표본이 얇은 노임 — 막는 것이 아니라 눈에 띄기만 하면 된다. */ .b09-hint--warn { color: var(--color-warning-text, #8a5a00); } @@ -483,10 +491,10 @@ function buildSidePanel( legendKey: keyof typeof ui_locales, fields: Array<[keyof CostFormState, keyof typeof ui_locales]>, ): void => { - const group = document.createElement("div"); - group.className = "b09-panel__group"; + const group = document.createElement("section"); + group.className = "b09-panel__group ui-collapsible ui-sidebar-section"; const legend = document.createElement("span"); - legend.className = "b09-panel__legend"; + legend.className = "b09-panel__legend ui-collapsible__title"; legend.textContent = L(legendKey); group.append(legend); for (const [field, labelKey] of fields) { @@ -512,10 +520,10 @@ function buildSidePanel( ]); // 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다. - const rateGroup = document.createElement("div"); - rateGroup.className = "b09-panel__group"; + const rateGroup = document.createElement("section"); + rateGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section"; const rateLegend = document.createElement("span"); - rateLegend.className = "b09-panel__legend"; + rateLegend.className = "b09-panel__legend ui-collapsible__title"; rateLegend.textContent = L("B09_Estimation_Group_RateVersion"); const rateVersionBox = document.createElement("div"); rateGroup.append(rateLegend, rateVersionBox); @@ -532,10 +540,10 @@ function buildSidePanel( ]); // 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다. - const quantityGroup = document.createElement("div"); - quantityGroup.className = "b09-panel__group"; + const quantityGroup = document.createElement("section"); + quantityGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section"; const quantityLegend = document.createElement("span"); - quantityLegend.className = "b09-panel__legend"; + quantityLegend.className = "b09-panel__legend ui-collapsible__title"; quantityLegend.textContent = L("B09_Estimation_Group_Quantity"); const quantityLabel = document.createElement("label"); quantityLabel.className = "ui-field__label"; @@ -555,7 +563,8 @@ function buildSidePanel( root.append(hintBox); const actions = document.createElement("div"); - actions.className = "b09-panel__actions"; + // 바닥 고정 액션 줄(공용) — ui_template_overlay 가 이 줄을 스크롤 밖으로 빼낸다. + actions.className = "ui-sidebar-actions"; actions.append( createButton({ label: L("B09_Estimation_Btn_Recalc"), @@ -569,6 +578,9 @@ function buildSidePanel( ); root.append(actions); + // 그룹 제목 행 클릭 시 접기/펼치기(B04~B07 공통). 액션 줄은 collapsible 이 아니다. + attachCollapsible(root); + return { root, rateVersionBox, hintBox }; } From 332948694ef4ff28d24f3ef5f44a2b8b768b7b2f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 14:39:35 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(B05):=20=EC=B8=A1=EC=A0=90=20=EB=88=88?= =?UTF-8?q?=EA=B8=88=EC=9D=84=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EA=B7=B8=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=9A=A1=EB=8B=A8=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원지반 횡단선·기본 계획 횡단선·성토사면 길이 셋만 보임, 구조물은 안 그림 (계획서 0-9 ⑧) - 서버 `POST /route/cross-preview` 가 `generate_sections`+`compute_cross_design` 정본을 재사용 - 계획고는 편집 중에 없어 그 측점 지반고를 그대로 놓음(지반 추종) — 창에 그렇게 적음 - 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths` 를 그대로 부름 - 700줄 규정에 맞춰 곡선 패널 배선을 `_CurveBar.ts` 로 분리 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX --- B05_Profile/B05_Profile_Api_Replan.ts | 38 +++ B05_Profile/B05_Profile_Router_Terrain.py | 129 +++++++++- B05_Profile/B05_Profile_UI_RouteEdit.ts | 144 +++++------ B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 234 ++++++++++++++++++ .../B05_Profile_UI_RouteEdit_CurveBar.ts | 152 ++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 45 ++++ .../B05_Profile_UI_Style_RouteEdit.css | 50 ++++ 7 files changed, 705 insertions(+), 87 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 5581a8e1..ad414804 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -148,6 +148,44 @@ export async function fetchRouteElevations( return payload.z; } +/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */ +export interface CrossPreviewResponse { + status: string; + chainage_m: number; + label: string | null; + uphill_side: string | null; + plan_radius_m: number | null; + curve_widening_m: number | null; + /** 원지반 횡단 샘플. */ + samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>; + /** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */ + design: { + design_line: Array<{ offset_m: number; elevation_m: number }>; + cut_area_m2: number; + fill_area_m2: number; + [key: string]: unknown; + } | null; +} + +export interface CrossPreviewRequest { + vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; + chainage_m: number; + min_radius_m: number; + station_interval_m: number; +} + +/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ +export async function fetchCrossPreview( + projectId: string, + request: CrossPreviewRequest, +): Promise { + return requestJson( + `/projects/${projectId}/route/cross-preview`, + { method: "POST", body: JSON.stringify(request) }, + 120000, + ); +} + /** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ 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 index 31e1b353..db032f93 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -8,7 +8,8 @@ 표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다. 두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다. - POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 + POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 + POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기 """ import asyncio @@ -22,6 +23,12 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Engine_Sections_Core import ( + SectionGenerationOptions, + generate_sections, +) +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from common_util.common_util_route_polyline import build_planned_polyline 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 @@ -92,3 +99,123 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> "z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)], "valid": [bool(ok) for ok in valid], } + + +class PreviewVertex(BaseModel): + """편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴.""" + + x: float + y: float + curve: bool = True + radius_m: float | None = None + + +class CrossPreviewRequest(BaseModel): + """고치던 노선 그대로 한 측점의 횡단을 미리 본다.""" + + vertices: list[PreviewVertex] = Field(..., min_length=2) + chainage_m: float = Field(..., ge=0) + #: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다. + min_radius_m: float = Field(12.0, gt=0) + station_interval_m: float | None = None + + +def _cross_preview( + project_root: Path, + params: dict, + request: CrossPreviewRequest, +) -> dict | None: + """고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다. + + **B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에 + 존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design` + 이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다. + + ⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지 + 않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면 + 기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다. + """ + 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 + + built = build_planned_polyline( + [(vertex.x, vertex.y) for vertex in request.vertices], + min_radius_m=request.min_radius_m, + # 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`). + simplify=False, + curve_flags=[vertex.curve for vertex in request.vertices], + radii=[vertex.radius_m for vertex in request.vertices], + ) + interval = request.station_interval_m + options = ( + SectionGenerationOptions(station_interval_m=float(interval)) + if interval and interval > 0 + else SectionGenerationOptions() + ) + result = generate_sections(built.vertices, sampler, options) + sections = result["cross_sections"] + if not sections: + return None + section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m)) + + design = None + center_z = section.get("center_z") + if center_z is not None: + # 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다. + section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut" + design = compute_cross_design( + section["samples"], + float(center_z), + ground_type="soil", + section_mode=section_mode, + **curve_widening_args(section), + ) + return { + "chainage_m": round(float(section["chainage_m"]), 3), + "label": section.get("label"), + "uphill_side": section.get("uphill_side"), + "plan_radius_m": section.get("plan_radius_m"), + "curve_widening_m": section.get("curve_widening_m"), + "samples": section["samples"], + "design": design, + "total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3) + if result.get("longitudinal", {}).get("total_length_m") is not None + else None, + } + + +@router.post("/{project_id}/route/cross-preview", response_model=None) +async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse: + """고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧). + + 정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다. + """ + 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)) + preview = await asyncio.to_thread(_cross_preview, project_root, params, request) + if preview is None: + return JSONResponse( + status_code=409, + content={ + "status": "error", + "message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.", + }, + ) + return {"status": "success", "project_id": str(project_id), **preview} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 57322c7b..99c76a89 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -42,13 +42,11 @@ import { handleAtScreen, nodeAtScreen, segmentAtScreen, + stationAtScreen, } from "./B05_Profile_UI_RouteEdit_Input"; +import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; -import { - centerDirectionOf, - createCurveLabel, - deflectionRad, -} from "./B05_Profile_UI_RouteEdit_Label"; +import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; import { applyArcLocks, applyCurveLimits, @@ -72,6 +70,8 @@ const NODE_HIT_PX = 9; const SEGMENT_HIT_PX = 12; /** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */ const CONTOUR_HIT_PX = 6; +/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */ +const STATION_HIT_PX = 11; type Vertex = [number, number]; @@ -100,7 +100,8 @@ export async function openRouteEditModal( 계획노선 편집 노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 · - 노드 오른쪽 클릭 = 삭제 · Shift+클릭 = 두 점 사이 거리·기울기 · + 노드 오른쪽 클릭 = 삭제 · 측점 눈금 클릭 = 횡단 미리보기 · + Shift+클릭 = 두 점 사이 거리·기울기 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대 @@ -162,6 +163,21 @@ export async function openRouteEditModal( let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ let pickedContour = -1; + /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ + const crossPreview = createCrossPreview({ + projectId, + bounds: () => canvas.getBoundingClientRect(), + request: () => ({ + vertices: planned.map(([x, y], index) => ({ + x, + y, + curve: curveOn[index] !== false, + radius_m: curveRadius[index] ?? null, + })), + min_radius_m: minRadiusM || 12, + station_interval_m: stationIntervalM, + }), + }); /** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */ const measure = createMeasureTool({ projectId, @@ -190,6 +206,7 @@ export async function openRouteEditModal( window.removeEventListener("resize", resize); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. + crossPreview.destroy(); overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); @@ -340,88 +357,26 @@ export async function openRouteEditModal( fresh: nodeInfo.length === 0, }); - // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── - const curveLabelBox = createCurveLabel({ - onRadius: (value) => { - if (picked < 0) return; - curveRadius[picked] = value; - // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. - applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); - }, - onArcLength: (value) => { - if (picked < 0) return; - // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). - // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. - const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); - curveArc[picked] = value; - curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; - applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); - }, - onLock: (lock) => { - if (picked < 0) return; - curveLock[picked] = lock; - const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); - const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null; - // 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다. - if (lock === "arc") { - curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null; - } - // R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음). - if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown; - applyEdit( - lock === "radius" - ? "반지름을 고정했습니다." - : lock === "arc" - ? "곡선 길이를 고정했습니다." - : "고정을 풀었습니다.", - ); - }, - onCurveOn: (on) => { - if (picked < 0) return; - curveOn[picked] = on; - applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); - }, - }); - - /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ - function syncCurveBar(): void { - if (!(picked > 0 && picked < planned.length - 1)) { - curveLabelBox.hide(); - return; - } - const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); - const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; - const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); - const rect = canvas.getBoundingClientRect(); - const [screenX, screenY] = toScreen(planned[picked]); - curveLabelBox.show({ - seat: picked, - // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다. - at: [screenX + rect.left, screenY + rect.top], - // 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을 - // 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨). - bounds: { - left: rect.left + 8, - top: rect.top + 8, - right: rect.right - 8, - bottom: rect.bottom - 8, - }, - centerDirection: pickedCurve - ? centerDirectionOf( - toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), - toScreen(pickedCurve.start), - toScreen(pickedCurve.end), - ) - : null, - curveOn: curveOn[picked] !== false, - radiusShown: shown, - arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, - lock: curveLock[picked] ?? null, - innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── + const curveBar = createCurveBar({ + canvas, + state: () => ({ + picked, + planned, + nodeInfo, + curveInfo, + curveOn, + curveRadius, + curveLock, + curveArc, limitRadiusM, limitArcM, - }); - } + }), + toScreen: (vertex) => toScreen(vertex), + applyEdit: (message) => applyEdit(message), + }); + const curveLabelBox = curveBar.label; + const syncCurveBar = curveBar.sync; /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ function applyEdit(message: string, record = true): void { @@ -485,6 +440,23 @@ export async function openRouteEditModal( picked = dragHandle.node; syncCurveBar(); draw(); + } else if ( + // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. + (() => { + const chainage = stationAtScreen( + plannedLine.length ? plannedLine : planned, + toScreen, + stationIntervalM, + px, + py, + STATION_HIT_PX, + ); + if (chainage === null) return false; + void crossPreview.open(chainage); + return true; + })() + ) { + /* 횡단 창이 떴다 — 더 집지 않는다. */ } else if (contours) { // 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦). // 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts new file mode 100644 index 00000000..7d8b47ad --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Cross.ts + * 계획노선 편집 중 **한 측점의 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ⑧). + * + * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 · + * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. + * + * ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의 + * 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의 + * 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다. + * + * 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은 + * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`(여기). + * ========================================================================== */ + +import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; +import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit"; +import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; + +/** 그림 가장자리 여백(px). */ +const PAD = 28; + +export interface CrossPreviewParams { + projectId: string; + /** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */ + bounds: () => DOMRect; + /** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */ + request: () => { + vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; + min_radius_m: number; + station_interval_m: number; + }; +} + +export interface CrossPreviewWindow { + /** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */ + open: (chainageM: number) => Promise; + /** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */ + destroy: () => void; +} + +export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { + const root = document.createElement("div"); + root.className = "b05-routeedit__cross"; + root.hidden = true; + root.innerHTML = ` +
+ 횡단 미리보기 + +
+ +
`; + document.body.append(root); + + const head = root.querySelector(".b05-routeedit__cross-head")!; + const title = root.querySelector(".b05-routeedit__cross-title")!; + const foot = root.querySelector(".b05-routeedit__cross-foot")!; + const canvas = root.querySelector(".b05-routeedit__cross-canvas")!; + const context = canvas.getContext("2d")!; + + root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => { + root.hidden = true; + }); + // 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다. + for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) { + root.addEventListener(type, (event) => event.stopPropagation()); + } + + // ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ── + let dragFrom: { x: number; y: number; left: number; top: number } | null = null; + head.addEventListener("pointerdown", (event) => { + if ((event.target as HTMLElement).closest("button")) return; + dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop }; + head.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + head.addEventListener("pointermove", (event) => { + if (!dragFrom) return; + root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`; + root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`; + }); + const stopDrag = (event: PointerEvent): void => { + if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId); + dragFrom = null; + }; + head.addEventListener("pointerup", stopDrag); + head.addEventListener("pointercancel", stopDrag); + + /** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */ + let asked = -1; + + return { + async open(chainageM) { + asked = chainageM; + root.hidden = false; + if (!root.style.left) { + // 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다. + // 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다. + const box = params.bounds(); + root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`; + root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`; + } + title.textContent = "횡단 미리보기 — 읽는 중…"; + foot.textContent = ""; + context.clearRect(0, 0, canvas.width, canvas.height); + let preview: CrossPreviewResponse; + try { + preview = await fetchCrossPreview(params.projectId, { + ...params.request(), + chainage_m: chainageM, + }); + } catch (error) { + if (asked !== chainageM) return; + title.textContent = "횡단 미리보기"; + foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다."; + return; + } + if (asked !== chainageM || root.hidden) return; + title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`; + drawCross(context, canvas, preview); + foot.textContent = summarize(preview); + }, + destroy() { + root.remove(); + }, + }; +} + +/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */ +function summarize(preview: CrossPreviewResponse): string { + const design = preview.design; + if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다."; + // 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를 + // 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다. + const lengths = fillSlopeLengths({ + samples: preview.samples, + design, + } as unknown as CrossSection); + const sides = (["left", "right"] as const) + .filter((side) => lengths[side] !== null) + .map((side) => { + const value = lengths[side]!; + const label = side === "left" ? "좌" : "우"; + // 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다. + return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`; + }); + const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음"; + return ( + `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡` + + " · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임" + ); +} + +/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */ +function drawCross( + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + preview: CrossPreviewResponse, +): void { + const ground = preview.samples + .filter((sample) => sample.valid && sample.elevation_m !== null) + .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]); + const design = (preview.design?.design_line ?? []).map( + (point) => [point.offset_m, point.elevation_m] as [number, number], + ); + const all = [...ground, ...design]; + context.clearRect(0, 0, canvas.width, canvas.height); + if (all.length < 2) return; + + const offsets = all.map((point) => point[0]); + const heights = all.map((point) => point[1]); + const minOffset = Math.min(...offsets); + const maxOffset = Math.max(...offsets); + const minZ = Math.min(...heights); + const maxZ = Math.max(...heights); + const spanX = maxOffset - minOffset || 1; + const spanZ = maxZ - minZ || 1; + // **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 + // 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). + const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ); + const centerOffset = (minOffset + maxOffset) / 2; + const centerZ = (minZ + maxZ) / 2; + // 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다. + const toScreen = (point: [number, number]): [number, number] => [ + canvas.width / 2 + (centerOffset - point[0]) * scale, + canvas.height / 2 + (centerZ - point[1]) * scale, + ]; + + const stroke = (points: Array<[number, number]>, color: string, width: number): void => { + if (points.length < 2) return; + context.beginPath(); + points.forEach((point, index) => { + const [x, y] = toScreen(point); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.strokeStyle = color; + context.lineWidth = width; + context.stroke(); + }; + + // 중심선 — 어디가 노선 가운데인지 먼저 보이게. + const [centerX] = toScreen([0, centerZ]); + context.save(); + context.setLineDash([4, 4]); + context.strokeStyle = "rgba(148,163,184,0.7)"; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(centerX, PAD / 2); + context.lineTo(centerX, canvas.height - PAD / 2); + context.stroke(); + context.restore(); + + stroke(ground, "#94a3b8", 1.6); // 원지반 + stroke(design, "#f97316", 2.2); // 기본 계획 횡단 + + context.font = "11px system-ui, sans-serif"; + context.textBaseline = "top"; + context.fillStyle = "#94a3b8"; + context.textAlign = "left"; + context.fillText("원지반", PAD, 6); + context.fillStyle = "#f97316"; + context.textAlign = "right"; + context.fillText("기본 계획 횡단", canvas.width - PAD, 6); + context.fillStyle = "#94a3b8"; + context.textAlign = "center"; + context.textBaseline = "bottom"; + context.fillText( + `좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` + + ` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`, + canvas.width / 2, + canvas.height - 4, + ); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts new file mode 100644 index 00000000..97f404c0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_CurveBar.ts + * 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에 + * 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다. + * + * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과 + * 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다. + * + * **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔 + * 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고). + * ========================================================================== */ + +import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; +import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits"; +import { + centerDirectionOf, + createCurveLabel, + deflectionRad, + type CurveLabel, +} from "./B05_Profile_UI_RouteEdit_Label"; + +/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */ +export interface CurveBarState { + picked: number; + planned: Vertex[]; + nodeInfo: EditedNode[]; + curveInfo: EditedCurve[]; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + curveArc: Array; + /** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ + limitRadiusM: number; + limitArcM: number; +} + +export interface CurveBarParams { + canvas: HTMLCanvasElement; + state: () => CurveBarState; + toScreen: (vertex: Vertex) => [number, number]; + /** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */ + applyEdit: (message: string) => void; +} + +export interface CurveBar { + label: CurveLabel; + /** 고른 자리에 맞춰 패널을 옮겨 그린다. */ + sync: () => void; +} + +export function createCurveBar(params: CurveBarParams): CurveBar { + const label = createCurveLabel({ + onRadius: (value) => { + const { picked, curveRadius } = params.state(); + if (picked < 0) return; + curveRadius[picked] = value; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); + }, + onArcLength: (value) => { + const { picked, nodeInfo, curveArc, curveRadius } = params.state(); + if (picked < 0) return; + // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). + // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + curveArc[picked] = value; + curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; + params.applyEdit( + value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.", + ); + }, + onLock: (lock) => { + const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state(); + if (picked < 0) return; + curveLock[picked] = lock; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null; + // 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다. + if (lock === "arc") { + curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null; + } + // R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음). + if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown; + params.applyEdit( + lock === "radius" + ? "반지름을 고정했습니다." + : lock === "arc" + ? "곡선 길이를 고정했습니다." + : "고정을 풀었습니다.", + ); + }, + onCurveOn: (on) => { + const { picked, curveOn } = params.state(); + if (picked < 0) return; + curveOn[picked] = on; + params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + }, + }); + + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ + function sync(): void { + const { + picked, + planned, + nodeInfo, + curveInfo, + curveOn, + curveRadius, + curveLock, + limitRadiusM, + limitArcM, + } = params.state(); + if (!(picked > 0 && picked < planned.length - 1)) { + label.hide(); + return; + } + const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); + const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + const rect = params.canvas.getBoundingClientRect(); + const [screenX, screenY] = params.toScreen(planned[picked]); + label.show({ + seat: picked, + // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다. + at: [screenX + rect.left, screenY + rect.top], + // 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을 + // 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨). + bounds: { + left: rect.left + 8, + top: rect.top + 8, + right: rect.right - 8, + bottom: rect.bottom - 8, + }, + centerDirection: pickedCurve + ? centerDirectionOf( + params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), + params.toScreen(pickedCurve.start), + params.toScreen(pickedCurve.end), + ) + : null, + curveOn: curveOn[picked] !== false, + radiusShown: shown, + arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, + lock: curveLock[picked] ?? null, + innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + limitRadiusM, + limitArcM, + }); + } + + return { label, sync }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index f7f557bf..5c4256ff 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -228,6 +228,51 @@ export function routePointAtScreen( return best; } +/** + * 클릭에 가장 가까운 **규칙 측점**의 누가거리(m). 그만큼 안에 없으면 null(계획서 0-9 ⑧). + * + * 눈금을 그리는 `drawStationTicks` 와 **같은 자리**를 짚는다 — 선분 길이를 누적해 측점 간격 + * 마다 한 점씩 보간한다. 눈금이 보이는 자리를 눌렀는데 안 잡히면 안 되기 때문이다. + */ +export function stationAtScreen( + line: Array<[number, number]>, + toScreen: ScreenOf, + intervalM: number, + px: number, + py: number, + maxPx: number, +): number | null { + if (line.length < 2 || !(intervalM > 0)) return null; + const cumulative: number[] = [0]; + for (let index = 1; index < line.length; index += 1) { + cumulative.push( + cumulative[index - 1] + + Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]), + ); + } + const total = cumulative[cumulative.length - 1]; + let best: number | null = null; + let bestDistance = maxPx; + let cursor = 1; + for (let chainage = 0; chainage <= total; chainage += intervalM) { + while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; + const back = line[cursor - 1]; + const front = line[cursor]; + const segment = cumulative[cursor] - cumulative[cursor - 1] || 1; + const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment)); + const [x, y] = toScreen([ + back[0] + (front[0] - back[0]) * ratio, + back[1] + (front[1] - back[1]) * ratio, + ]); + const distance = Math.hypot(x - px, y - py); + if (distance < bestDistance) { + bestDistance = distance; + best = chainage; + } + } + return best; +} + /** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. * * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 789350c7..361a73f2 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -241,3 +241,53 @@ color: var(--color-text-secondary); line-height: 1.35; } + +/* ── 측점 횡단 미리보기 창 (계획서 0-9 ⑧) ──────────────────────────────── + 곡선 조작 패널과 같은 까닭으로 `document.body` 에 띄운다 — 모달이 `overflow: hidden` + 이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 수 있다. */ +.b05-routeedit__cross { + position: fixed; + z-index: calc(var(--z-modal, 1000) + 2); + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); + width: 452px; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + background: var(--color-surface-raised); + box-shadow: 0 8px 28px rgb(0 0 0 / 40%); +} + +.b05-routeedit__cross-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8, 8px); + cursor: move; + touch-action: none; +} + +.b05-routeedit__cross-close { + padding: 0 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; +} + +.b05-routeedit__cross-canvas { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); +} + +.b05-routeedit__cross-foot { + color: var(--color-text-secondary); + font-size: var(--text-caption); + line-height: 1.5; +}