# P1 粗路径 Clumsy UI 集成 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 交付可在 Clumsy 中后台运行的七个粗路径测试入口，显示真实规划栅格快照和完整路径信息，并支持传入 AMR 世界位姿与手动目标位姿。

**Architecture:** `CoarsePathScenarioFactory` 保持无 UI 的纯输入构造职责，提供六个可重复的回归场景及一个显式标注为空图演示的“AMR 位姿 + 手动终点”请求创建入口。`MovementTest.CoarsePathTest.cs` 只作为 UI 适配层：把 AMR/目标 mm+deg 转为核心所需的 m+rad，使用一个共享门面在 `Task.Run` 后台运行，并从不可变 `CoarsePathPlanningJobResult` 绘制地图快照和路径。

**Tech Stack:** C# / `netstandard2.0`、现有 Clumsy `MovementTest`/`Painter`、`CoarsePathPlanningService`、PowerShell 反射验证脚本。

## Global Constraints

- `PlanningMapRequest` 的地图、障碍物、AMR 输入和手动目标 X/Y 均为世界 mm；`Pose2D` 和路径 X/Y 为世界 m；核心航向为 rad。
- 项目上游的 AMR `th` 输入按 deg 适配为 `th * Math.PI / 180d`；不得沿用直接将该值传给 `Math.Cos/Sin` 的旧写法。
- AMR 起点必须是车辆几何中心；传感器安装点必须由上游先按外参转换。
- UI 和测试只能调用 `CoarsePathPlanningService.Plan(job, token)`；不得直接实例化 `PlanningMapFactory`、`HybridAStarPlanner`、栅格化器、碰撞器、原语生成器或搜索节点。
- 所有七个 MovementTest 都不得引用 `BasicPilotBase.Chassis`、`SendMotion`、`DriveTask` 或任何底盘控制 API。
- `Test` 不得等待后台任务或读取 `Task.Result`；`TestStop` 先取消令牌，再使运行编号失效、解绑任务并清空 Painter。
- 只在 `PlanningStatus.Success` 绘制路径、方向箭头、换向点和扩大车体检查框；失败、取消和超时只显示地图、起点、终点和状态。
- 代码兼容 `netstandard2.0`，不引入新 NuGet 包；公开类型/成员写中文 XML 文档，复杂单位与并发逻辑写简短中文行注释。
- 不改动 TrapMap 文件或旧 TrapMap 验证脚本；不执行 Git 状态、差异、提交或重置操作。

---

## 文件结构

| 文件 | 变更职责 |
| --- | --- |
| `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/CoarsePathScenarioFactory.cs` | 新建纯场景工厂、六场景枚举、AMR/目标位姿 mm+deg 到核心 `Pose2D` 的转换，以及空图演示手动目标请求。 |
| `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/MovementTest.CoarsePathTest.cs` | 新建七个 UI 入口、共享会话执行器、任务取消、Painter 地图/路径/图例绘制与手动输入解析。 |
| `ClumsyPilot/tests/verify_coarse_path_integration.ps1` | 为工厂行为、单位转换、缓存/换向/无解、UI 源码边界与 README 内容新增真实程序集和文本断言。 |
| `ClumsyPilot/ParkrobTrajplanner/CoarsePath/README.md` | 记录 P1 测试入口、输入单位、空图演示限制、图例、停止语义及无底盘命令边界。 |

### 固定接口

```csharp
namespace MultiWheelC.TrajectoryPlanning.CoarsePath.Test;

public enum CoarsePathTestScenario
{
    ExplicitEmpty,
    RectangleDetour,
    ManualAndTwoLeg,
    CacheHit,
    ReverseGearSwitch,
    NoFeasiblePath,
}

public static class CoarsePathScenarioFactory
{
    public static CoarsePathPlanningJob Create(CoarsePathTestScenario scenario);

    public static CoarsePathPlanningJob CreateManualGoalDemo(
        double startXMillimeters, double startYMillimeters, double startHeadingDegrees,
        double goalXMillimeters, double goalYMillimeters, double goalHeadingDegrees);
}
```

`CreateManualGoalDemo` 只构造带 2,000 mm 边缘留白的显式空图演示请求，并在 README/测试名称中明确其不代表真实环境安全。未来现场入口必须提供真实 `IMapObstacleSource` 快照，而不是修改此方法的语义。

### Task 1: 工厂契约与失败测试

**Files:**

- Modify: `ClumsyPilot/tests/verify_coarse_path_integration.ps1`
- Create later in Task 2: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/CoarsePathScenarioFactory.cs`

**Consumes:** 已有 `CoarsePathPlanningService.Plan(CoarsePathPlanningJob, CancellationToken)`、`Find-Method`、`Assert-True`、`Assert-Equal` 与程序集加载逻辑。

**Produces:** 对 `CoarsePathTestScenario` 和 `CoarsePathScenarioFactory` 的反射行为约束；Task 2 的最小实现必须使这些断言通过。

- [ ] **Step 1: 在集成脚本加入工厂反射测试**

在现有 facade 检查后、最终输出前插入以下 PowerShell。它要求类型和两个公开方法都存在，因此在工厂未创建时失败。

```powershell
$testNamespace = $coarsePath + 'Test.'
$scenarioEnumType = $assembly.GetType($testNamespace + 'CoarsePathTestScenario', $false)
$scenarioFactoryType = $assembly.GetType($testNamespace + 'CoarsePathScenarioFactory', $false)
Assert-True ($scenarioEnumType -ne $null) 'P1 scenario enum must exist.'
Assert-True ($scenarioFactoryType -ne $null) 'P1 scenario factory must exist.'

$factoryCreate = Find-Method $scenarioFactoryType 'Create' @($scenarioEnumType)
$factoryManual = Find-Method $scenarioFactoryType 'CreateManualGoalDemo' @(
    [double], [double], [double], [double], [double], [double])
Assert-True ($factoryCreate -ne $null) 'P1 scenario factory must expose Create(scenario).'
Assert-True ($factoryManual -ne $null) 'P1 scenario factory must expose CreateManualGoalDemo with six doubles.'

$scenarioNames = @('ExplicitEmpty', 'RectangleDetour', 'ManualAndTwoLeg', 'CacheHit', 'ReverseGearSwitch', 'NoFeasiblePath')
foreach ($scenarioName in $scenarioNames) {
    $scenario = [Enum]::Parse($scenarioEnumType, $scenarioName)
    $jobA = $factoryCreate.Invoke($null, @($scenario))
    $jobB = $factoryCreate.Invoke($null, @($scenario))
    Assert-True ($jobA -ne $null) "Scenario $scenarioName must return a job."
    Assert-False ([object]::ReferenceEquals($jobA, $jobB)) "Scenario $scenarioName must return a new job per call."
}

$manualJob = $factoryManual.Invoke($null, @(1000.0, 2000.0, 90.0, 4000.0, 2000.0, 0.0))
Assert-Near 1.0 $manualJob.Start.X 'Manual AMR X must convert mm to m.'
Assert-Near 2.0 $manualJob.Start.Y 'Manual AMR Y must convert mm to m.'
Assert-Near ([Math]::PI / 2.0) $manualJob.Start.Heading 'Manual AMR heading must convert degrees to radians.'
Assert-Near 4.0 $manualJob.Goal.X 'Manual goal X must convert mm to m.'
Assert-Near 0.0 $manualJob.Goal.Heading 'Manual goal heading must convert degrees to radians.'
Assert-True $manualJob.MapRequest.AllowExplicitEmptyMap 'Manual goal demo must declare its empty map explicitly.'
```

- [ ] **Step 2: 运行脚本确认失败**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected: 构建成功；脚本因 `P1 scenario enum must exist.` 失败。

### Task 2: 实现纯场景工厂并通过行为测试

**Files:**

- Create: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/CoarsePathScenarioFactory.cs`
- Test: `ClumsyPilot/tests/verify_coarse_path_integration.ps1`

**Consumes:** Task 1 的枚举和两个公开工厂方法；`MapBoundsMm`、`ManualObstacleSource`、`TwoLegObstacleSource`、`Pose2D`、`VehicleParameters`、`HybridAStarConfiguration`。

**Produces:** 六个可重复 job 与一个空图演示手动 job，供 UI 入口和后续脚本行为断言共同使用。

- [ ] **Step 1: 先建立最小的公共类型和转换辅助函数**

创建工厂文件并定义以下枚举、转换函数和公共入口。所有输入先做有限值检查；非有限输入抛出 `ArgumentOutOfRangeException`，避免伪造核心请求。

```csharp
public enum CoarsePathTestScenario
{
    ExplicitEmpty,
    RectangleDetour,
    ManualAndTwoLeg,
    CacheHit,
    ReverseGearSwitch,
    NoFeasiblePath,
}

public static class CoarsePathScenarioFactory
{
    private const double MillimetersPerMeter = 1000d;
    private const double DegreesToRadians = Math.PI / 180d;
    private const float ResolutionMillimeters = 50f;
    private const double ManualMapPaddingMillimeters = 2000d;

    public static CoarsePathPlanningJob Create(CoarsePathTestScenario scenario)
    {
        switch (scenario)
        {
            case CoarsePathTestScenario.ExplicitEmpty: return CreateExplicitEmpty();
            case CoarsePathTestScenario.RectangleDetour: return CreateRectangleDetour();
            case CoarsePathTestScenario.ManualAndTwoLeg: return CreateManualAndTwoLeg();
            case CoarsePathTestScenario.CacheHit: return CreateRectangleDetour();
            case CoarsePathTestScenario.ReverseGearSwitch: return CreateReverseGearSwitch();
            case CoarsePathTestScenario.NoFeasiblePath: return CreateNoFeasiblePath();
            default: throw new ArgumentOutOfRangeException(nameof(scenario));
        }
    }

    public static CoarsePathPlanningJob CreateManualGoalDemo(
        double startXMillimeters, double startYMillimeters, double startHeadingDegrees,
        double goalXMillimeters, double goalYMillimeters, double goalHeadingDegrees)
    {
        EnsureFinite(startXMillimeters, nameof(startXMillimeters));
        EnsureFinite(startYMillimeters, nameof(startYMillimeters));
        EnsureFinite(startHeadingDegrees, nameof(startHeadingDegrees));
        EnsureFinite(goalXMillimeters, nameof(goalXMillimeters));
        EnsureFinite(goalYMillimeters, nameof(goalYMillimeters));
        EnsureFinite(goalHeadingDegrees, nameof(goalHeadingDegrees));
        return CreateJob(CreateManualDemoMap(startXMillimeters, startYMillimeters, goalXMillimeters, goalYMillimeters),
            ToPose(startXMillimeters, startYMillimeters, startHeadingDegrees),
            ToPose(goalXMillimeters, goalYMillimeters, goalHeadingDegrees), null, GoalDirectionConstraint.Any);
    }

    private static Pose2D ToPose(double xMillimeters, double yMillimeters, double headingDegrees)
        => new Pose2D(xMillimeters / MillimetersPerMeter, yMillimeters / MillimetersPerMeter,
            headingDegrees * DegreesToRadians);
}
```

- [ ] **Step 2: 实现统一请求模板和六个固定场景**

使用统一的车辆和配置，避免场景间无意改变安全或搜索语义。模板必须是新对象：

```csharp
private static CoarsePathPlanningJob CreateJob(PlanningMapRequest mapRequest, Pose2D start, Pose2D goal,
    TravelDirection? startDirection, GoalDirectionConstraint goalDirection)
{
    return new CoarsePathPlanningJob
    {
        MapRequest = mapRequest,
        Start = start,
        Goal = goal,
        Vehicle = new VehicleParameters
        {
            LengthMeters = 0.80d,
            WidthMeters = 0.60d,
            SafetyMarginMeters = 0.05d,
            MaximumCurvaturePerMeter = 1d / 1.20d,
        },
        Configuration = new HybridAStarConfiguration(),
        StartDirection = startDirection,
        GoalDirection = goalDirection,
    };
}
```

固定地图均使用 `new MapBoundsMm(0f, 6000f, 0f, 4000f)`、50 mm 分辨率。以下代码固定各场景的障碍来源和世界位姿，所有 `ManualObstacleSource` 版本为 `1L`、所有必需来源为 `true`：

```csharp
private static CoarsePathPlanningJob CreateExplicitEmpty()
    => CreateJob(CreateMap(true, Array.Empty<IMapObstacleSource>()),
        new Pose2D(1d, 2d, 0d), new Pose2D(5d, 2d, 0d), null, GoalDirectionConstraint.Forward);

private static CoarsePathPlanningJob CreateRectangleDetour()
    => CreateJob(CreateMap(false, new IMapObstacleSource[]
        {
            new ManualObstacleSource("manual", 1L, true, new IMapObstacle[]
                { new AxisAlignedRectangleObstacle(2700f, 3300f, 1200f, 2800f) }),
        }), new Pose2D(1d, 2d, 0d), new Pose2D(5d, 2d, 0d), null, GoalDirectionConstraint.Forward);

private static CoarsePathPlanningJob CreateManualAndTwoLeg()
    => CreateJob(CreateMap(false, new IMapObstacleSource[]
        {
            new ManualObstacleSource("manual", 1L, true, new IMapObstacle[]
            {
                new CircleObstacle(2400f, 1300f, 220f),
                new AxisAlignedRectangleObstacle(3000f, 3600f, 2000f, 2600f),
            }),
            new TwoLegObstacleSource("two-leg", 1L, true,
                new TwoLegProjectionInput(true, 3900f, 2500f, 0d,
                    -180f, -180f, -180f, 180f, 140f, "P1 fixed TwoLeg snapshot.")),
        }), new Pose2D(1d, 1d, 0d), new Pose2D(5d, 3d, 0d), null, GoalDirectionConstraint.Forward);

private static CoarsePathPlanningJob CreateNoFeasiblePath()
    => CreateJob(CreateMap(false, new IMapObstacleSource[]
        {
            new ManualObstacleSource("manual", 1L, true, new IMapObstacle[]
                { new AxisAlignedRectangleObstacle(2900f, 3100f, 0f, 4000f) }),
        }), new Pose2D(1d, 2d, 0d), new Pose2D(5d, 2d, 0d), null, GoalDirectionConstraint.Forward);
```

`CreateMap` 返回新的 `PlanningMapRequest`，固定写入地图边界、分辨率、给定来源和 `AllowExplicitEmptyMap`。倒车换向场景使用空图、起点 `(1,2,0)`、终点 `(4,2,0)`、`StartDirection=Forward`、`GoalDirection=Reverse`，使路径必须以至少一次换向结束。

倒车换向场景使用空图、起点 `(1,2,0)`、终点 `(4,2,0)`、`StartDirection=Forward`、`GoalDirection=Reverse`，使路径必须以至少一次换向结束。若 P0 的离散搜索在此几何下无法稳定得到成功，只允许调整此场景的目标距离或障碍布局，且测试必须继续要求 `IsGearSwitchPoint=true`。

`CreateManualDemoMap` 用起终点 X/Y 的最小/最大值各扩展 `ManualMapPaddingMillimeters`，按 50 mm 向外取整，并明确设置 `AllowExplicitEmptyMap=true` 和空的 `ObstacleSources`。

- [ ] **Step 3: 扩展行为断言以覆盖所有场景的实际状态**

在 Task 1 的反射代码之后增加服务执行测试。它不引用任何 Painter 或 MovementTest：

```powershell
$scenarioService = [Activator]::CreateInstance($serviceType)
foreach ($scenarioName in @('ExplicitEmpty', 'RectangleDetour', 'ManualAndTwoLeg', 'ReverseGearSwitch')) {
    $job = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, $scenarioName)))
    $result = $servicePlan.Invoke($scenarioService, @($job, [Threading.CancellationToken]::None))
    Assert-Equal 'Success' $result.PlanningResult.Status.ToString() "Scenario $scenarioName must succeed."
}

$reverseJob = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, 'ReverseGearSwitch')))
$reverseResult = $servicePlan.Invoke($scenarioService, @($reverseJob, [Threading.CancellationToken]::None))
Assert-True (($reverseResult.PlanningResult.Path | Where-Object { $_.IsGearSwitchPoint }).Count -ge 1) 'Reverse scenario must expose a gear-switch point.'

$noPathJob = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, 'NoFeasiblePath')))
$noPathResult = $servicePlan.Invoke($scenarioService, @($noPathJob, [Threading.CancellationToken]::None))
Assert-Equal 'NoFeasiblePath' $noPathResult.PlanningResult.Status.ToString() 'Barrier scenario must be infeasible.'
Assert-Equal 0 $noPathResult.PlanningResult.Path.Count 'Infeasible scenario must not publish a path.'

$cacheJobA = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, 'CacheHit')))
$cacheJobB = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, 'CacheHit')))
$cacheFirst = $servicePlan.Invoke($scenarioService, @($cacheJobA, [Threading.CancellationToken]::None))
$cacheSecond = $servicePlan.Invoke($scenarioService, @($cacheJobB, [Threading.CancellationToken]::None))
Assert-Equal 'Input' $cacheSecond.MapResult.CacheHit.ToString() 'Cache-hit scenario must reuse the complete map input.'
Assert-Equal $cacheFirst.PlanningResult.Status $cacheSecond.PlanningResult.Status 'Map cache reuse must not change planning status.'
```

- [ ] **Step 4: 运行测试并固定数值场景**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected: 工厂、单位转换、六个固定场景、缓存与无解断言通过；此时尚未加入 MovementTest 源码检查，因此脚本整体通过。

### Task 3: MovementTest 后台会话与完整 Painter 绘制

**Files:**

- Modify: `ClumsyPilot/tests/verify_coarse_path_integration.ps1`
- Create: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/MovementTest.CoarsePathTest.cs`

**Consumes:** Task 2 的 `CoarsePathScenarioFactory.Create` 和 `CreateManualGoalDemo`；`CoarsePathPlanningService`、`CoarsePathPlanningJobResult`、`PlanningGridMap`、`Painter`。

**Produces:** 六个固定场景入口与一个“AMR 位姿 + 手动终点（空图演示）”入口；所有入口通过同一个后台执行器运行和绘制。

- [ ] **Step 1: 写入失败的 UI 源码结构断言**

在 PowerShell 脚本中加入下列纯文本检查，避免在自动化测试中实例化外部 UI：

```powershell
$movementTestPath = Join-Path $plannerRoot 'CoarsePath\Test\MovementTest.CoarsePathTest.cs'
if (-not (Test-Path -LiteralPath $movementTestPath -PathType Leaf)) {
    throw 'P1 coarse-path MovementTest source file must exist.'
}
$movementTestContent = Get-Content -LiteralPath $movementTestPath -Raw
foreach ($required in @(
    '[MovementTest(name = "粗路径-显式空图")]',
    '[MovementTest(name = "粗路径-矩形绕行")]',
    '[MovementTest(name = "粗路径-多来源障碍")]',
    '[MovementTest(name = "粗路径-缓存命中")]',
    '[MovementTest(name = "粗路径-倒车换向")]',
    '[MovementTest(name = "粗路径-无解")]',
    '[MovementTest(name = "粗路径-AMR起点手动终点(空图演示)")]',
    'Task.Run', 'CancellationTokenSource', 'CoarsePathPlanningService',
    'PlanningGridMap', 'IsOccupied', 'ResolutionMm', 'SnapshotId', '图例', 'IsGearSwitchPoint')) {
    Assert-True $movementTestContent.Contains($required) "MovementTest must contain: $required"
}
foreach ($forbidden in @('PlanningMapFactory', 'HybridAStarPlanner', 'MapObstacleRasterizer',
    'FootprintCollisionChecker', 'MotionPrimitiveGenerator', 'HybridAStarSearch',
    'BasicPilotBase.Chassis', 'SendMotion', 'DriveTask', '.Wait()', '.Result')) {
    Assert-False $movementTestContent.Contains($forbidden) "MovementTest must not depend on: $forbidden"
}
```

- [ ] **Step 2: 运行脚本确认 UI 结构检查失败**

Run:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected: 在工厂断言通过后，因 `P1 coarse-path MovementTest source file must exist.` 失败。

- [ ] **Step 3: 实现共享会话执行器与七个薄入口**

在新文件中使用 `namespace MultiWheelC;`，引用 `System.Threading`、`System.Threading.Tasks`、`System.Drawing`、`System.Numerics`、`ClumsyCore`、`MDCSToolBox.Clumsy.Movements` 和 Map/CoarsePath 命名空间。定义一个内部静态执行器，核心形状如下：

```csharp
internal static class CoarsePathMovementTestRunner
{
    private static readonly object SyncRoot = new object();
    private static readonly CoarsePathPlanningService Service = new CoarsePathPlanningService();
    private static readonly Painter Painter = UI.GetPainter("CoarsePathPlanningV1", true);
    private static long _nextRunId;
    private static long _activeRunId;
    private static CancellationTokenSource _activeCancellation;
    private static Task _activeTask;

    internal static void Start(string displayName, CoarsePathPlanningJob job)
    {
        CancellationTokenSource previous;
        long runId;
        var cancellation = new CancellationTokenSource();
        lock (SyncRoot)
        {
            previous = _activeCancellation;
            runId = ++_nextRunId;
            _activeRunId = runId;
            _activeCancellation = cancellation;
            Painter.Clear();
            _activeTask = Task.Run(() => Service.Plan(job, cancellation.Token));
            _activeTask.ContinueWith(task => Complete(runId, displayName, job, cancellation, task),
                CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
        }
        previous?.Cancel();
    }

    internal static void Stop()
    {
        CancellationTokenSource cancellation;
        lock (SyncRoot)
        {
            cancellation = _activeCancellation;
        }
        cancellation?.Cancel();
        lock (SyncRoot)
        {
            if (!ReferenceEquals(_activeCancellation, cancellation)) return;
            _activeCancellation = null;
            _activeTask = null;
            _activeRunId = ++_nextRunId;
        }
        Painter.Clear();
    }
}
```

`Complete` 必须捕获 `task.Exception`，但正常情况下只接受 `CoarsePathPlanningJobResult`。在锁内确认 `runId == _activeRunId`、任务未取消并且 `task.Status == TaskStatus.RanToCompletion` 后才绘制；无论绘制与否都在 finally 中释放该任务专用 `CancellationTokenSource`。不可在锁内等待任务。

添加一个抽象 `CoarsePathScenarioMovementTest`，其 `Test` 调用 `CoarsePathMovementTestRunner.Start(DisplayName, CoarsePathScenarioFactory.Create(Scenario))`，其 `TestStop` 调用 `Stop()`。实现六个带固定属性名称的密封子类。第七个类在 `Test` 中只读取一次六个 UI 输入：AMR 起点 X/Y/航向和目标 X/Y/航向（分别为 mm/mm/deg），调用 `CreateManualGoalDemo` 后启动；输入解析失败时仅记录错误并不启动任务。

- [ ] **Step 4: 实现确定的地图和结果绘制辅助方法**

在同一执行器内只消费 `job` 与 `CoarsePathPlanningJobResult`，按固定顺序调用以下辅助方法：

```csharp
private static void DrawMap(PlanningGridMap map);
private static void DrawPose(Color color, string label, Pose2D pose);
private static void DrawGoalTolerance(Pose2D goal, HybridAStarConfiguration configuration);
private static void DrawSuccessfulPath(PlanningResult result, VehicleParameters vehicle);
private static void DrawLegendAndStatus(string displayName, CoarsePathPlanningJobResult result, int gridStride);
```

`DrawMap` 画 `[Bounds.XMin, Bounds.XMax) × [Bounds.YMin, Bounds.YMax)` 的粗边界和 X/Y 参考。`gridStride = Max(1, Ceiling(Max(Rows, Cols) / 100d))`；每 `gridStride` 个真实栅格画一条线，状态文字写入 `分辨率=...mm，显示每...格`。遍历 `row/col`，仅对 `map.IsOccupied(row,col)` 为 true 的单元以四条边线画深色格框，确保显示的是最终快照而非原始几何。

`DrawPose` 将 m 转 mm，以圆、朝向短线和标签分别绘制绿色起点、橙色终点。`DrawGoalTolerance` 将位置容差 m 转 mm，绘制橙色容差圆。`DrawSuccessfulPath` 仅在 `result.Status == PlanningStatus.Success` 时运行：相邻路径点按当前点 `Direction` 使用青色（前进）或蓝色（倒车）连线；每隔 10 点画短箭头；`IsGearSwitchPoint` 画紫色圆与“换向”；首、末、换向和每 20 点调用旋转矩形绘制，半长/半宽严格按车辆长宽加安全余量。`DrawLegendAndStatus` 在边界左上方显示边界、占据格、起点、终点、前进、倒车、换向和扩大车体颜色说明，另显示 `SnapshotId`、`MapResult.Status`、`CacheHit`、`PlanningResult.Status`、`Elapsed` 和终止原因。

- [ ] **Step 5: 运行构建与集成脚本**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected: 构建成功；工厂行为、七入口结构、后台取消约束和 Painter 数据来源检查全部通过。

### Task 4: README 与文档断言

**Files:**

- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/README.md`
- Modify: `ClumsyPilot/tests/verify_coarse_path_integration.ps1`

**Consumes:** Task 2 的 `CreateManualGoalDemo` 单位契约和 Task 3 的七个入口名称、图例颜色和停止行为。

**Produces:** 可独立使用的 P1 UI 说明，以及对其关键安全声明的自动化保护。

- [ ] **Step 1: 为 README 写失败断言**

在现有 README 检查后加入：

```powershell
foreach ($requiredReadmeText in @(
    '## P1：Clumsy 手动测试与可视化',
    '粗路径-AMR起点手动终点(空图演示)',
    'AMR 位姿输入：X/Y 使用世界 mm，th 使用 deg',
    'Pose2D：X/Y 使用 m，航向使用 rad',
    '显式空图只能用于演示',
    '不会发送底盘运动命令',
    'TestStop',
    '栅格边界',
    '占据格',
    '换向')) {
    Assert-True $coarsePathReadmeContent.Contains($requiredReadmeText) "CoarsePath README must document: $requiredReadmeText"
}
```

- [ ] **Step 2: 运行脚本确认 README 检查失败**

Run:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected: 所有代码检查通过；脚本因 `CoarsePath README must document: ## P1：Clumsy 手动测试与可视化` 失败。

- [ ] **Step 3: 在 README 增加 P1 专节**

在“第一版限制”之前增加 `## P1：Clumsy 手动测试与可视化`，逐项写明：

1. 七个 MovementTest 名称及对应场景；缓存测试连续运行两次，第二次展示 `Input` 命中。
2. AMR/手动目标输入契约：世界 `X/Y(mm)`、`th(deg)`，转换成 `Pose2D` 的 m/rad；起点是车辆几何中心。
3. 空图手动目标入口只能演示坐标、路径和取消流程；现场必须提供真实障碍物快照。
4. 可视化图例：边界、抽稀格线、占据格、起点、终点及容差、前进、倒车、换向和扩大车体检查框；失败不显示部分路径。
5. `Test` 在后台规划，`TestStop` 取消令牌并清空图层；测试只显示结果，绝不发送底盘运动命令或执行路径跟踪。

- [ ] **Step 4: 运行 README 与集成检查**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected: 构建成功；README 和全部 P1 集成检查通过。

### Task 5: 全量回归与手动核验说明

**Files:**

- Verify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/CoarsePathScenarioFactory.cs`
- Verify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/MovementTest.CoarsePathTest.cs`
- Verify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/README.md`
- Verify: `ClumsyPilot/tests/verify_coarse_path_integration.ps1`

**Consumes:** Tasks 1–4 的代码、文档和脚本。

**Produces:** 通过 Debug 回归的 P1 UI 集成首个交付；不进入 Release 性能基准。

- [ ] **Step 1: 执行 Debug 构建和所有现存 P0/P1 功能脚本**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_factory.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_adapter.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_image.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_documentation.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_test_config.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_collision.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_search.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected: 构建 0 errors；每个存在的脚本返回 0 并输出其 `passed` 消息。

- [ ] **Step 2: 手动 Clumsy 验收**

在 Clumsy 的 MovementTest 列表依次运行“粗路径-矩形绕行”和“粗路径-AMR起点手动终点(空图演示)”。检查：测试启动后界面仍可操作；图层拥有边界、格线、占据格、图例、起终点与状态；成功案例有方向区分路径和扩大车体框；点击停止后图层清空且没有任何底盘运动命令。

- [ ] **Step 3: 记录交付边界**

在完成报告中明确：P1 UI 集成已完成；下一 P1 子项目是 Release 性能、资源和确定性基准；TrapMap 迁移/清理继续排除；没有执行 Git 操作。

## 自检

- 覆盖性：Task 2 交付纯场景与单位转换；Task 3 交付后台七入口和完整视觉要素；Task 4 交付 README；Task 5 交付自动化与手动验收。规格中的空图限制、实际占据快照、停止语义、无底盘命令和不显示部分路径均有对应任务。
- 占位符：已检查任务不含未决占位、延后实现或泛化错误处理类措辞；每个实现任务均给出文件、接口、测试、命令和具体代码形状。
- 类型一致性：所有任务统一使用 `CoarsePathTestScenario`、`CoarsePathScenarioFactory.Create`、`CreateManualGoalDemo`、`CoarsePathPlanningJob`、`CoarsePathPlanningJobResult` 和 `PlanningGridMap`；AMR 输入始终是 mm+deg，核心位姿始终是 m+rad。
