5 Commits

Author SHA1 Message Date
bcbf8017ad 📝 docs(planning): 统一 Rust 规划库命名
将 Rust 原生规划库设计口径统一为 flyshot_planning。

同步更新 Windows 和 Linux 产物名以及复制命令示例。
2026-06-27 15:10:06 +08:00
537c6b5320 💥 feat(planning)!: 拆分 C# ICSP 并默认 Rust
* 新增可选 Flyshot.CSharp.Planning 项目

* 将 icsp 映射到 Rust 后端并拒绝旧 icsp_rust

* 补充后端注册、文档、调试页和回归测试

BREAKING CHANGE: method=icsp 现在默认调用 Rust 后端;C# ICSP 改为 method=icsp-csharp,旧 method=icsp_rust 不再作为正式入口。
2026-06-27 15:03:05 +08:00
32cc740df5 ✨ feat(native-planning): 更新 Rust ICSP 原生库及构建脚本
* 将 Rust ICSP 原生库名称更改为 `flyshot_planning`,更新相关引用。
* 修改构建脚本以支持新的库名称,并确保库文件复制到正确的目录。
* 添加对 Rust ICSP 原生库的依赖检查,确保发布时包含必要的库文件。
* 更新测试项目以加载新的原生库,确保兼容性和功能完整性。
2026-06-26 17:47:04 +08:00
9c56ee825e ✨ feat(native-planning): 添加 Rust ICSP 原生规划核心及 C# 互操作集成
* 新增 Rust 原生 ICSP 规划库 (Cargo 项目, CubicSpline 算法, FFI 接口)
* 新增 Flyshot.Native.Planning C# 互操作项目 (P/Invoke 封装)
* 更新 ControllerClientCompat 适配新的规划接口
* 添加构建脚本 (Copy-NativePlanningArtifacts.ps1)
* 新增互操作测试及一致性对比测试
* 更新解决方案文件和设计文档
2026-06-26 15:41:01 +08:00
bb21e0923d docs(planning): 添加 Rust ICSP 原生核心设计 2026-06-26 15:41:01 +08:00
49 changed files with 3319 additions and 52 deletions

3
.gitignore vendored
View File

@@ -405,3 +405,6 @@ codex-dotnet-home/*
outputs/obfuscated/
outputs/obfuscation-maps/
.tmp/obfuscation/
# Rust 原生规划库构建产物
**/target/

View File

@@ -1,4 +1,3 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
@@ -27,6 +26,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.ControllerClientCom
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.Runtime.Fanuc", "src\Flyshot.Runtime.Fanuc\Flyshot.Runtime.Fanuc.csproj", "{B705FA6C-19CA-44A8-882C-6CE26A5379C9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.Native.Planning", "src\Flyshot.Native.Planning\Flyshot.Native.Planning.csproj", "{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flyshot.CSharp.Planning", "src\Flyshot.CSharp.Planning\Flyshot.CSharp.Planning.csproj", "{4717558C-0A09-4D4D-BB1A-C0FD9241070E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -157,6 +160,30 @@ Global
{B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x64.Build.0 = Release|Any CPU
{B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x86.ActiveCfg = Release|Any CPU
{B705FA6C-19CA-44A8-882C-6CE26A5379C9}.Release|x86.Build.0 = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|x64.ActiveCfg = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|x64.Build.0 = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|x86.ActiveCfg = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Debug|x86.Build.0 = Debug|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|Any CPU.Build.0 = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|x64.ActiveCfg = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|x64.Build.0 = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|x86.ActiveCfg = Release|Any CPU
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0}.Release|x86.Build.0 = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|x64.ActiveCfg = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|x64.Build.0 = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|x86.ActiveCfg = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Debug|x86.Build.0 = Debug|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|Any CPU.Build.0 = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|x64.ActiveCfg = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|x64.Build.0 = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|x86.ActiveCfg = Release|Any CPU
{4717558C-0A09-4D4D-BB1A-C0FD9241070E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -172,5 +199,7 @@ Global
{E4DDC34C-9AB6-4050-A927-3DF69804708A} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
{5B45CC23-3551-4D0F-B3CC-22659C2A8BA3} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
{B705FA6C-19CA-44A8-882C-6CE26A5379C9} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
{928CF396-C0E1-488E-BCC1-CF0279CF2DD0} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
{4717558C-0A09-4D4D-BB1A-C0FD9241070E} = {64EABE09-B1E0-4476-A213-32C93E46E7C3}
EndGlobalSection
EndGlobal

View File

@@ -16,7 +16,8 @@
- 当前仓库不再恢复旧 `50001/TCP+JSON` 监听入口;旧 `ControllerClient` 逆向资料只作为接口语义参考,不作为运行时目标。
- 宿主只保留 ASP.NET Core HTTP 控制器层,以及其后端 `Flyshot.ControllerClientCompat` 兼容服务。
- `ExecuteTrajectory` 与 `ExecuteFlyShotTraj` 已经接入 `Planning + Triggering + Runtime` 链路;Web 状态页已通过 `/status` 和 `/api/status/snapshot` 暴露当前兼容层与运行时状态。
- `Flyshot.Core.Planning` 的 ICSP / self-adapt-icsp 轨迹已经完成旧系统导出轨迹对齐;`doubles` 仍未实现。
- `Flyshot.Core.Planning` 保留规划公共模型、样条、采样、导出和运动学能力;C# ICSP / self-adapt-icsp 已拆分到可选项目 `Flyshot.CSharp.Planning`。
- `Flyshot.Native.Planning` 已提供默认 Rust ICSP 规划入口,通过 C ABI 调用 `flyshot_planning`;普通轨迹和飞拍轨迹的默认 `method="icsp"` 使用 Rust 后端,`method="icsp-csharp"` 和 `method="self-adapt-icsp"` 只有显式注册 C# 后端时可用。
- `Flyshot.Runtime.Fanuc` 已固化 `10010 / 10012 / 60015` 基础协议帧编解码。`10010` 状态通道以 `j519 协议.pcap` 和 `Rvbust/uttc-20260428/20260428.pcap` 真机抓包确认为 90B 固定帧。
- 2026-04-28 UTTC 抓包确认:UDP 60015 命令 `target[0..5]` 为关节角度制 `deg`,`JointDetialTraj.txt` 为弧度制 `rad`,`speed_ratio=0.7` 体现为 UDP 下发时间轴约 `1.427730x` 拉伸;2026-04-30 实体机确认 `speed_ratio` 不影响生成的 `JointTraj.txt` 规划时长,当前实际生成约 `7.4s` 轨迹。
- 2026-04-30 本机 `50001/TCP+JSON` 抓包确认:`ExecuteFlyShotTraj(save_traj=true,use_cache=false)` 请求只显式携带规划方法、保存、缓存和等待参数,不携带 `JointLimits / acc_limit / jerk_limit / velocity / acceleration / jerk`。因此旧系统不可见的有效规划限制不再继续假设来自公开链路,新系统按 replacement-only 内部参数限制规划加速度。
@@ -64,8 +65,9 @@
- [x] 打通最小宿主与 `/healthz`
- [x] 建立领域模型与模块边界
- [x] 落地配置兼容与机器人模型解析
- [x] 落地 ICSP / self-adapt-icsp 轨迹规划与飞拍触发时间轴
- [x] 落地 Rust ICSP、可选 C# ICSP / self-adapt-icsp 轨迹规划与飞拍触发时间轴
- [x] 完成 ICSP 轨迹导出结果与旧系统对齐
- [x] 增加 Rust `cdylib` ICSP 核心、C# P/Invoke 适配和 C#/Rust 自动对拍
- [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入 FANUC 运行时链路
- [x] 落地 Web 状态页
- [x] 落地浏览器内 OpenAPI 自动驱动的接口调试页(`/debug`),与 `Swagger:Enabled` 同步可见
@@ -79,6 +81,33 @@
- [x] 完成真机优先与离线飞拍规划改造:`RealReady / OfflineReady / ModelLoadFailed` 状态、固定轨迹资产、完整离线规划响应和 Debug 页面
- [x] 删除生产仿真切换 HTTP 入口及旧 `/init_mpc_robt`,统一使用 `/init_mpc_robot`
### 规划后端与 Rust ICSP 原生库构建
普通轨迹或飞拍轨迹的默认参数 `method="icsp"` 会调用 Rust ICSP 后端。旧 `method="icsp_rust"` 已不再作为正式入口,调用时会提示改用 `method="icsp"`;Rust 原生库缺失时会明确报错,不会静默回退 C#。
C# `ICspPlanner` 与 `SelfAdaptIcspPlanner` 已拆分到 `Flyshot.CSharp.Planning.dll`。默认服务项目不引用该 DLL;只有在部署方显式引用并调用 `AddCSharpPlanningBackends()` 后,`method="icsp-csharp"` 与 `method="self-adapt-icsp"` 才可用。
Windows x64 需要安装:
- `rustup` 的 `stable-x86_64-pc-windows-msvc` 工具链。
- Visual Studio 或 Visual Studio Build Tools 的 C++ x64 编译工具和 Windows SDK。
在仓库根目录执行:
```powershell
.\build\Copy-NativePlanningArtifacts.ps1 -Configuration Release
```
脚本会执行 `cargo build --release`,构建
`native/flyshot-native-planning/target/release/flyshot_planning.dll`,
并默认复制到 `src/Libs/flyshot_planning.dll` 与
`tests/Flyshot.Core.Tests/bin/Release/net8.0/`。发布服务时可通过
`-DestinationDirectories` 把原生库复制到实际发布目录。
Linux x64 在已安装 `stable-x86_64-unknown-linux-gnu` 工具链的 Linux 环境执行同一脚本,
产物为 `libflyshot_planning.so`,默认写入 `src/Libs/libflyshot_planning.so`。
Windows 第一阶段不要求交叉编译 Linux 原生库。
剩余 Todo:
1. 配置与测试基线

View File

@@ -0,0 +1,76 @@
<#
.SYNOPSIS
构建 Rust ICSP 原生规划库并复制到 src/Libs 与可选目标目录。
.DESCRIPTION
在 Windows x64 上通过 cargo release 构建 flyshot_planning,默认写入
src/Libs/flyshot_planning.dll;Linux x64 写入 src/Libs/libflyshot_planning.so。
可通过 -DestinationDirectories 额外复制到测试或发布输出目录。
.PARAMETER Configuration
传递给 dotnet 相关路径的构建配置名,默认 Release。
.PARAMETER DestinationDirectories
除 src/Libs 外需要覆盖复制的额外目标目录列表。
#>
[CmdletBinding()]
param(
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Release',
[string[]]$DestinationDirectories = @()
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$cargoManifest = Join-Path $repositoryRoot 'native\flyshot-native-planning\Cargo.toml'
$libsDirectory = Join-Path $repositoryRoot 'src\Libs'
$cargoProfile = if ($Configuration -eq 'Debug') { 'debug' } else { 'release' }
$targetDirectory = Join-Path $repositoryRoot "native\flyshot-native-planning\target\$cargoProfile"
if ($IsLinux -or (-not $IsWindows -and -not $IsMacOS))
{
$artifactFileName = 'libflyshot_planning.so'
}
else
{
$artifactFileName = 'flyshot_planning.dll'
}
$builtArtifactPath = Join-Path $targetDirectory $artifactFileName
$libsArtifactPath = Join-Path $libsDirectory $artifactFileName
Write-Host "正在构建 Rust 原生规划库 ($Configuration) ..."
& cargo build --manifest-path $cargoManifest --$Configuration
if ($LASTEXITCODE -ne 0)
{
throw "cargo build 失败,退出码:$LASTEXITCODE"
}
if (-not (Test-Path -LiteralPath $builtArtifactPath -PathType Leaf))
{
throw "未找到构建产物:$builtArtifactPath"
}
New-Item -ItemType Directory -Path $libsDirectory -Force | Out-Null
Copy-Item -LiteralPath $builtArtifactPath -Destination $libsArtifactPath -Force
Write-Host "已复制到:$libsArtifactPath"
$defaultTestOutput = Join-Path $repositoryRoot "tests\Flyshot.Core.Tests\bin\$Configuration\net8.0"
$allDestinations = @($DestinationDirectories) + @($defaultTestOutput)
foreach ($destination in $allDestinations | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
{
$absoluteDestination = [System.IO.Path]::GetFullPath($destination)
if (-not (Test-Path -LiteralPath $absoluteDestination -PathType Container))
{
Write-Host "跳过不存在的目标目录:$absoluteDestination"
continue
}
Copy-Item -LiteralPath $builtArtifactPath -Destination (Join-Path $absoluteDestination $artifactFileName) -Force
Write-Host "已复制到:$absoluteDestination\$artifactFileName"
}

View File

@@ -130,11 +130,11 @@
以下内容是当前新实现的状态,不反推为旧系统事实:
- `Flyshot.ControllerClientCompat` 继续作为 HTTP 控制器后端兼容服务,不启动 `50001/TCP+JSON` 监听。
- `ExecuteTrajectory` 会先通过 `ICspPlanner` 规划普通轨迹,再把 `TrajectoryResult` 和最终关节位置交给 `IControllerRuntime`。
- `ExecuteFlyShotTraj` 会从上传轨迹目录取出轨迹,通过 `SelfAdaptIcspPlanner` 规划并用 `ShotTimelineBuilder` 生成 `ShotEvent` / `TrajectoryDoEvent`。
- `ExecuteTrajectory` 会先通过默认 Rust `method="icsp"` 后端规划普通轨迹,再把 `TrajectoryResult` 和最终关节位置交给 `IControllerRuntime`。
- `ExecuteFlyShotTraj` 会从上传轨迹目录取出轨迹,通过已注册的规划后端生成轨迹,并用 `ShotTimelineBuilder` 生成 `ShotEvent` / `TrajectoryDoEvent`。
- HTTP 控制器已经按公开文档和抓包补齐 `ExecuteTrajectory(method, save_traj)` 与 `ExecuteFlyShotTraj(move_to_start, method, save_traj, use_cache, wait)` 参数,并继续兼容旧的裸 waypoint 数组和只传 `name` 的请求体。
- 规划阶段会继续消费旧配置中的 `acc_limit / jerk_limit`。如果现场需要复现旧服务端不可见的保守约束,replacement 设计上使用内部 `planning_acceleration_scale` 限制规划加速度;该字段不属于旧 `ControllerClient` 公开 API,也不会通过 `50001` JSON 下发。
- `method="icsp"` 与 `method="self-adapt-icsp"` 已接入当前规划器;`method="doubles"` 会被识别但返回显式未实现,不会静默降级成 ICSP。
- `method="icsp"` 已接入默认 Rust 后端;`method="icsp-csharp"` 与 `method="self-adapt-icsp"` 只有显式注册 `Flyshot.CSharp.Planning` 后端时可用;`method="doubles"` 会被识别但返回显式未实现,不会静默降级成 ICSP。
- `Flyshot.Runtime.Fanuc.Protocol` 已经固化 `10010` 状态帧、`10012` 命令帧和 `60015` J519 数据包的基础编解码,并使用逆向抓包样本覆盖最小测试;`10010` 当前现场确认固定 90B。
- `Flyshot.Runtime.Fanuc` 已具备基础 Socket 客户端、程序启停、速度倍率/TCP/IO 参数命令和 J519 状态包驱动发送链路;稠密轨迹下发已按 `speed_ratio` 推进轨迹时间,并在收到机器人 UDP status 后按该 status sequence 回发命令。真实 R30iB 全流程现场联调仍需执行。
- 2026-04-28 `UTTC_MS11` 抓包确认 J519 命令目标为 `deg`、导出 `JointDetialTraj.txt` 为 `rad`,`speed_ratio=0.5/0.7/1.0` 分别形成 `1851/1322/926` 个主运行 J519 包;实际执行不发送 464 行导出点,而是在 8ms 实发周期上按 `t_traj = t_send * speed_ratio` 采样,包数为 `floor(duration / (0.008 * speed_ratio)) + 1`。

View File

@@ -0,0 +1,330 @@
# Rust ICSP Native 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:** 为现有 `ICspPlanner` 增加一套基于 Rust `cdylib` 的原生 ICSP 规划实现,并通过 C ABI + C# P/Invoke 完成可验证的对拍接入,同时不改变当前默认生产路径。
**Architecture:** 新增 `native/flyshot-native-planning` Rust 原生库承载 clamped-zero 三次样条与 ICSP 迭代核心;新增 `src/Flyshot.Native.Planning` 托管互操作项目负责库加载、数组编排、错误转换和结果复制。现有 `Flyshot.Core.Planning.ICspPlanner` 保持不变,`RustIcspPlanner` 仅作为显式新入口并在测试中与托管实现逐项对拍。
**Tech Stack:** Rust `cdylib`、C ABI、.NET 8、`LibraryImport`、`SafeHandle`、xUnit、PowerShell、Cargo
---
### Task 1: 建立原生与托管骨架
**Files:**
- Create: `native/flyshot-native-planning/Cargo.toml`
- Create: `native/flyshot-native-planning/src/lib.rs`
- Create: `native/flyshot-native-planning/src/error.rs`
- Create: `native/flyshot-native-planning/src/ffi.rs`
- Create: `native/flyshot-native-planning/src/icsp.rs`
- Create: `native/flyshot-native-planning/src/spline.rs`
- Create: `src/Flyshot.Native.Planning/Flyshot.Native.Planning.csproj`
- Create: `src/Flyshot.Native.Planning/NativeIcspMethods.cs`
- Create: `src/Flyshot.Native.Planning/NativeIcspSafeHandle.cs`
- Create: `src/Flyshot.Native.Planning/NativePlanningException.cs`
- Create: `src/Flyshot.Native.Planning/NativePlanningLibraryResolver.cs`
- Modify: `FlyshotReplacement.sln`
- Modify: `tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj`
- [x] **Step 1: 先写托管侧失败测试,锁定缺少原生库时的报错边界**
```csharp
[Fact]
public void RustIcspPlanner_ThrowsClearError_WhenNativeLibraryIsMissing()
{
var planner = new RustIcspPlanner(
options: NativeIcspPlannerOptions.Default,
libraryResolver: new NativePlanningLibraryResolver(_ => "missing-library"));
var exception = Assert.Throws<NativePlanningException>(() => planner.Plan(CreateSimpleRequest()));
Assert.Contains("missing-library", exception.Message);
}
```
- [x] **Step 2: 运行单测确认当前失败**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_ThrowsClearError_WhenNativeLibraryIsMissing -v minimal`
Expected: FAIL,提示 `RustIcspPlanner` 或互操作类型不存在
- [x] **Step 3: 创建 Rust `cdylib` 和托管互操作项目骨架**
```toml
[package]
name = "flyshot_planning"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
panic = "abort"
```
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
</ItemGroup>
</Project>
```
- [x] **Step 4: 添加最小库解析与异常包装实现**
```csharp
public sealed class NativePlanningException : Exception
{
public NativePlanningException(string message, int errorCode, Exception? innerException = null)
: base(message, innerException)
{
ErrorCodeValue = errorCode;
}
public int ErrorCodeValue { get; }
}
```
```csharp
public sealed class NativePlanningLibraryResolver
{
private readonly Func<RuntimeInformation, string> _resolveLibraryPath;
}
```
- [x] **Step 5: 重新运行单测,确认错误已变为清晰的原生库缺失异常**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_ThrowsClearError_WhenNativeLibraryIsMissing -v minimal`
Expected: PASS
### Task 2: 先做样条基础能力
**Files:**
- Modify: `native/flyshot-native-planning/src/spline.rs`
- Modify: `native/flyshot-native-planning/src/lib.rs`
- Create: `native/flyshot-native-planning/tests/spline_tests.rs`
- [x] **Step 1: 先写 Rust 失败测试,锁定 clamped-zero 样条边界和值/导数接口**
```rust
#[test]
fn clamped_zero_spline_hits_waypoints_and_zero_boundary_velocity() {
let times = [0.0, 1.0, 2.0, 3.0];
let values = [0.0, 1.0, 0.5, 2.0];
let spline = CubicSpline1D::new_clamped_zero(&times, &values).unwrap();
assert_abs_diff_eq!(spline.position(0.0), 0.0, epsilon = 1e-12);
assert_abs_diff_eq!(spline.position(3.0), 2.0, epsilon = 1e-12);
assert_abs_diff_eq!(spline.velocity(0.0), 0.0, epsilon = 1e-12);
assert_abs_diff_eq!(spline.velocity(3.0), 0.0, epsilon = 1e-12);
}
```
- [x] **Step 2: 运行 Rust 测试确认失败**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml spline_tests::clamped_zero_spline_hits_waypoints_and_zero_boundary_velocity -- --exact`
Expected: FAIL,提示样条类型或构造函数不存在
- [x] **Step 3: 实现三对角求解、样条系数和每段导数峰值接口**
```rust
pub struct CubicSpline1D {
knots: Vec<f64>,
coefficients: Vec<[f64; 4]>,
}
impl CubicSpline1D {
pub fn new_clamped_zero(times: &[f64], values: &[f64]) -> Result<Self, NativePlanningError> { /* ... */ }
pub fn position(&self, time: f64) -> f64 { /* ... */ }
pub fn velocity(&self, time: f64) -> f64 { /* ... */ }
pub fn acceleration(&self, time: f64) -> f64 { /* ... */ }
pub fn jerk(&self, time: f64) -> f64 { /* ... */ }
pub fn segment_peak_derivatives(&self, segment_index: usize) -> [f64; 3] { /* ... */ }
}
```
- [x] **Step 4: 重新运行 Rust 样条测试**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml spline_tests::clamped_zero_spline_hits_waypoints_and_zero_boundary_velocity -- --exact`
Expected: PASS
### Task 3: 实现纯 Rust ICSP 核心
**Files:**
- Modify: `native/flyshot-native-planning/src/icsp.rs`
- Create: `native/flyshot-native-planning/tests/icsp_tests.rs`
- [x] **Step 1: 先写 Rust 失败测试,锁定初始段时长、scale 计算和未收敛路径**
```rust
#[test]
fn icsp_returns_same_iteration_count_as_managed_baseline_fixture() {
let input = test_fixture_three_segment_case();
let result = plan_icsp(&input).unwrap();
assert_eq!(result.iterations, 2);
assert_abs_diff_eq!(result.threshold, 0.0, epsilon = 1e-12);
}
```
- [x] **Step 2: 运行 Rust ICSP 测试确认失败**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml icsp_tests::icsp_returns_same_iteration_count_as_managed_baseline_fixture -- --exact`
Expected: FAIL,提示 `plan_icsp` 或结果结构不存在
- [x] **Step 3: 实现最小 ICSP 纯算法结构,逐项复刻 C# 收敛语义**
```rust
pub struct IcspInput<'a> {
pub waypoints: &'a [f64],
pub waypoint_count: usize,
pub dof: usize,
pub velocity_limits: &'a [f64],
pub acceleration_limits: &'a [f64],
pub jerk_limits: &'a [f64],
pub options: IcspOptions,
}
pub fn plan_icsp(input: &IcspInput<'_>) -> Result<IcspPlanResult, NativePlanningError> { /* ... */ }
```
- [x] **Step 4: 重新运行 Rust ICSP 测试**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml icsp_tests::icsp_returns_same_iteration_count_as_managed_baseline_fixture -- --exact`
Expected: PASS
### Task 4: 接通 C ABI 和托管结果复制
**Files:**
- Modify: `native/flyshot-native-planning/src/ffi.rs`
- Modify: `native/flyshot-native-planning/src/error.rs`
- Modify: `src/Flyshot.Native.Planning/NativeIcspMethods.cs`
- Modify: `src/Flyshot.Native.Planning/NativeIcspSafeHandle.cs`
- Create: `src/Flyshot.Native.Planning/NativeIcspInteropModels.cs`
- Create: `src/Flyshot.Native.Planning/RustIcspPlanner.cs`
- Create: `tests/Flyshot.Core.Tests/RustIcspPlannerInteropTests.cs`
- [x] **Step 1: 先写托管失败测试,锁定数组布局、结果复制和释放路径**
```csharp
[Fact]
public void RustIcspPlanner_ReturnsManagedPlannedTrajectory_FromNativeResult()
{
var planner = CreateRustPlannerAgainstBuiltNativeLibrary();
var result = planner.Plan(CreateSimpleRequest());
Assert.Equal(PlanningMethod.Icsp, result.Method);
Assert.Equal(result.PlannedWaypointCount, result.WaypointTimes.Count);
}
```
- [x] **Step 2: 运行单测确认失败**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_ReturnsManagedPlannedTrajectory_FromNativeResult -v minimal`
Expected: FAIL,提示 P/Invoke 签名、结果结构或释放逻辑未实现
- [x] **Step 3: 实现 C ABI 入口、线程局部错误文本和 `SafeHandle` 释放**
```rust
#[no_mangle]
pub extern "C" fn flyshot_icsp_plan(/* ... */) -> i32 { /* ... */ }
#[no_mangle]
pub extern "C" fn flyshot_icsp_result_free(result: *mut FlyshotIcspResult) { /* ... */ }
#[no_mangle]
pub extern "C" fn flyshot_last_error_message() -> *const c_char { /* ... */ }
```
```csharp
[LibraryImport(NativeLibraryName, EntryPoint = "flyshot_icsp_plan")]
internal static unsafe partial int FlyshotIcspPlan(/* ... */);
```
- [x] **Step 4: 重新运行托管互操作测试**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_ReturnsManagedPlannedTrajectory_FromNativeResult -v minimal`
Expected: PASS
### Task 5: 增加 C#/Rust 对拍测试和构建复制脚本
**Files:**
- Modify: `tests/Flyshot.Core.Tests/PlanningCompatibilityTests.cs`
- Create: `tests/Flyshot.Core.Tests/RustIcspPlannerParityTests.cs`
- Create: `build/Copy-NativePlanningArtifacts.ps1`
- Modify: `README.md`
- [x] **Step 1: 先写失败测试,锁定 C# 与 Rust 在基础样本上的逐项一致**
```csharp
[Fact]
public void RustIcspPlanner_MatchesManagedIcspPlanner_ForCompatibilityFixture()
{
var request = CreateCompatibilityRequest();
var managed = new ICspPlanner().Plan(request);
var native = CreateRustPlannerAgainstBuiltNativeLibrary().Plan(request);
AssertTrajectoryMatches(managed, native, tolerance: 1e-9);
}
```
- [x] **Step 2: 运行对拍测试确认失败**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_MatchesManagedIcspPlanner_ForCompatibilityFixture -v minimal`
Expected: FAIL,提示数值未对齐或原生库复制失败
- [x] **Step 3: 补齐原生库复制脚本、测试输出复制规则和对拍辅助断言**
```powershell
param(
[string]$Configuration = "Release"
)
cargo build --manifest-path native/flyshot-native-planning/Cargo.toml --release
Copy-Item native/flyshot-native-planning/target/release/flyshot_planning.dll tests/Flyshot.Core.Tests/bin/$Configuration/net8.0/ -Force
```
- [x] **Step 4: 重新运行对拍测试**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj --filter RustIcspPlanner_MatchesManagedIcspPlanner_ForCompatibilityFixture -v minimal`
Expected: PASS
### Task 6: 解决方案级验证
**Files:**
- Modify: `README.md`
- Modify: `docs/superpowers/specs/2026-06-22-rust-icsp-native-design.md`
- [x] **Step 1: 运行 Rust 全量测试**
Run: `cargo test --manifest-path native/flyshot-native-planning/Cargo.toml`
Expected: PASS
- [ ] **Step 2: 运行 Core 测试**
Run: `dotnet test tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj -v minimal`
Expected: PASS
当前验证:新增 Rust 相关测试全部通过;Core 全套仍保留实施前已存在的两项
`FlyshotExecutionSendSequenceBuilder` speed-ratio 失败,本计划未修改该行为。
- [x] **Step 3: 运行解决方案构建**
Run: `dotnet build FlyshotReplacement.sln -v minimal`
Expected: PASS
- [x] **Step 4: 更新文档,记录本地构建、库复制和当前上线边界**
```markdown
- Rust 规划器当前仅显式使用,不替换默认 `ICspPlanner`
- Windows 需要 `flyshot_planning.dll`
- Linux 需要 `libflyshot_planning.so`
```

View File

@@ -0,0 +1,271 @@
# Rust ICSP 原生规划核心设计
## 1. 目标
在不改变现有上层业务接口的前提下,使用 Rust 重新实现当前 C# 版本的 ICSP 和 clamped-zero 三次样条算法,并通过稳定的 C ABI 供 .NET 8 调用。
第一阶段必须实现:
- Rust `cdylib` 中的 clamped-zero 三次样条系数求解。
- 样条位置求值和每段一至三阶导数绝对值峰值解析计算。
- ICSP 初始分段时长、迭代缩放、最优结果保存和最终收敛判定。
- C# P/Invoke 适配层。
- Rust 与现有 C# 算法的自动对拍测试。
- Windows x64 和 Linux x64 原生库加载边界。
第一阶段不实现:
- `SelfAdaptIcspPlanner` 的补中点逻辑。
- 轨迹稠密采样、J519 发送采样和触发绑定。
- 正运动学、MoveJoint 和 MovePose。
- FANUC 协议、Socket 和运行时状态机。
- 删除或替换现有 C# ICSP 实现。
## 2. 总体架构
新增原生算法模块和 C# 适配模块:
```text
ControllerClientTrajectoryOrchestrator
|
ICSP 规划接口
/ \
C# ICspPlanner RustIcspPlanner
|
Flyshot.Native.Interop
|
C ABI
|
flyshot_planning
Rust cdylib
```
现有 `ICspPlanner` 保持不变,继续作为行为基准和回退实现。Rust 规划器返回的数据仍由 C# 组装成现有 `PlannedTrajectory`,因此领域层、兼容层和运行时不感知原生对象。
## 3. Rust 模块边界
原生模块建议位于:
```text
native/
flyshot-native-planning/
Cargo.toml
src/
lib.rs
error.rs
ffi.rs
icsp.rs
spline.rs
```
各文件职责:
- `spline.rs`:三对角方程求解、样条系数、位置求值、解析导数峰值。
- `icsp.rs`:纯 Rust ICSP 输入、迭代规划和结果模型。
- `ffi.rs`:C ABI 数据结构、指针校验、结果分配与释放。
- `error.rs`:原生错误分类和 UTF-8 错误信息。
- `lib.rs`:只导出稳定 ABI,不导出内部算法符号。
Rust 内部算法不依赖 .NET 类型、JSON、日志框架、网络或文件系统。
## 4. C ABI 设计
ABI 使用固定宽度整数、C 布局结构和连续 `double` 数组。路点输入采用行优先扁平布局:
```text
[waypoint0_joint0, waypoint0_joint1, ...,
waypoint1_joint0, waypoint1_joint1, ...]
```
主要入口:
```c
int32_t flyshot_icsp_plan(
const double* waypoints,
int32_t waypoint_count,
int32_t dof,
const double* velocity_limits,
const double* acceleration_limits,
const double* jerk_limits,
const FlyshotIcspOptions* options,
FlyshotIcspResult** result);
void flyshot_icsp_result_free(FlyshotIcspResult* result);
const char* flyshot_last_error_message(void);
```
`FlyshotIcspOptions` 包含:
- `threshold`
- `max_iterations`
- `enforce_final_scale`
- `final_scale_tolerance`
`FlyshotIcspResult` 包含:
- `waypoint_times` 及长度
- `segment_durations` 及长度
- `segment_scales` 及长度
- `iterations`
- `threshold`
调用成功返回 `0`。输入非法、数值计算失败、未收敛和内部异常使用不同错误码。错误文本由线程局部存储保存,只保证在当前线程下一次原生调用前有效。
所有结果内存都由 Rust 分配,并且只能通过 `flyshot_icsp_result_free` 释放。C# 不直接释放 Rust 分配的数组。
所有可预期失败必须在 FFI 内转换为错误码,FFI 路径不得依赖 panic 表达业务错误。Debug 和测试构建使用 `catch_unwind` 检测遗漏的 panic;Release 使用 `panic=abort`,此时 `catch_unwind` 不能恢复 panic,因此实现必须通过边界校验避免索引越界、空指针解引用和显式 `unwrap`。任何构建模式都不得让 Rust unwind 穿过 C ABI。
## 5. C# 适配层
新增一个只负责原生互操作的 C# 项目,建议命名为:
```text
src/Flyshot.Native.Planning/
```
包含:
- `NativeIcspMethods`:使用 `LibraryImport` 声明 C ABI。
- `NativeIcspSafeHandle`:封装 Rust 结果指针和释放函数。
- `RustIcspPlanner`:把 `TrajectoryRequest` 转换为连续数组,再把结果转换为 `PlannedTrajectory`。
- `NativePlanningLibraryResolver`:按当前操作系统和体系结构解析原生库。
- `NativePlanningException`:携带原生错误码和错误文本。
原生库逻辑名称固定为 `flyshot_planning`:
- Windows:`flyshot_planning.dll`
- Linux:`libflyshot_planning.so`
C# 适配层负责:
- 检查输入自由度和数组长度。
- 固定数组,避免调用期间被 GC 移动。
- 将 Rust 返回数据复制到托管数组。
- 无论成功或失败都正确释放结果句柄。
- 将原生错误转换为包含诊断信息的 C# 异常。
`RustIcspPlanner` 的构造参数和 `Plan` 行为应与 `ICspPlanner` 对齐,但第一阶段不自动替换现有依赖注入注册。
## 6. 数值一致性
Rust 实现必须逐项复刻当前 C# 行为:
- 初始段时长使用相邻路点的关节空间欧氏距离。
- 使用 clamped-zero 边界条件求三次样条。
- 每轮解析计算各段速度、加速度和 jerk 峰值。
- 分段倍率分别使用一次方、平方根和立方根。
- 收敛指标为 `sum(abs(scale - 1))`。
- 始终保存历史最小收敛指标对应的结果。
- 迭代范围与当前 C# 保持一致,即包含第 `max_iterations` 轮。
- 最终按 `global_scale > 1 + tolerance` 判定是否失败。
第一阶段采用以下对拍容差:
- 路点时间:绝对误差不超过 `1e-9`。
- 段时长:绝对误差不超过 `1e-9`。
- 段缩放:绝对误差不超过 `1e-9`。
- 最终 threshold:绝对误差不超过 `1e-9`。
- 迭代次数:必须完全一致。
如果真实样本因浮点指令或编译器优化无法满足 `1e-9`,必须先定位首个分歧计算步骤,再基于证据调整容差,不直接放宽整个测试。
## 7. 测试策略
遵循 TDD,先增加失败测试,再实现 Rust 和 C# 适配。
测试分四层:
1. Rust 单元测试
- 三次样条边界值。
- 一至三阶导数。
- 解析峰值。
- ICSP 收敛与不收敛。
- 非法指针之外的纯算法输入校验。
2. Rust FFI 测试
- 正常调用和结果释放。
- 空指针、错误维度、非有限数。
- 错误码与错误文本。
- 重复释放由 C# `SafeHandle` 防止,不把未定义行为包装成可接受用法。
3. C# 互操作测试
- 原生库加载。
- 托管数组到 C ABI 的转换。
- 结果复制和释放。
- 原生错误到 C# 异常的转换。
4. C#/Rust 对拍测试
- 仓库中的稳定规划样本。
- 当前 `PlanningCompatibilityTests` 使用的基础输入。
- 多自由度、多路点和极限参数。
- `maxIterations=0` 的未收敛路径。
- 至少一个父目录真实样本固化后的 golden case。
对拍测试必须同时调用现有 `ICspPlanner` 和 `RustIcspPlanner`,不能把预期值只写成 Rust 自身输出。
## 8. 构建与发布
本地构建脚本负责:
- 执行 `cargo build --release`。
- 根据目标平台复制原生库到 C# 测试和发布输出目录。
- Windows x64 构建 Rust `x86_64-pc-windows-msvc`。
- Linux x64 构建 Rust `x86_64-unknown-linux-gnu`。
第一阶段不要求在 Windows 主机上完成 Linux 交叉编译;Linux 原生库可以在 Linux 构建环境生成。仓库必须提供对应命令和 CI/离线构建说明。
发布时原生库与托管程序集放在同一部署目录。现有 Obfuscar 继续处理 `Flyshot.*.dll` 托管程序集,不处理 Rust 原生库。
Rust Release 配置启用:
```toml
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
panic = "abort"
```
## 9. 回退与上线边界
第一阶段只提供显式的 Rust 规划器,不改变生产默认算法。完成全部对拍和跨平台验证后,再单独设计配置切换:
```text
Planning:Backend = Managed | Rust
```
原生库缺失、架构不匹配或加载失败时,显式选择 Rust 后应返回清晰错误,不静默回退。静默回退会掩盖部署缺陷,也无法确认现场实际使用的是哪套算法。
保留 C# 实现至少经过一个完整现场验证周期。后续是否删除托管算法,需要独立评审,不属于本设计范围。
## 10. 验收标准
- Rust 单元测试全部通过。
- C# 原生互操作测试全部通过。
- 选定样本的 C#/Rust 对拍全部通过。
- `dotnet test` 和解决方案构建通过。
- Windows x64 实际加载 `.dll` 并完成规划。
- Linux x64 实际加载 `.so` 并完成规划。
- 缺少原生库时能得到明确且可诊断的错误。
- 现有 C# `ICspPlanner` 和上层运行链路行为不变。
## 11. 第一阶段落地状态(2026-06-23)
当前已落地:
- `native/flyshot-native-planning` Rust `cdylib`,包含 clamped-zero 样条、
一至三阶导数解析峰值、ICSP 迭代和稳定 C ABI。
- `src/Flyshot.Native.Planning` 托管互操作项目,包含显式平台解析、
`LibraryImport`、`SafeHandle`、错误码转换和结果复制。
- Rust 样条、ICSP、FFI 测试,以及 C#/Rust `1e-9` 自动对拍测试。
- `build/Copy-NativePlanningArtifacts.ps1`,用于初始化 Windows MSVC 环境、
构建原生库并复制到测试或发布目录。
- Windows x64 已实际生成并加载 `flyshot_planning.dll` 完成规划。
当前上线边界保持不变:
- 默认生产路径继续使用 C# `ICspPlanner`。
- Rust 规划器只允许显式选择,库缺失、路径不一致或架构不支持时返回明确错误。
- Linux x64 的源码、库名和构建脚本边界已落地,但 `.so` 的实际加载验证必须在
Linux x64 构建环境执行后才能标记完成。

View File

@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "flyshot_planning"
version = "0.1.0"

View File

@@ -0,0 +1,13 @@
[package]
name = "flyshot_planning"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
panic = "abort"

View File

@@ -0,0 +1,33 @@
//! 原生规划错误定义。
use std::fmt::{Display, Formatter};
/// 表示 Rust 规划核心中可预期、可转换为 C ABI 错误码的失败。
#[derive(Debug, Clone, PartialEq)]
pub enum NativePlanningError {
/// 表示输入参数不满足算法前置条件。
InvalidInput(String),
/// 表示数值计算无法继续。
NumericalFailure(String),
/// 表示规划迭代结束后仍未满足约束。
NotConverged(String),
}
impl Display for NativePlanningError {
/// 将错误格式化为可供托管侧诊断的中文文本。
///
/// # 参数
/// - `formatter`: 接收格式化文本的输出缓冲区。
///
/// # 返回值
/// 格式化成功时返回 `Ok(())`,否则返回底层格式化错误。
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidInput(message) => write!(formatter, "输入非法:{message}"),
Self::NumericalFailure(message) => write!(formatter, "数值计算失败:{message}"),
Self::NotConverged(message) => write!(formatter, "ICSP 未收敛:{message}"),
}
}
}
impl std::error::Error for NativePlanningError {}

View File

@@ -0,0 +1,436 @@
//! C ABI 边界定义。
use std::cell::RefCell;
use std::ffi::{c_char, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::ptr;
use std::slice;
use crate::error::NativePlanningError;
use crate::icsp::{plan_icsp, IcspInput, IcspOptions};
/// 原生调用成功时返回的固定错误码。
pub const FLYSHOT_SUCCESS: i32 = 0;
/// 输入指针、维度或数值非法时返回的错误码。
pub const FLYSHOT_ERROR_INVALID_INPUT: i32 = 1;
/// 样条或 ICSP 数值计算无法继续时返回的错误码。
pub const FLYSHOT_ERROR_NUMERICAL_FAILURE: i32 = 2;
/// 迭代完成但最终 scale 仍超限时返回的错误码。
pub const FLYSHOT_ERROR_NOT_CONVERGED: i32 = 3;
/// FFI 捕获到未预期 panic 时返回的错误码。
pub const FLYSHOT_ERROR_INTERNAL: i32 = 4;
thread_local! {
/// 保存当前线程最近一次原生错误文本,指针仅在下一次原生调用前有效。
static LAST_ERROR_MESSAGE: RefCell<CString> = RefCell::new(CString::default());
}
/// 表示 C ABI 输入的 ICSP 迭代选项。
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct FlyshotIcspOptions {
/// 收敛指标停止阈值。
pub threshold: f64,
/// 最大迭代索引,必须为非负数。
pub max_iterations: i32,
/// 非零表示启用最终 scale 检查。
pub enforce_final_scale: i32,
/// 最终 scale 允许的非负容差。
pub final_scale_tolerance: f64,
}
impl Default for FlyshotIcspOptions {
/// 返回与托管 ICSP 默认行为一致的 C ABI 选项。
///
/// # 返回值
/// 可直接传入 `flyshot_icsp_plan` 的默认选项。
fn default() -> Self {
let options = IcspOptions::default();
Self {
threshold: options.threshold,
max_iterations: options.max_iterations as i32,
enforce_final_scale: i32::from(options.enforce_final_scale),
final_scale_tolerance: options.final_scale_tolerance,
}
}
}
/// 表示 Rust 分配并通过 C ABI 返回的 ICSP 结果。
#[repr(C)]
#[derive(Debug)]
pub struct FlyshotIcspResult {
/// 路点绝对时间连续数组指针。
pub waypoint_times: *mut f64,
/// 路点时间数组长度。
pub waypoint_times_len: i32,
/// 分段时长连续数组指针。
pub segment_durations: *mut f64,
/// 分段时长数组长度。
pub segment_durations_len: i32,
/// 分段缩放连续数组指针。
pub segment_scales: *mut f64,
/// 分段缩放数组长度。
pub segment_scales_len: i32,
/// 历史最优结果对应的迭代轮数。
pub iterations: i32,
/// 历史最小收敛指标。
pub threshold: f64,
}
/// 通过稳定 C ABI 执行 ICSP 规划。
///
/// # Safety
/// 调用方必须保证所有非空输入指针在调用期间指向满足声明长度的可读连续数组,
/// `result` 必须指向可写的结果指针槽位;返回的结果只能交给
/// `flyshot_icsp_result_free` 释放。
#[no_mangle]
pub unsafe extern "C" fn flyshot_icsp_plan(
waypoints: *const f64,
waypoint_count: i32,
dof: i32,
velocity_limits: *const f64,
acceleration_limits: *const f64,
jerk_limits: *const f64,
options: *const FlyshotIcspOptions,
result: *mut *mut FlyshotIcspResult,
) -> i32 {
clear_last_error();
// Debug/测试构建可恢复遗漏 panic;Release panic=abort 下仍依赖边界校验避免 panic。
match catch_unwind(AssertUnwindSafe(|| unsafe {
flyshot_icsp_plan_impl(
waypoints,
waypoint_count,
dof,
velocity_limits,
acceleration_limits,
jerk_limits,
options,
result,
)
})) {
Ok(code) => code,
Err(_) => {
set_last_error("Rust ICSP FFI 捕获到未预期的内部 panic");
FLYSHOT_ERROR_INTERNAL
}
}
}
/// 释放由 `flyshot_icsp_plan` 返回的结果及其全部连续数组。
///
/// # Safety
/// `result` 必须为空指针,或是尚未释放且由本库当前版本分配的结果指针。
#[no_mangle]
pub unsafe extern "C" fn flyshot_icsp_result_free(result: *mut FlyshotIcspResult) {
if result.is_null() {
return;
}
// 先收回结果结构所有权,再按原始长度重建 boxed slice 归还数组内存。
let result = unsafe { Box::from_raw(result) };
unsafe {
free_f64_array(result.waypoint_times, result.waypoint_times_len);
free_f64_array(result.segment_durations, result.segment_durations_len);
free_f64_array(result.segment_scales, result.segment_scales_len);
}
}
/// 返回当前线程最近一次原生错误的 UTF-8 C 字符串。
///
/// # 返回值
/// 指针在当前线程下一次原生调用前有效;没有错误时指向空字符串。
#[no_mangle]
pub extern "C" fn flyshot_last_error_message() -> *const c_char {
LAST_ERROR_MESSAGE.with(|message| message.borrow().as_ptr())
}
/// 执行已通过 panic 边界保护的实际 FFI 参数校验和规划调用。
///
/// # Safety
/// 参数约束与公开 `flyshot_icsp_plan` 相同,本方法会在解引用前检查空指针和维度。
#[allow(clippy::too_many_arguments)]
unsafe fn flyshot_icsp_plan_impl(
waypoints: *const f64,
waypoint_count: i32,
dof: i32,
velocity_limits: *const f64,
acceleration_limits: *const f64,
jerk_limits: *const f64,
options: *const FlyshotIcspOptions,
result: *mut *mut FlyshotIcspResult,
) -> i32 {
if result.is_null() {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "result 输出指针不能为空");
}
unsafe {
*result = ptr::null_mut();
}
if waypoints.is_null() {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "waypoints 输入指针不能为空");
}
if velocity_limits.is_null() || acceleration_limits.is_null() || jerk_limits.is_null() {
return fail(
FLYSHOT_ERROR_INVALID_INPUT,
"velocity/acceleration/jerk 限值指针不能为空",
);
}
if options.is_null() {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "options 输入指针不能为空");
}
if waypoint_count < 4 || dof <= 0 {
return fail(
FLYSHOT_ERROR_INVALID_INPUT,
"waypoint_count 必须至少为 4,dof 必须大于零",
);
}
let Ok(waypoint_count_usize) = usize::try_from(waypoint_count) else {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "waypoint_count 转换失败");
};
let Ok(dof_usize) = usize::try_from(dof) else {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "dof 转换失败");
};
let Some(waypoint_value_count) = waypoint_count_usize.checked_mul(dof_usize) else {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "路点数组长度计算溢出");
};
let ffi_options = unsafe { *options };
if ffi_options.max_iterations < 0 {
return fail(FLYSHOT_ERROR_INVALID_INPUT, "max_iterations 不能为负数");
}
// 所有原始指针都已完成非空及长度上界校验,此处才建立只读切片。
let input = IcspInput {
waypoints: unsafe { slice::from_raw_parts(waypoints, waypoint_value_count) },
waypoint_count: waypoint_count_usize,
dof: dof_usize,
velocity_limits: unsafe { slice::from_raw_parts(velocity_limits, dof_usize) },
acceleration_limits: unsafe { slice::from_raw_parts(acceleration_limits, dof_usize) },
jerk_limits: unsafe { slice::from_raw_parts(jerk_limits, dof_usize) },
options: IcspOptions {
threshold: ffi_options.threshold,
max_iterations: ffi_options.max_iterations as usize,
enforce_final_scale: ffi_options.enforce_final_scale != 0,
final_scale_tolerance: ffi_options.final_scale_tolerance,
},
};
match plan_icsp(&input) {
Ok(plan) => {
let Ok(iterations) = i32::try_from(plan.iterations) else {
return fail(FLYSHOT_ERROR_INTERNAL, "迭代次数超出 C ABI i32 范围");
};
let (waypoint_times, waypoint_times_len) = leak_f64_array(plan.waypoint_times);
let (segment_durations, segment_durations_len) = leak_f64_array(plan.segment_durations);
let (segment_scales, segment_scales_len) = leak_f64_array(plan.segment_scales);
let native_result = Box::new(FlyshotIcspResult {
waypoint_times,
waypoint_times_len,
segment_durations,
segment_durations_len,
segment_scales,
segment_scales_len,
iterations,
threshold: plan.threshold,
});
unsafe {
*result = Box::into_raw(native_result);
}
FLYSHOT_SUCCESS
}
Err(error) => {
let code = error_code(&error);
set_last_error(&error.to_string());
code
}
}
}
/// 将 Rust 规划错误映射为稳定 C ABI 错误码。
///
/// # 参数
/// - `error`: 纯算法层返回的错误。
///
/// # 返回值
/// 与错误类别一一对应的固定整数。
fn error_code(error: &NativePlanningError) -> i32 {
match error {
NativePlanningError::InvalidInput(_) => FLYSHOT_ERROR_INVALID_INPUT,
NativePlanningError::NumericalFailure(_) => FLYSHOT_ERROR_NUMERICAL_FAILURE,
NativePlanningError::NotConverged(_) => FLYSHOT_ERROR_NOT_CONVERGED,
}
}
/// 写入错误文本并返回指定错误码。
///
/// # 参数
/// - `code`: 需要返回给调用方的稳定错误码。
/// - `message`: 当前线程可读取的诊断文本。
///
/// # 返回值
/// 原样返回 `code`,便于校验分支直接结束。
fn fail(code: i32, message: &str) -> i32 {
set_last_error(message);
code
}
/// 清空当前线程的最近错误文本。
fn clear_last_error() {
set_last_error("");
}
/// 将诊断文本保存到当前线程;内部 NUL 会替换为空格以保持合法 C 字符串。
///
/// # 参数
/// - `message`: 需要保存到线程局部缓冲区的 UTF-8 文本。
fn set_last_error(message: &str) {
let sanitized = message.replace('\0', " ");
// FFI 路径明确不使用 unwrap;即使替换逻辑未来变化,也保持无 panic 回退。
#[allow(clippy::manual_unwrap_or_default)]
let c_string = match CString::new(sanitized) {
Ok(value) => value,
Err(_) => CString::default(),
};
LAST_ERROR_MESSAGE.with(|slot| {
*slot.borrow_mut() = c_string;
});
}
/// 将 `Vec<f64>` 转为由 C ABI 结果持有的裸指针和 i32 长度。
///
/// # 参数
/// - `values`: 需要转移给结果对象的连续数组。
///
/// # 返回值
/// 数组裸指针及其长度;当前输入长度来源于 i32 路点维度,因此不会溢出。
fn leak_f64_array(values: Vec<f64>) -> (*mut f64, i32) {
let mut values = values.into_boxed_slice();
let length = i32::try_from(values.len()).unwrap_or(i32::MAX);
let pointer = values.as_mut_ptr();
std::mem::forget(values);
(pointer, length)
}
/// 按原始长度重建并释放 Rust 分配的 `f64` boxed slice。
///
/// # Safety
/// 指针和长度必须来自 `leak_f64_array` 且尚未释放。
unsafe fn free_f64_array(pointer: *mut f64, length: i32) {
if pointer.is_null() || length <= 0 {
return;
}
let Ok(length) = usize::try_from(length) else {
return;
};
let slice_pointer = ptr::slice_from_raw_parts_mut(pointer, length);
drop(unsafe { Box::from_raw(slice_pointer) });
}
#[cfg(test)]
mod tests {
use std::ffi::CStr;
use std::ptr;
use super::{
flyshot_icsp_plan, flyshot_icsp_result_free, flyshot_last_error_message,
FlyshotIcspOptions, FlyshotIcspResult, FLYSHOT_ERROR_INVALID_INPUT, FLYSHOT_SUCCESS,
};
/// 验证空输入指针会返回输入错误,并在线程局部错误文本中保留可诊断信息。
#[test]
fn ffi_rejects_null_waypoint_pointer_with_error_message() {
let options = FlyshotIcspOptions::default();
let mut result: *mut FlyshotIcspResult = ptr::null_mut();
let error_code = unsafe {
flyshot_icsp_plan(
ptr::null(),
4,
1,
[2.0].as_ptr(),
[4.0].as_ptr(),
[8.0].as_ptr(),
&options,
&mut result,
)
};
assert_eq!(error_code, FLYSHOT_ERROR_INVALID_INPUT);
assert!(result.is_null());
let message = unsafe { CStr::from_ptr(flyshot_last_error_message()) }
.to_string_lossy()
.into_owned();
assert!(message.contains("waypoints"));
}
/// 验证正常 C ABI 调用会返回 Rust 分配的结果,并可通过唯一释放函数归还。
#[test]
fn ffi_returns_and_frees_successful_plan_result() {
let waypoints = [0.0, 0.5, 1.0, 1.5];
let velocity_limits = [2.0];
let acceleration_limits = [4.0];
let jerk_limits = [8.0];
let options = FlyshotIcspOptions {
enforce_final_scale: 0,
..FlyshotIcspOptions::default()
};
let mut result: *mut FlyshotIcspResult = ptr::null_mut();
let error_code = unsafe {
flyshot_icsp_plan(
waypoints.as_ptr(),
4,
1,
velocity_limits.as_ptr(),
acceleration_limits.as_ptr(),
jerk_limits.as_ptr(),
&options,
&mut result,
)
};
let message = unsafe { CStr::from_ptr(flyshot_last_error_message()) }
.to_string_lossy()
.into_owned();
assert_eq!(error_code, FLYSHOT_SUCCESS, "{message}");
assert!(!result.is_null());
unsafe {
assert_eq!((*result).waypoint_times_len, 4);
assert_eq!((*result).segment_durations_len, 3);
assert_eq!((*result).segment_scales_len, 3);
flyshot_icsp_result_free(result);
}
}
/// 验证包含非有限数的路点会在 FFI 内转换为输入错误而不是触发 panic。
#[test]
fn ffi_rejects_non_finite_waypoint_values() {
let waypoints = [0.0, 0.5, f64::NAN, 1.5];
let velocity_limits = [2.0];
let acceleration_limits = [4.0];
let jerk_limits = [8.0];
let options = FlyshotIcspOptions::default();
let mut result: *mut FlyshotIcspResult = ptr::null_mut();
let error_code = unsafe {
flyshot_icsp_plan(
waypoints.as_ptr(),
4,
1,
velocity_limits.as_ptr(),
acceleration_limits.as_ptr(),
jerk_limits.as_ptr(),
&options,
&mut result,
)
};
assert_eq!(error_code, FLYSHOT_ERROR_INVALID_INPUT);
assert!(result.is_null());
let message = unsafe { CStr::from_ptr(flyshot_last_error_message()) }
.to_string_lossy()
.into_owned();
assert!(message.contains("有限数"));
}
}

View File

@@ -0,0 +1,367 @@
//! 纯 Rust ICSP 规划算法。
use crate::error::NativePlanningError;
use crate::spline::CubicSpline1D;
/// 描述 ICSP 迭代和最终约束检查选项。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IcspOptions {
/// 收敛指标 `sum(abs(scale - 1))` 的停止阈值。
pub threshold: f64,
/// 最大迭代索引;算法与托管实现一致,会执行 `0..=max_iterations`。
pub max_iterations: usize,
/// 是否在历史最优结果仍有 scale 超限时返回失败。
pub enforce_final_scale: bool,
/// 最终 scale 检查允许的非负容差。
pub final_scale_tolerance: f64,
}
impl Default for IcspOptions {
/// 返回与现有 C# `ICspPlanner` 一致的默认参数。
///
/// # 返回值
/// threshold=1e-3、max_iterations=1000、启用最终 scale 检查且容差为 1e-2。
fn default() -> Self {
Self {
threshold: 1e-3,
max_iterations: 1000,
enforce_final_scale: true,
final_scale_tolerance: 1e-2,
}
}
}
/// 描述一次纯 Rust ICSP 调用的扁平输入。
#[derive(Debug, Clone, Copy)]
pub struct IcspInput<'a> {
/// 按路点行优先排列的连续关节位置数组。
pub waypoints: &'a [f64],
/// 路点数量。
pub waypoint_count: usize,
/// 每个路点的关节自由度。
pub dof: usize,
/// 每个关节的正速度上限。
pub velocity_limits: &'a [f64],
/// 每个关节的正加速度上限。
pub acceleration_limits: &'a [f64],
/// 每个关节的正 jerk 上限。
pub jerk_limits: &'a [f64],
/// 本次规划使用的迭代选项。
pub options: IcspOptions,
}
/// 表示 ICSP 历史最优迭代对应的完整规划结果。
#[derive(Debug, Clone, PartialEq)]
pub struct IcspPlanResult {
/// 每个路点对应的绝对时间,首项固定为零。
pub waypoint_times: Vec<f64>,
/// 历史最优结果的逐段时长。
pub segment_durations: Vec<f64>,
/// 历史最优结果的逐段约束缩放因子。
pub segment_scales: Vec<f64>,
/// 历史最优结果产生时已执行的迭代轮数。
pub iterations: usize,
/// 历史最小 `sum(abs(scale - 1))`。
pub threshold: f64,
}
/// 执行 ICSP 迭代规划并返回历史最优结果。
///
/// # 参数
/// - `input`: 行优先路点、逐关节约束和迭代选项。
///
/// # 返回值
/// 输入合法并通过最终 scale 检查时返回规划结果;否则返回明确错误。
pub fn plan_icsp(input: &IcspInput<'_>) -> Result<IcspPlanResult, NativePlanningError> {
validate_input(input)?;
let mut segment_durations = compute_initial_durations(input)?;
let segment_count = segment_durations.len();
let mut best_result: Option<IcspPlanResult> = None;
let mut best_threshold = f64::INFINITY;
// 迭代范围必须包含 max_iterations 对应轮次,以复刻托管实现的 `<=` 语义。
for iteration in 0..=input.options.max_iterations {
let waypoint_times = cumulative_times(&segment_durations)?;
let splines = build_joint_splines(input, &waypoint_times)?;
let mut segment_scales = vec![0.0; segment_count];
for (segment, segment_scale) in segment_scales.iter_mut().enumerate() {
let mut scale = 0.0_f64;
for (joint, spline) in splines.iter().enumerate() {
let [max_velocity, max_acceleration, max_jerk] =
spline.segment_peak_derivatives(segment)?;
let velocity_scale = (max_velocity / input.velocity_limits[joint]).abs();
let acceleration_scale = (max_acceleration / input.acceleration_limits[joint])
.abs()
.sqrt();
let jerk_scale = (max_jerk / input.jerk_limits[joint]).abs().cbrt();
scale = scale.max(velocity_scale.max(acceleration_scale.max(jerk_scale)));
}
if !scale.is_finite() {
return Err(NativePlanningError::NumericalFailure(format!(
"段 {segment} 的缩放因子不是有限数"
)));
}
*segment_scale = scale;
}
let current_threshold = segment_scales
.iter()
.map(|scale| (scale - 1.0).abs())
.sum::<f64>();
if !current_threshold.is_finite() {
return Err(NativePlanningError::NumericalFailure(
"ICSP 收敛指标不是有限数".to_owned(),
));
}
// 始终保存历史最小指标对应的数据,不能只保留最后一轮。
if current_threshold < best_threshold {
best_threshold = current_threshold;
best_result = Some(IcspPlanResult {
waypoint_times: waypoint_times.clone(),
segment_durations: segment_durations.clone(),
segment_scales: segment_scales.clone(),
iterations: iteration + 1,
threshold: current_threshold,
});
}
if current_threshold < input.options.threshold {
break;
}
for (duration, scale) in segment_durations.iter_mut().zip(segment_scales.iter()) {
*duration *= *scale;
}
}
let Some(result) = best_result else {
return Err(NativePlanningError::NumericalFailure(
"ICSP 未产生任何有效迭代结果".to_owned(),
));
};
let global_scale = result
.segment_scales
.iter()
.copied()
.fold(0.0_f64, f64::max);
if input.options.enforce_final_scale && global_scale > 1.0 + input.options.final_scale_tolerance
{
return Err(NativePlanningError::NotConverged(format!(
"global_scale={global_scale:.6} > {:.6}",
1.0 + input.options.final_scale_tolerance
)));
}
Ok(result)
}
/// 校验 ICSP 输入维度、有限性和正约束。
///
/// # 参数
/// - `input`: 待校验的纯算法输入。
///
/// # 返回值
/// 全部约束满足时返回 `Ok(())`,否则返回输入错误。
fn validate_input(input: &IcspInput<'_>) -> Result<(), NativePlanningError> {
if input.waypoint_count < 4 {
return Err(NativePlanningError::InvalidInput(
"ICSP 至少需要 4 个示教点".to_owned(),
));
}
if input.dof == 0 {
return Err(NativePlanningError::InvalidInput(
"自由度必须大于零".to_owned(),
));
}
let expected_waypoint_values = input
.waypoint_count
.checked_mul(input.dof)
.ok_or_else(|| NativePlanningError::InvalidInput("路点维度乘积溢出".to_owned()))?;
if input.waypoints.len() != expected_waypoint_values {
return Err(NativePlanningError::InvalidInput(format!(
"路点扁平数组长度应为 {expected_waypoint_values},实际为 {}",
input.waypoints.len()
)));
}
if input.velocity_limits.len() != input.dof
|| input.acceleration_limits.len() != input.dof
|| input.jerk_limits.len() != input.dof
{
return Err(NativePlanningError::InvalidInput(
"速度、加速度和 jerk 限值长度必须等于自由度".to_owned(),
));
}
if input.waypoints.iter().any(|value| !value.is_finite()) {
return Err(NativePlanningError::InvalidInput(
"路点位置必须全部为有限数".to_owned(),
));
}
if input
.velocity_limits
.iter()
.chain(input.acceleration_limits.iter())
.chain(input.jerk_limits.iter())
.any(|limit| !limit.is_finite() || *limit <= 0.0)
{
return Err(NativePlanningError::InvalidInput(
"速度、加速度和 jerk 限值必须全部为有限正数".to_owned(),
));
}
if !input.options.threshold.is_finite() || input.options.threshold <= 0.0 {
return Err(NativePlanningError::InvalidInput(
"收敛阈值必须为有限正数".to_owned(),
));
}
if !input.options.final_scale_tolerance.is_finite() || input.options.final_scale_tolerance < 0.0
{
return Err(NativePlanningError::InvalidInput(
"最终 scale 容差必须为有限非负数".to_owned(),
));
}
Ok(())
}
/// 计算相邻路点在关节空间的欧氏距离,作为初始段时长。
///
/// # 参数
/// - `input`: 已通过维度校验的 ICSP 输入。
///
/// # 返回值
/// 每对相邻路点对应的正段时长。
fn compute_initial_durations(input: &IcspInput<'_>) -> Result<Vec<f64>, NativePlanningError> {
let mut durations = Vec::with_capacity(input.waypoint_count - 1);
for segment in 0..input.waypoint_count - 1 {
let start_offset = segment * input.dof;
let end_offset = (segment + 1) * input.dof;
let mut squared_distance = 0.0;
for joint in 0..input.dof {
let difference =
input.waypoints[end_offset + joint] - input.waypoints[start_offset + joint];
squared_distance += difference * difference;
}
let duration = squared_distance.sqrt();
if !duration.is_finite() || duration <= 0.0 {
return Err(NativePlanningError::InvalidInput(format!(
"相邻路点段 {segment} 的欧氏距离必须为有限正数"
)));
}
durations.push(duration);
}
Ok(durations)
}
/// 将逐段时长累加为首项为零的绝对时间节点。
///
/// # 参数
/// - `segment_durations`: 当前迭代的逐段时长。
///
/// # 返回值
/// 长度比分段时长多一的严格递增时间轴。
fn cumulative_times(segment_durations: &[f64]) -> Result<Vec<f64>, NativePlanningError> {
let mut times = Vec::with_capacity(segment_durations.len() + 1);
times.push(0.0);
let mut current = 0.0;
for (segment, duration) in segment_durations.iter().copied().enumerate() {
if !duration.is_finite() || duration <= 0.0 {
return Err(NativePlanningError::NumericalFailure(format!(
"段 {segment} 的迭代时长必须为有限正数"
)));
}
current += duration;
if !current.is_finite() {
return Err(NativePlanningError::NumericalFailure(
"累计时间轴发生溢出".to_owned(),
));
}
times.push(current);
}
Ok(times)
}
/// 为每个关节独立构造 clamped-zero 样条。
///
/// # 参数
/// - `input`: 行优先路点及自由度信息。
/// - `waypoint_times`: 当前迭代的绝对时间节点。
///
/// # 返回值
/// 按关节顺序排列的单自由度样条集合。
fn build_joint_splines(
input: &IcspInput<'_>,
waypoint_times: &[f64],
) -> Result<Vec<CubicSpline1D>, NativePlanningError> {
let mut splines = Vec::with_capacity(input.dof);
for joint in 0..input.dof {
let mut values = Vec::with_capacity(input.waypoint_count);
for waypoint in 0..input.waypoint_count {
values.push(input.waypoints[waypoint * input.dof + joint]);
}
splines.push(CubicSpline1D::new_clamped_zero(waypoint_times, &values)?);
}
Ok(splines)
}
#[cfg(test)]
mod tests {
use super::{plan_icsp, IcspInput, IcspOptions};
use crate::error::NativePlanningError;
/// 验证 ICSP 对基础四路点样本返回完整时间轴、分段结果和有限收敛信息。
#[test]
fn icsp_returns_complete_result_for_basic_fixture() {
let waypoints = [0.0, 0.0, 1.0, 0.5, 2.0, 1.0, 3.0, 1.5];
let velocity_limits = [2.0, 2.0];
let acceleration_limits = [4.0, 4.0];
let jerk_limits = [8.0, 8.0];
let input = IcspInput {
waypoints: &waypoints,
waypoint_count: 4,
dof: 2,
velocity_limits: &velocity_limits,
acceleration_limits: &acceleration_limits,
jerk_limits: &jerk_limits,
options: IcspOptions::default(),
};
let result = plan_icsp(&input).unwrap();
assert_eq!(result.waypoint_times.len(), 4);
assert_eq!(result.segment_durations.len(), 3);
assert_eq!(result.segment_scales.len(), 3);
assert!(result.iterations >= 1);
assert!(result.threshold.is_finite());
assert!(result
.waypoint_times
.windows(2)
.all(|pair| pair[1] > pair[0]));
}
/// 验证禁用迭代且最终 scale 明显超限时,纯 Rust 核心返回未收敛错误。
#[test]
fn icsp_reports_not_converged_when_zero_iterations_cannot_meet_limits() {
let waypoints = [0.0, 1.0, 2.0, 3.0];
let velocity_limits = [0.01];
let acceleration_limits = [0.01];
let jerk_limits = [0.01];
let input = IcspInput {
waypoints: &waypoints,
waypoint_count: 4,
dof: 1,
velocity_limits: &velocity_limits,
acceleration_limits: &acceleration_limits,
jerk_limits: &jerk_limits,
options: IcspOptions {
max_iterations: 0,
..IcspOptions::default()
},
};
let error = plan_icsp(&input).unwrap_err();
assert!(matches!(error, NativePlanningError::NotConverged(_)));
}
}

View File

@@ -0,0 +1,6 @@
//! Flyshot ICSP 原生规划库的稳定入口。
mod error;
mod ffi;
mod icsp;
mod spline;

View File

@@ -0,0 +1,277 @@
//! clamped-zero 三次样条算法。
use crate::error::NativePlanningError;
/// 表示单自由度 clamped-zero 三次样条及其逐段多项式系数。
#[derive(Debug, Clone)]
pub struct CubicSpline1D {
knots: Vec<f64>,
coefficients: Vec<[f64; 4]>,
}
// 位置和逐阶导数求值是第一阶段要求的原生能力,当前 ICSP 热路径只直接使用解析峰值。
#[allow(dead_code)]
impl CubicSpline1D {
/// 使用严格递增时间节点和对应值构造首尾速度为零的三次样条。
///
/// # 参数
/// - `times`: 严格递增且全部有限的时间节点,长度至少为 2。
/// - `values`: 与时间节点一一对应的有限位置值。
///
/// # 返回值
/// 输入合法且三对角方程可解时返回样条,否则返回输入或数值错误。
pub fn new_clamped_zero(times: &[f64], values: &[f64]) -> Result<Self, NativePlanningError> {
if times.len() < 2 {
return Err(NativePlanningError::InvalidInput(
"时间节点至少需要 2 个".to_owned(),
));
}
if values.len() != times.len() {
return Err(NativePlanningError::InvalidInput(
"位置值数量必须与时间节点数量一致".to_owned(),
));
}
if times.iter().any(|value| !value.is_finite())
|| values.iter().any(|value| !value.is_finite())
{
return Err(NativePlanningError::InvalidInput(
"时间节点和位置值必须全部为有限数".to_owned(),
));
}
let segment_count = times.len() - 1;
let mut durations = Vec::with_capacity(segment_count);
for segment in 0..segment_count {
let duration = times[segment + 1] - times[segment];
if duration <= 0.0 || !duration.is_finite() {
return Err(NativePlanningError::InvalidInput(format!(
"时间节点必须严格递增,段 {segment} 的时长为 {duration}"
)));
}
durations.push(duration);
}
// 与托管实现保持一致:先组装 clamped-zero 边界下的三对角方程右端项。
let node_count = times.len();
let mut alpha = vec![0.0; node_count];
alpha[0] = 6.0 * ((values[1] - values[0]) / durations[0]);
alpha[node_count - 1] = 6.0
* (0.0 - (values[node_count - 1] - values[node_count - 2]) / durations[node_count - 2]);
for index in 1..node_count - 1 {
alpha[index] = 6.0
* ((values[index + 1] - values[index]) / durations[index]
- (values[index] - values[index - 1]) / durations[index - 1]);
}
let mut diagonal = vec![0.0; node_count];
let mut upper_ratio = vec![0.0; node_count];
let mut solved_rhs = vec![0.0; node_count];
diagonal[0] = 2.0 * durations[0];
upper_ratio[0] = 0.5;
solved_rhs[0] = alpha[0] / diagonal[0];
// Thomas 消元逐行处理内部节点,避免建立完整稠密矩阵。
for index in 1..node_count - 1 {
diagonal[index] = 2.0 * (durations[index - 1] + durations[index])
- durations[index - 1] * upper_ratio[index - 1];
if diagonal[index].abs() <= f64::EPSILON || !diagonal[index].is_finite() {
return Err(NativePlanningError::NumericalFailure(format!(
"三对角方程在节点 {index} 出现无效主元"
)));
}
upper_ratio[index] = durations[index] / diagonal[index];
solved_rhs[index] =
(alpha[index] - durations[index - 1] * solved_rhs[index - 1]) / diagonal[index];
}
diagonal[node_count - 1] = 2.0 * durations[node_count - 2]
- durations[node_count - 2] * upper_ratio[node_count - 2];
if diagonal[node_count - 1].abs() <= f64::EPSILON || !diagonal[node_count - 1].is_finite() {
return Err(NativePlanningError::NumericalFailure(
"三对角方程末端出现无效主元".to_owned(),
));
}
solved_rhs[node_count - 1] = (alpha[node_count - 1]
- durations[node_count - 2] * solved_rhs[node_count - 2])
/ diagonal[node_count - 1];
// 回代得到各节点二阶导数,再转换为每段局部三次多项式系数。
let mut second_derivatives = vec![0.0; node_count];
second_derivatives[node_count - 1] = solved_rhs[node_count - 1];
for index in (0..node_count - 1).rev() {
second_derivatives[index] =
solved_rhs[index] - upper_ratio[index] * second_derivatives[index + 1];
}
let mut coefficients = Vec::with_capacity(segment_count);
for segment in 0..segment_count {
let duration = durations[segment];
let cubic =
(second_derivatives[segment + 1] - second_derivatives[segment]) / (6.0 * duration);
let quadratic = second_derivatives[segment] / 2.0;
let linear = (values[segment + 1] - values[segment]) / duration
- duration * (2.0 * second_derivatives[segment] + second_derivatives[segment + 1])
/ 6.0;
coefficients.push([cubic, quadratic, linear, values[segment]]);
}
Ok(Self {
knots: times.to_vec(),
coefficients,
})
}
/// 在指定绝对时间求样条位置;超出范围时使用首段或末段外推点。
///
/// # 参数
/// - `time`: 需要求值的绝对时间。
///
/// # 返回值
/// 对应样条位置。
pub fn position(&self, time: f64) -> f64 {
let (segment, local_time) = self.segment_and_local_time(time);
let [cubic, quadratic, linear, constant] = self.coefficients[segment];
cubic * local_time * local_time * local_time
+ quadratic * local_time * local_time
+ linear * local_time
+ constant
}
/// 在指定绝对时间求一阶导数。
///
/// # 参数
/// - `time`: 需要求值的绝对时间。
///
/// # 返回值
/// 对应样条速度。
pub fn velocity(&self, time: f64) -> f64 {
let (segment, local_time) = self.segment_and_local_time(time);
let [cubic, quadratic, linear, _] = self.coefficients[segment];
3.0 * cubic * local_time * local_time + 2.0 * quadratic * local_time + linear
}
/// 在指定绝对时间求二阶导数。
///
/// # 参数
/// - `time`: 需要求值的绝对时间。
///
/// # 返回值
/// 对应样条加速度。
pub fn acceleration(&self, time: f64) -> f64 {
let (segment, local_time) = self.segment_and_local_time(time);
let [cubic, quadratic, _, _] = self.coefficients[segment];
6.0 * cubic * local_time + 2.0 * quadratic
}
/// 在指定绝对时间求三阶导数。
///
/// # 参数
/// - `time`: 需要求值的绝对时间。
///
/// # 返回值
/// 对应样条 jerk。
pub fn jerk(&self, time: f64) -> f64 {
let (segment, _) = self.segment_and_local_time(time);
6.0 * self.coefficients[segment][0]
}
/// 解析计算指定段的一至三阶导数绝对值峰值。
///
/// # 参数
/// - `segment_index`: 从零开始的段索引。
///
/// # 返回值
/// `[速度峰值, 加速度峰值, jerk 峰值]`;索引越界时返回输入错误。
pub fn segment_peak_derivatives(
&self,
segment_index: usize,
) -> Result<[f64; 3], NativePlanningError> {
let Some([cubic, quadratic, linear, _]) = self.coefficients.get(segment_index).copied()
else {
return Err(NativePlanningError::InvalidInput(format!(
"样条段索引 {segment_index} 越界"
)));
};
let duration = self.knots[segment_index + 1] - self.knots[segment_index];
// 一阶导为二次函数,绝对值峰值只可能出现在端点或段内顶点。
let mut velocity_peak = linear
.abs()
.max((3.0 * cubic * duration * duration + 2.0 * quadratic * duration + linear).abs());
if cubic.abs() > 1e-12 {
let critical = -quadratic / (3.0 * cubic);
if critical > 0.0 && critical < duration {
velocity_peak = velocity_peak.max(
(3.0 * cubic * critical * critical + 2.0 * quadratic * critical + linear).abs(),
);
}
}
let acceleration_peak = (2.0 * quadratic)
.abs()
.max((6.0 * cubic * duration + 2.0 * quadratic).abs());
let jerk_peak = (6.0 * cubic).abs();
Ok([velocity_peak, acceleration_peak, jerk_peak])
}
/// 定位时间所在样条段并计算相对段起点的局部时间。
///
/// # 参数
/// - `time`: 需要定位的绝对时间。
///
/// # 返回值
/// `(段索引, 局部时间)`;时间超界时钳制到首尾节点。
fn segment_and_local_time(&self, time: f64) -> (usize, f64) {
let last_segment = self.coefficients.len() - 1;
if time <= self.knots[0] {
return (0, 0.0);
}
let last_knot = self.knots[self.knots.len() - 1];
if time >= last_knot {
return (
last_segment,
self.knots[last_segment + 1] - self.knots[last_segment],
);
}
let insertion = self.knots.partition_point(|knot| *knot <= time);
let segment = insertion.saturating_sub(1).min(last_segment);
(segment, time - self.knots[segment])
}
}
#[cfg(test)]
mod tests {
use super::CubicSpline1D;
/// 验证 clamped-zero 样条严格命中首尾路点,并满足两端零速度边界。
#[test]
fn clamped_zero_spline_hits_waypoints_and_zero_boundary_velocity() {
let times = [0.0, 1.0, 2.0, 3.0];
let values = [0.0, 1.0, 0.5, 2.0];
let spline = CubicSpline1D::new_clamped_zero(&times, &values).unwrap();
assert!((spline.position(0.0) - 0.0).abs() <= 1e-12);
assert!((spline.position(3.0) - 2.0).abs() <= 1e-12);
assert!(spline.velocity(0.0).abs() <= 1e-12);
assert!(spline.velocity(3.0).abs() <= 1e-12);
assert!(spline.acceleration(1.5).is_finite());
assert!(spline.jerk(1.5).is_finite());
}
/// 验证每段速度、加速度和 jerk 的解析峰值不会漏掉段内速度极值。
#[test]
fn segment_peak_derivatives_include_internal_velocity_extremum() {
let times = [0.0, 1.0, 2.0, 3.0];
let values = [0.0, 1.0, 0.5, 2.0];
let spline = CubicSpline1D::new_clamped_zero(&times, &values).unwrap();
let peaks = spline.segment_peak_derivatives(0).unwrap();
assert!(peaks[0] >= spline.velocity(0.0).abs());
assert!(peaks[0] >= spline.velocity(1.0).abs());
assert!(peaks[1].is_finite() && peaks[1] > 0.0);
assert!(peaks[2].is_finite() && peaks[2] > 0.0);
}
}

View File

@@ -427,6 +427,13 @@ function Install-ObfuscatedAssemblies
$_.Name -like 'Flyshot*.pdb'
} |
Remove-Item -Force
# Rust ICSP 原生库必须随框架依赖包一起交付,否则默认 method=icsp 会在运行时加载失败。
$nativePlanningLibraryPath = Join-Path $PublishedDirectory 'flyshot_planning.dll'
if (-not (Test-Path -LiteralPath $nativePlanningLibraryPath -PathType Leaf))
{
throw "发布目录缺少 Rust 原生规划库 flyshot_planning.dll:$nativePlanningLibraryPath"
}
}
<#

View File

@@ -0,0 +1,44 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 将拆分后的 C# ICSP 规划器暴露为可选规划后端。
/// </summary>
public sealed class CSharpIcspPlanningBackend : IPlanningBackend
{
private readonly ICspPlanner _planner;
/// <summary>
/// 初始化 C# ICSP 后端。
/// </summary>
/// <param name="logger">C# ICSP 规划器日志记录器;允许为空。</param>
public CSharpIcspPlanningBackend(ILogger<ICspPlanner>? logger = null)
{
_planner = new ICspPlanner(logger: logger);
}
/// <summary>
/// 获取外部调用 C# ICSP 后端时使用的准确方法名。
/// </summary>
public string MethodName => "icsp-csharp";
/// <summary>
/// 获取 C# ICSP 后端写入结果对象的规划方法。
/// </summary>
public PlanningMethod ResultMethod => PlanningMethod.IcspCSharp;
/// <summary>
/// 执行 C# ICSP 规划,并将结果标记为 C# 后端。
/// </summary>
/// <param name="request">包含机器人约束和示教路点的规划请求。</param>
/// <param name="context">规划上下文;普通 C# ICSP 当前不使用该参数。</param>
/// <returns>由 C# ICSP 规划器生成的轨迹结果。</returns>
public PlannedTrajectory Plan(TrajectoryRequest request, PlanningBackendContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _planner.Plan(request);
}
}

View File

@@ -0,0 +1,24 @@
using Flyshot.Core.Planning;
using Microsoft.Extensions.DependencyInjection;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 提供可选 C# 规划算法后端的依赖注入注册入口。
/// </summary>
public static class CSharpPlanningServiceCollectionExtensions
{
/// <summary>
/// 显式注册 C# ICSP 与 self-adapt-icsp 后端。
/// </summary>
/// <param name="services">当前宿主服务集合。</param>
/// <returns>同一服务集合,便于链式调用。</returns>
public static IServiceCollection AddCSharpPlanningBackends(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IPlanningBackend, CSharpIcspPlanningBackend>();
services.AddSingleton<IPlanningBackend, SelfAdaptIcspPlanningBackend>();
return services;
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
# ICSP 算法说明(`ICspPlanner`)
本文档用于解释 `Flyshot.Core.Planning.ICspPlanner` 当前实现的 **ICSP 规划算法**在本仓库中的真实含义与计算逻辑,便于与逆向结论对照、以及指导后续改造(例如“按约束生成中间点位”)。
本文档用于解释 `Flyshot.CSharp.Planning.ICspPlanner` 当前实现的 **C# ICSP 规划算法**在本仓库中的真实含义与计算逻辑,便于与逆向结论对照、以及指导后续改造(例如“按约束生成中间点位”)。
> 适用范围:本文描述的是当前 C# 实现的 **“CubicSpline + 逐段时间缩放迭代(retiming)”** 版本。
> 重要澄清:`ICspPlanner` 的主要输出是 **时间轴**(每个示教点的时间戳),而不是直接输出固定周期的稠密点序列;稠密点在后续采样层生成。
@@ -186,9 +186,8 @@ globalScale > 1 + \text{finalScaleTolerance}
## 7. 关联实现位置(便于跳转)
- 算法入口:`src/Flyshot.Core.Planning/ICspPlanner.cs`
- 自适应补点:`src/Flyshot.Core.Planning/SelfAdaptIcspPlanner.cs`
- 算法入口:`src/Flyshot.CSharp.Planning/ICspPlanner.cs`
- 自适应补点:`src/Flyshot.CSharp.Planning/SelfAdaptIcspPlanner.cs`
- 三次样条实现(clamped-zero + 解析导峰值):`src/Flyshot.Core.Planning/CubicSplineInterpolator.cs`
- 规划层稠密采样:`src/Flyshot.Core.Planning/Sampling/TrajectorySampler.cs`
- J519 实发重采样:`src/Flyshot.Core.Planning/Sampling/J519SendTrajectorySampler.cs`

View File

@@ -1,7 +1,8 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.Core.Planning;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 基于 CubicSpline + 逐段时间缩放迭代的 ICSP 规划器。
@@ -196,7 +197,7 @@ public sealed class ICspPlanner
waypointTimes: bestWaypointTimes,
segmentDurations: bestDurations,
segmentScales: bestScales,
method: PlanningMethod.Icsp,
method: PlanningMethod.IcspCSharp,
iterations: bestIterations,
threshold: bestThreshold);
}

View File

@@ -1,7 +1,8 @@
using Flyshot.Core.Domain;
using Microsoft.Extensions.Logging;
namespace Flyshot.Core.Planning;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 在 ICSP 外层包裹补中点策略,实现 self-adapt-icsp 行为。

View File

@@ -0,0 +1,44 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.CSharp.Planning;
/// <summary>
/// 将 C# self-adapt-icsp 规划器暴露为可选规划后端。
/// </summary>
public sealed class SelfAdaptIcspPlanningBackend : IPlanningBackend
{
private readonly SelfAdaptIcspPlanner _planner;
/// <summary>
/// 初始化 C# self-adapt-icsp 后端。
/// </summary>
/// <param name="logger">self-adapt-icsp 规划器日志记录器;允许为空。</param>
public SelfAdaptIcspPlanningBackend(ILogger<SelfAdaptIcspPlanner>? logger = null)
{
_planner = new SelfAdaptIcspPlanner(logger);
}
/// <summary>
/// 获取外部调用 self-adapt-icsp 后端时使用的准确方法名。
/// </summary>
public string MethodName => "self-adapt-icsp";
/// <summary>
/// 获取 self-adapt-icsp 后端写入结果对象的规划方法。
/// </summary>
public PlanningMethod ResultMethod => PlanningMethod.SelfAdaptIcsp;
/// <summary>
/// 执行 C# 自适应 ICSP 规划,按上下文使用配置中的补点次数。
/// </summary>
/// <param name="request">包含机器人约束、示教路点和触发元数据的规划请求。</param>
/// <param name="context">包含 self-adapt-icsp 最大补点次数的规划上下文。</param>
/// <returns>由 self-adapt-icsp 规划器生成的轨迹结果。</returns>
public PlannedTrajectory Plan(TrajectoryRequest request, PlanningBackendContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _planner.Plan(request, context.AdaptIcspTryNum);
}
}

View File

@@ -1,4 +1,6 @@
using Flyshot.Core.Config;
using Flyshot.Core.Planning;
using Flyshot.Native.Planning;
using Flyshot.Runtime.Common;
using Flyshot.Runtime.Fanuc;
using Microsoft.Extensions.Configuration;
@@ -31,6 +33,7 @@ public static class ControllerClientCompatServiceCollectionExtensions
services.AddSingleton<RobotModelLoader>();
services.AddSingleton<RobotConfigLoader>();
services.AddSingleton<ControllerClientCompatRobotCatalog>();
services.AddSingleton<IPlanningBackend, RustIcspPlanningBackend>();
services.AddSingleton<ControllerClientTrajectoryOrchestrator>();
services.AddSingleton<FlyshotTrajectoryArtifactWriter>();
services.AddSingleton<JsonFlyshotTrajectoryStore>();

View File

@@ -3,6 +3,7 @@ using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Planning.Sampling;
using Flyshot.Core.Triggering;
using Flyshot.Native.Planning;
using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
@@ -28,8 +29,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
/// </summary>
private const double SmoothStartStopBlend = 0.60;
private readonly ICspPlanner _icspPlanner;
private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner;
private readonly IReadOnlyDictionary<string, IPlanningBackend> _planningBackends;
private readonly ShotTimelineBuilder _shotTimelineBuilder = new(new WaypointTimestampResolver());
private readonly Dictionary<string, PlannedExecutionBundle> _flyshotCache = new(StringComparer.Ordinal);
private readonly ILogger<ControllerClientTrajectoryOrchestrator>? _logger;
@@ -37,15 +37,16 @@ public sealed class ControllerClientTrajectoryOrchestrator
/// <summary>
/// 初始化轨迹编排器。
/// </summary>
/// <param name="planningBackends">显式注册的规划后端;为空时仅启用默认 Rust icsp 后端。</param>
/// <param name="logger">日志记录器;允许 null。</param>
/// <param name="loggerFactory">日志工厂;允许 null。</param>
public ControllerClientTrajectoryOrchestrator(
IEnumerable<IPlanningBackend>? planningBackends = null,
ILogger<ControllerClientTrajectoryOrchestrator>? logger = null,
ILoggerFactory? loggerFactory = null)
{
_logger = logger;
_icspPlanner = new(logger: loggerFactory?.CreateLogger<ICspPlanner>());
_selfAdaptIcspPlanner = new(logger: loggerFactory?.CreateLogger<SelfAdaptIcspPlanner>());
_planningBackends = BuildPlanningBackendMap(planningBackends);
}
/// <summary>
@@ -81,14 +82,15 @@ public sealed class ControllerClientTrajectoryOrchestrator
offsetValues: Enumerable.Repeat(0, waypoints.Count),
addressGroups: Enumerable.Range(0, waypoints.Count).Select(static _ => Array.Empty<int>()));
var method = ParseOrdinaryMethod(options.Method);
var methodName = ParseOrdinaryMethod(options.Method);
var backend = ResolvePlanningBackend(methodName);
var request = new TrajectoryRequest(
robot: planningRobot,
program: program,
method: method,
method: backend.ResultMethod,
saveTrajectoryArtifacts: options.SaveTrajectory);
var plannedTrajectory = PlanByMethod(request, method);
var plannedTrajectory = PlanByBackend(request, backend);
var executionTrajectory = plannedTrajectory;
var denseJointTrajectory = CreateLimitCompliantDenseTrajectory(ref executionTrajectory, shapeTrajectoryEdges: false);
var shotTimeline = new ShotTimeline(Array.Empty<ShotEvent>(), Array.Empty<TrajectoryDoEvent>());
@@ -152,7 +154,8 @@ public sealed class ControllerClientTrajectoryOrchestrator
offsetValues: uploaded.OffsetValues,
addressGroups: uploaded.AddressGroups);
var method = ParseFlyshotMethod(options.Method);
var methodName = ParseFlyshotMethod(options.Method);
var backend = ResolvePlanningBackend(methodName);
var cacheKey = CreateFlyshotCacheKey(planningRobot, uploaded, options, settings, effectivePlanningSpeedScale);
if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle))
{
@@ -165,12 +168,12 @@ public sealed class ControllerClientTrajectoryOrchestrator
var request = new TrajectoryRequest(
robot: planningRobot,
program: program,
method: method,
method: backend.ResultMethod,
moveToStart: options.MoveToStart,
saveTrajectoryArtifacts: options.SaveTrajectory,
useCache: options.UseCache);
var plannedTrajectory = PlanByMethod(request, method, settings);
var plannedTrajectory = PlanByBackend(request, backend, settings);
var smoothedExecutionTrajectory = ApplyExecutionTiming(plannedTrajectory, settings);
var denseJointTrajectory = CreateLimitCompliantDenseTrajectory(ref smoothedExecutionTrajectory, shapeTrajectoryEdges: false);
var shotTimeline = _shotTimelineBuilder.Build(
@@ -203,13 +206,15 @@ public sealed class ControllerClientTrajectoryOrchestrator
/// </summary>
/// <param name="method">旧 SDK 传入的方法名。</param>
/// <returns>领域层规划方法。</returns>
private static PlanningMethod ParseOrdinaryMethod(string method)
private static string ParseOrdinaryMethod(string method)
{
var normalized = NormalizeMethod(method);
return normalized switch
{
"icsp" => PlanningMethod.Icsp,
"doubles" => PlanningMethod.Doubles,
"icsp" => normalized,
"icsp-csharp" => normalized,
"icsp_rust" => throw new ArgumentException("method=\"icsp_rust\" 已改名为 method=\"icsp\"。", nameof(method)),
"doubles" => normalized,
_ => throw new ArgumentException($"Unsupported ExecuteTrajectory method: {method}", nameof(method))
};
}
@@ -219,33 +224,80 @@ public sealed class ControllerClientTrajectoryOrchestrator
/// </summary>
/// <param name="method">旧 SDK 传入的方法名。</param>
/// <returns>领域层规划方法。</returns>
private static PlanningMethod ParseFlyshotMethod(string method)
private static string ParseFlyshotMethod(string method)
{
var normalized = NormalizeMethod(method);
return normalized switch
{
"icsp" => PlanningMethod.Icsp,
"self-adapt-icsp" => PlanningMethod.SelfAdaptIcsp,
"doubles" => PlanningMethod.Doubles,
"icsp" => normalized,
"icsp-csharp" => normalized,
"icsp_rust" => throw new ArgumentException("method=\"icsp_rust\" 已改名为 method=\"icsp\"。", nameof(method)),
"self-adapt-icsp" => normalized,
"doubles" => normalized,
_ => throw new ArgumentException($"Unsupported ExecuteFlyShotTraj method: {method}", nameof(method))
};
}
/// <summary>
/// 按领域枚举分派到当前已经落地的规划器。
/// 使用显式注册的规划后端执行规划。
/// </summary>
/// <param name="request">规划请求。</param>
/// <param name="method">规划方法。</param>
/// <param name="backend">已解析的规划后端。</param>
/// <param name="settings">当前机器人兼容配置;为空时使用默认补点次数。</param>
/// <returns>规划轨迹。</returns>
private PlannedTrajectory PlanByMethod(TrajectoryRequest request, PlanningMethod method, CompatibilityRobotSettings? settings = null)
private static PlannedTrajectory PlanByBackend(
TrajectoryRequest request,
IPlanningBackend backend,
CompatibilityRobotSettings? settings = null)
{
return method switch
var context = new PlanningBackendContext(settings?.AdaptIcspTryNum ?? 5);
return backend.Plan(request, context);
}
/// <summary>
/// 从已注册后端中解析 method 对应的规划实现。
/// </summary>
/// <param name="methodName">已经归一化的外部 method 名称。</param>
/// <returns>可执行该 method 的规划后端。</returns>
private IPlanningBackend ResolvePlanningBackend(string methodName)
{
if (methodName == "doubles")
{
PlanningMethod.Icsp => _icspPlanner.Plan(request),
PlanningMethod.SelfAdaptIcsp => _selfAdaptIcspPlanner.Plan(request, settings?.AdaptIcspTryNum ?? 5),
PlanningMethod.Doubles => throw new NotSupportedException("doubles 轨迹规划尚未落地。"),
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "未知轨迹规划方法。")
};
throw new NotSupportedException("doubles 轨迹规划尚未落地。");
}
if (_planningBackends.TryGetValue(methodName, out var backend))
{
return backend;
}
throw new NotSupportedException($"轨迹规划方法 {methodName} 未注册对应后端。");
}
/// <summary>
/// 构造规划后端查找表,未显式注册时只启用默认 Rust icsp 后端。
/// </summary>
/// <param name="planningBackends">调用方显式注册的规划后端集合。</param>
/// <returns>按 method 名称索引的后端表。</returns>
private static IReadOnlyDictionary<string, IPlanningBackend> BuildPlanningBackendMap(IEnumerable<IPlanningBackend>? planningBackends)
{
var backends = planningBackends?.ToArray() ?? [];
if (backends.Length == 0)
{
backends = [new RustIcspPlanningBackend()];
}
var map = new Dictionary<string, IPlanningBackend>(StringComparer.Ordinal);
foreach (var backend in backends)
{
var methodName = NormalizeMethod(backend.MethodName);
if (!map.TryAdd(methodName, backend))
{
throw new InvalidOperationException($"规划后端 method={methodName} 被重复注册。");
}
}
return map;
}
/// <summary>

View File

@@ -5,6 +5,7 @@
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
<ProjectReference Include="..\Flyshot.Core.Triggering\Flyshot.Core.Triggering.csproj" />
<ProjectReference Include="..\Flyshot.Native.Planning\Flyshot.Native.Planning.csproj" />
<ProjectReference Include="..\Flyshot.Runtime.Common\Flyshot.Runtime.Common.csproj" />
<ProjectReference Include="..\Flyshot.Runtime.Fanuc\Flyshot.Runtime.Fanuc.csproj" />
</ItemGroup>

View File

@@ -9,7 +9,7 @@ public sealed class FlyshotExecutionOptions
/// 初始化飞拍轨迹执行参数。
/// </summary>
/// <param name="moveToStart">执行前是否自动移动到轨迹起点。</param>
/// <param name="method">轨迹生成方法,支持 `icsp`、`doubles` 或 `self-adapt-icsp`。</param>
/// <param name="method">轨迹生成方法,支持默认 Rust `icsp`、可选 C# `icsp-csharp`、`self-adapt-icsp` 或 `doubles`。</param>
/// <param name="saveTrajectory">是否保存轨迹信息。</param>
/// <param name="useCache">是否优先复用已规划轨迹缓存。</param>
/// <param name="wait">是否等待机器人执行完整条飞拍轨迹后再返回。</param>

View File

@@ -8,7 +8,7 @@ public sealed class TrajectoryExecutionOptions
/// <summary>
/// 初始化普通轨迹执行参数。
/// </summary>
/// <param name="method">轨迹生成方法,支持 `icsp` 或 `doubles`。</param>
/// <param name="method">轨迹生成方法,支持默认 Rust `icsp`、可选 C# `icsp-csharp` 或 `doubles`。</param>
/// <param name="saveTrajectory">是否保存轨迹信息。</param>
public TrajectoryExecutionOptions(string method = "icsp", bool saveTrajectory = false)
{

View File

@@ -70,10 +70,15 @@ public sealed class TrajectoryRequest
public enum PlanningMethod
{
/// <summary>
/// Uses the cubic-spline ICSP planner.
/// 使用正式 icsp 后端;当前默认实现为 Rust 原生 ICSP。
/// </summary>
Icsp,
/// <summary>
/// 使用拆分到可选程序集中的 C# ICSP 规划器。
/// </summary>
IcspCSharp,
/// <summary>
/// Uses the adaptive ICSP planner that may insert additional points.
/// </summary>

View File

@@ -0,0 +1,27 @@
using Flyshot.Core.Domain;
namespace Flyshot.Core.Planning;
/// <summary>
/// 定义可由兼容层按 method 字符串分派的轨迹规划后端。
/// </summary>
public interface IPlanningBackend
{
/// <summary>
/// 获取外部 HTTP/ControllerClient method 参数使用的准确方法名。
/// </summary>
string MethodName { get; }
/// <summary>
/// 获取该后端成功规划后写入结果对象的领域规划方法。
/// </summary>
PlanningMethod ResultMethod { get; }
/// <summary>
/// 执行轨迹规划。
/// </summary>
/// <param name="request">包含机器人约束、轨迹程序和调用标志的规划请求。</param>
/// <param name="context">兼容层传入的规划上下文参数。</param>
/// <returns>规划后的轨迹时间轴、路点和收敛信息。</returns>
PlannedTrajectory Plan(TrajectoryRequest request, PlanningBackendContext context);
}

View File

@@ -0,0 +1,21 @@
namespace Flyshot.Core.Planning;
/// <summary>
/// 表示规划后端执行时由兼容层补充的运行参数。
/// </summary>
public sealed class PlanningBackendContext
{
/// <summary>
/// 初始化规划后端上下文。
/// </summary>
/// <param name="adaptIcspTryNum">self-adapt-icsp 允许的最大补点重试次数。</param>
public PlanningBackendContext(int adaptIcspTryNum = 5)
{
AdaptIcspTryNum = adaptIcspTryNum;
}
/// <summary>
/// 获取 self-adapt-icsp 允许的最大补点重试次数。
/// </summary>
public int AdaptIcspTryNum { get; }
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,77 @@
using System.Runtime.InteropServices;
namespace Flyshot.Native.Planning;
/// <summary>
/// 与 Rust <c>FlyshotIcspOptions</c> 保持逐字段一致的 C ABI 选项结构。
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct NativeIcspOptions
{
/// <summary>
/// 收敛指标停止阈值。
/// </summary>
internal double Threshold;
/// <summary>
/// 最大迭代索引。
/// </summary>
internal int MaxIterations;
/// <summary>
/// 非零表示启用最终 scale 检查。
/// </summary>
internal int EnforceFinalScale;
/// <summary>
/// 最终 scale 允许的非负容差。
/// </summary>
internal double FinalScaleTolerance;
}
/// <summary>
/// 与 Rust <c>FlyshotIcspResult</c> 保持逐字段一致的 C ABI 结果结构。
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct NativeIcspResult
{
/// <summary>
/// 路点绝对时间连续数组指针。
/// </summary>
internal nint WaypointTimes;
/// <summary>
/// 路点绝对时间数组长度。
/// </summary>
internal int WaypointTimesLength;
/// <summary>
/// 分段时长连续数组指针。
/// </summary>
internal nint SegmentDurations;
/// <summary>
/// 分段时长数组长度。
/// </summary>
internal int SegmentDurationsLength;
/// <summary>
/// 分段缩放连续数组指针。
/// </summary>
internal nint SegmentScales;
/// <summary>
/// 分段缩放数组长度。
/// </summary>
internal int SegmentScalesLength;
/// <summary>
/// 历史最优结果对应的迭代轮数。
/// </summary>
internal int Iterations;
/// <summary>
/// 历史最小收敛指标。
/// </summary>
internal double Threshold;
}

View File

@@ -0,0 +1,130 @@
using System.Runtime.InteropServices;
namespace Flyshot.Native.Planning;
/// <summary>
/// 声明 Rust 原生规划库导出的稳定 C ABI 方法。
/// </summary>
internal static partial class NativeIcspMethods
{
/// <summary>
/// 保护原生库句柄首次初始化,避免并发规划重复加载。
/// </summary>
private static readonly object LibraryLoadLock = new();
/// <summary>
/// 保存已加载原生库句柄,使 P/Invoke 生命周期覆盖整个进程。
/// </summary>
private static nint _libraryHandle;
/// <summary>
/// 保存当前进程已绑定到 P/Invoke 逻辑名称的实际库路径。
/// </summary>
private static string? _loadedLibraryPath;
/// <summary>
/// 初始化程序集级 DllImport 解析器,把逻辑库名绑定到显式加载句柄。
/// </summary>
static NativeIcspMethods()
{
NativeLibrary.SetDllImportResolver(
typeof(NativeIcspMethods).Assembly,
ResolveImportedLibrary);
}
/// <summary>
/// 使用指定解析器确保原生规划库已加载。
/// </summary>
/// <param name="resolver">负责生成平台路径并加载原生库的解析器。</param>
internal static void EnsureLibraryLoaded(NativePlanningLibraryResolver resolver)
{
ArgumentNullException.ThrowIfNull(resolver);
string requestedPath = resolver.ResolveLibraryPath();
lock (LibraryLoadLock)
{
if (_libraryHandle != nint.Zero)
{
StringComparison comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (!string.Equals(_loadedLibraryPath, requestedPath, comparison))
{
throw new NativePlanningException(
$"Rust 原生规划库已绑定到“{_loadedLibraryPath}”,不能在同一进程中改为“{requestedPath}”。",
errorCode: -1008);
}
return;
}
_libraryHandle = resolver.LoadLibrary();
_loadedLibraryPath = requestedPath;
}
}
/// <summary>
/// 调用 Rust ICSP 入口并返回错误码和结果指针。
/// </summary>
/// <param name="waypoints">行优先连续路点数组。</param>
/// <param name="waypointCount">路点数量。</param>
/// <param name="degreesOfFreedom">每个路点的关节自由度。</param>
/// <param name="velocityLimits">逐关节速度上限。</param>
/// <param name="accelerationLimits">逐关节加速度上限。</param>
/// <param name="jerkLimits">逐关节 jerk 上限。</param>
/// <param name="options">与 Rust C ABI 对齐的规划选项。</param>
/// <param name="result">接收 Rust 分配的结果指针。</param>
/// <returns>零表示成功,非零表示固定类别的原生错误。</returns>
[LibraryImport(
NativePlanningLibraryResolver.LogicalLibraryName,
EntryPoint = "flyshot_icsp_plan")]
internal static unsafe partial int FlyshotIcspPlan(
double* waypoints,
int waypointCount,
int degreesOfFreedom,
double* velocityLimits,
double* accelerationLimits,
double* jerkLimits,
NativeIcspOptions* options,
out nint result);
/// <summary>
/// 释放由 Rust 分配的 ICSP 结果对象。
/// </summary>
/// <param name="result">Rust 返回的结果对象指针;允许为空指针。</param>
[LibraryImport(
NativePlanningLibraryResolver.LogicalLibraryName,
EntryPoint = "flyshot_icsp_result_free")]
internal static partial void FlyshotIcspResultFree(nint result);
/// <summary>
/// 获取当前线程最近一次 Rust 调用保存的 UTF-8 错误文本。
/// </summary>
/// <returns>指向线程局部 C 字符串的只读指针。</returns>
[LibraryImport(
NativePlanningLibraryResolver.LogicalLibraryName,
EntryPoint = "flyshot_last_error_message")]
internal static partial nint FlyshotLastErrorMessage();
/// <summary>
/// 将固定逻辑库名解析为已经显式加载的系统句柄。
/// </summary>
/// <param name="libraryName">P/Invoke 请求的逻辑库名。</param>
/// <param name="assembly">发起请求的托管程序集。</param>
/// <param name="searchPath">调用方提供的可选搜索路径。</param>
/// <returns>逻辑名称匹配时返回已加载句柄,否则返回零交给系统继续解析。</returns>
private static nint ResolveImportedLibrary(
string libraryName,
System.Reflection.Assembly assembly,
DllImportSearchPath? searchPath)
{
_ = assembly;
_ = searchPath;
return string.Equals(
libraryName,
NativePlanningLibraryResolver.LogicalLibraryName,
StringComparison.Ordinal)
? _libraryHandle
: nint.Zero;
}
}

View File

@@ -0,0 +1,24 @@
namespace Flyshot.Native.Planning;
/// <summary>
/// 描述 Rust ICSP 规划器使用的收敛和最终约束选项。
/// </summary>
/// <param name="Threshold">收敛指标 <c>sum(abs(scale - 1))</c> 的停止阈值。</param>
/// <param name="MaxIterations">最大迭代索引;执行范围包含该索引对应轮次。</param>
/// <param name="EnforceFinalScale">是否拒绝历史最优结果中仍然超限的段。</param>
/// <param name="FinalScaleTolerance">最终段缩放因子相对 1.0 的非负容差。</param>
public sealed record NativeIcspPlannerOptions(
double Threshold,
int MaxIterations,
bool EnforceFinalScale,
double FinalScaleTolerance)
{
/// <summary>
/// 获取与现有托管 <c>ICspPlanner</c> 一致的默认选项。
/// </summary>
public static NativeIcspPlannerOptions Default { get; } = new(
Threshold: 1e-3,
MaxIterations: 1000,
EnforceFinalScale: true,
FinalScaleTolerance: 1e-2);
}

View File

@@ -0,0 +1,37 @@
using Microsoft.Win32.SafeHandles;
namespace Flyshot.Native.Planning;
/// <summary>
/// 封装 Rust 分配的 ICSP 结果对象,确保托管异常路径也会调用原生释放函数。
/// </summary>
internal sealed class NativeIcspSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
/// <summary>
/// 初始化一个尚未持有原生结果的安全句柄。
/// </summary>
public NativeIcspSafeHandle()
: base(ownsHandle: true)
{
}
/// <summary>
/// 初始化并接管 Rust 返回的非零结果指针。
/// </summary>
/// <param name="result">由 <c>flyshot_icsp_plan</c> 分配的结果指针。</param>
internal NativeIcspSafeHandle(nint result)
: this()
{
SetHandle(result);
}
/// <summary>
/// 调用 Rust 导出的释放函数归还结果及其连续数组内存。
/// </summary>
/// <returns>释放函数调用完成后返回 <see langword="true"/>。</returns>
protected override bool ReleaseHandle()
{
NativeIcspMethods.FlyshotIcspResultFree(handle);
return true;
}
}

View File

@@ -0,0 +1,24 @@
namespace Flyshot.Native.Planning;
/// <summary>
/// 表示原生规划库加载、调用或数值计算失败。
/// </summary>
public sealed class NativePlanningException : Exception
{
/// <summary>
/// 初始化包含原生错误码和底层异常的规划异常。
/// </summary>
/// <param name="message">供调用方诊断的错误说明。</param>
/// <param name="errorCode">Rust C ABI 返回的错误码;托管加载错误使用负数。</param>
/// <param name="innerException">触发本异常的底层异常;没有时为 <see langword="null"/>。</param>
public NativePlanningException(string message, int errorCode, Exception? innerException = null)
: base(message, innerException)
{
ErrorCodeValue = errorCode;
}
/// <summary>
/// 获取 Rust C ABI 错误码或托管互操作错误码。
/// </summary>
public int ErrorCodeValue { get; }
}

View File

@@ -0,0 +1,104 @@
using System.Runtime.InteropServices;
namespace Flyshot.Native.Planning;
/// <summary>
/// 描述当前进程加载原生规划库时使用的平台信息。
/// </summary>
/// <param name="OperatingSystem">当前操作系统名称。</param>
/// <param name="Architecture">当前进程体系结构。</param>
public readonly record struct NativePlanningPlatform(string OperatingSystem, Architecture Architecture);
/// <summary>
/// 按当前操作系统和体系结构解析并加载 Rust 原生规划库。
/// </summary>
public sealed class NativePlanningLibraryResolver
{
/// <summary>
/// 原生规划库的逻辑名称。
/// </summary>
public const string LogicalLibraryName = "flyshot_planning";
private readonly Func<NativePlanningPlatform, string> _resolveLibraryPath;
/// <summary>
/// 初始化使用默认部署目录解析规则的原生库解析器。
/// </summary>
public NativePlanningLibraryResolver()
: this(ResolveDefaultLibraryPath)
{
}
/// <summary>
/// 初始化使用自定义路径解析规则的原生库解析器。
/// </summary>
/// <param name="resolveLibraryPath">根据当前平台返回原生库路径或名称的函数。</param>
public NativePlanningLibraryResolver(Func<NativePlanningPlatform, string> resolveLibraryPath)
{
_resolveLibraryPath = resolveLibraryPath ?? throw new ArgumentNullException(nameof(resolveLibraryPath));
}
/// <summary>
/// 解析当前平台应加载的原生库路径。
/// </summary>
/// <returns>原生库路径或可被系统加载器识别的库名称。</returns>
public string ResolveLibraryPath()
{
string operatingSystem = OperatingSystem.IsWindows()
? "Windows"
: OperatingSystem.IsLinux()
? "Linux"
: "Unsupported";
return _resolveLibraryPath(new NativePlanningPlatform(operatingSystem, RuntimeInformation.ProcessArchitecture));
}
/// <summary>
/// 加载当前平台的原生规划库并返回系统句柄。
/// </summary>
/// <returns>由 <see cref="NativeLibrary"/> 管理的非零库句柄。</returns>
/// <exception cref="NativePlanningException">库缺失、架构不匹配或系统不受支持时抛出。</exception>
public nint LoadLibrary()
{
string path = ResolveLibraryPath();
try
{
return NativeLibrary.Load(path);
}
catch (Exception exception) when (
exception is DllNotFoundException
or BadImageFormatException
or FileLoadException)
{
throw new NativePlanningException(
$"无法加载 Rust 原生规划库“{path}”。请确认库文件存在且与 {RuntimeInformation.ProcessArchitecture} 架构匹配。",
errorCode: -1001,
exception);
}
}
/// <summary>
/// 根据当前平台生成与托管程序集同目录的默认原生库路径。
/// </summary>
/// <param name="platform">当前操作系统和进程体系结构。</param>
/// <returns>默认原生库绝对路径。</returns>
private static string ResolveDefaultLibraryPath(NativePlanningPlatform platform)
{
if (platform.Architecture != Architecture.X64)
{
throw new NativePlanningException(
$"Rust 原生规划库第一阶段仅支持 x64,当前进程架构为 {platform.Architecture}。",
errorCode: -1002);
}
string fileName = platform.OperatingSystem switch
{
"Windows" => "flyshot_planning.dll",
"Linux" => "libflyshot_planning.so",
_ => throw new NativePlanningException(
$"Rust 原生规划库不支持当前操作系统:{platform.OperatingSystem}。",
errorCode: -1003)
};
return Path.Combine(AppContext.BaseDirectory, fileName);
}
}

View File

@@ -0,0 +1,220 @@
using System.Runtime.InteropServices;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
namespace Flyshot.Native.Planning;
/// <summary>
/// 通过稳定 C ABI 调用 Rust ICSP 核心,并把结果转换为现有托管轨迹模型。
/// </summary>
public sealed class RustIcspPlanner
{
private readonly NativeIcspPlannerOptions _options;
private readonly NativePlanningLibraryResolver _libraryResolver;
/// <summary>
/// 初始化 Rust ICSP 规划器。
/// </summary>
/// <param name="options">与托管 ICSP 对齐的收敛及最终约束选项。</param>
/// <param name="libraryResolver">负责解析 Windows/Linux x64 原生库的加载器。</param>
public RustIcspPlanner(
NativeIcspPlannerOptions? options = null,
NativePlanningLibraryResolver? libraryResolver = null)
{
_options = options ?? NativeIcspPlannerOptions.Default;
_libraryResolver = libraryResolver ?? new NativePlanningLibraryResolver();
ValidateOptions(_options);
}
/// <summary>
/// 使用 Rust 原生核心规划给定关节路点。
/// </summary>
/// <param name="request">包含机器人约束和示教路点的规划请求。</param>
/// <returns>与现有托管规划链路兼容的轨迹结果。</returns>
/// <exception cref="NativePlanningException">原生库加载或规划调用失败时抛出。</exception>
public unsafe PlannedTrajectory Plan(TrajectoryRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
NativeIcspMethods.EnsureLibraryLoaded(_libraryResolver);
int waypointCount = request.Program.Waypoints.Count;
int degreesOfFreedom = request.Robot.DegreesOfFreedom;
double[] waypoints = FlattenWaypoints(request, waypointCount, degreesOfFreedom);
double[] velocityLimits = request.Robot.JointLimits
.Select(limit => limit.VelocityLimit)
.ToArray();
double[] accelerationLimits = request.Robot.JointLimits
.Select(limit => limit.AccelerationLimit)
.ToArray();
double[] jerkLimits = request.Robot.JointLimits
.Select(limit => limit.JerkLimit)
.ToArray();
var nativeOptions = new NativeIcspOptions
{
Threshold = _options.Threshold,
MaxIterations = _options.MaxIterations,
EnforceFinalScale = _options.EnforceFinalScale ? 1 : 0,
FinalScaleTolerance = _options.FinalScaleTolerance
};
fixed (double* waypointsPointer = waypoints)
fixed (double* velocityLimitsPointer = velocityLimits)
fixed (double* accelerationLimitsPointer = accelerationLimits)
fixed (double* jerkLimitsPointer = jerkLimits)
{
int errorCode = NativeIcspMethods.FlyshotIcspPlan(
waypointsPointer,
waypointCount,
degreesOfFreedom,
velocityLimitsPointer,
accelerationLimitsPointer,
jerkLimitsPointer,
&nativeOptions,
out nint resultPointer);
if (errorCode != 0)
{
string errorMessage = ReadLastErrorMessage();
throw new NativePlanningException(
$"Rust ICSP 规划失败,错误码={errorCode}:{errorMessage}",
errorCode);
}
if (resultPointer == nint.Zero)
{
throw new NativePlanningException(
"Rust ICSP 返回成功,但结果指针为空。",
errorCode: -1005);
}
using var resultHandle = new NativeIcspSafeHandle(resultPointer);
NativeIcspResult nativeResult = Marshal.PtrToStructure<NativeIcspResult>(resultPointer);
double[] waypointTimes = CopyNativeArray(
nativeResult.WaypointTimes,
nativeResult.WaypointTimesLength,
"waypoint_times");
double[] segmentDurations = CopyNativeArray(
nativeResult.SegmentDurations,
nativeResult.SegmentDurationsLength,
"segment_durations");
double[] segmentScales = CopyNativeArray(
nativeResult.SegmentScales,
nativeResult.SegmentScalesLength,
"segment_scales");
return new PlannedTrajectory(
robot: request.Robot,
originalProgram: request.Program,
plannedWaypoints: request.Program.Waypoints,
waypointTimes: waypointTimes,
segmentDurations: segmentDurations,
segmentScales: segmentScales,
method: PlanningMethod.Icsp,
iterations: nativeResult.Iterations,
threshold: nativeResult.Threshold);
}
}
/// <summary>
/// 校验托管规划选项,防止非法数值穿过 C ABI。
/// </summary>
/// <param name="options">待校验的原生规划选项。</param>
private static void ValidateOptions(NativeIcspPlannerOptions options)
{
if (!double.IsFinite(options.Threshold) || options.Threshold <= 0.0)
{
throw new ArgumentOutOfRangeException(nameof(options), "收敛阈值必须为有限正数。");
}
if (options.MaxIterations < 0)
{
throw new ArgumentOutOfRangeException(nameof(options), "最大迭代次数不能为负数。");
}
if (!double.IsFinite(options.FinalScaleTolerance) || options.FinalScaleTolerance < 0.0)
{
throw new ArgumentOutOfRangeException(nameof(options), "最终 scale 容差必须为有限非负数。");
}
}
/// <summary>
/// 校验规划请求与机器人自由度,确保扁平数组布局可安全建立。
/// </summary>
/// <param name="request">待传入 Rust 的规划请求。</param>
private static void ValidateRequest(TrajectoryRequest request)
{
if (request.Program.Waypoints.Count < 4)
{
throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
}
int degreesOfFreedom = request.Robot.DegreesOfFreedom;
if (request.Program.Waypoints.Any(waypoint => waypoint.Positions.Count != degreesOfFreedom))
{
throw new ArgumentException("所有路点维度必须与机器人自由度一致。", nameof(request));
}
}
/// <summary>
/// 将托管路点按行优先顺序复制为连续数组。
/// </summary>
/// <param name="request">包含路点的规划请求。</param>
/// <param name="waypointCount">已校验的路点数量。</param>
/// <param name="degreesOfFreedom">已校验的机器人自由度。</param>
/// <returns>长度为路点数量乘自由度的连续数组。</returns>
private static double[] FlattenWaypoints(
TrajectoryRequest request,
int waypointCount,
int degreesOfFreedom)
{
var flattened = new double[checked(waypointCount * degreesOfFreedom)];
for (int waypoint = 0; waypoint < waypointCount; waypoint++)
{
for (int joint = 0; joint < degreesOfFreedom; joint++)
{
flattened[waypoint * degreesOfFreedom + joint] =
request.Program.Waypoints[waypoint].Positions[joint];
}
}
return flattened;
}
/// <summary>
/// 将 Rust 连续数组复制到独立托管数组,避免结果释放后继续引用原生内存。
/// </summary>
/// <param name="pointer">Rust 结果中的连续数组指针。</param>
/// <param name="length">数组元素数量。</param>
/// <param name="fieldName">用于异常诊断的 ABI 字段名。</param>
/// <returns>与原生结果生命周期解耦的托管数组。</returns>
private static double[] CopyNativeArray(nint pointer, int length, string fieldName)
{
if (length < 0)
{
throw new NativePlanningException(
$"Rust ICSP 结果字段 {fieldName} 的长度为负数:{length}。",
errorCode: -1006);
}
if (length > 0 && pointer == nint.Zero)
{
throw new NativePlanningException(
$"Rust ICSP 结果字段 {fieldName} 长度为 {length},但指针为空。",
errorCode: -1007);
}
var values = new double[length];
if (length > 0)
{
Marshal.Copy(pointer, values, 0, length);
}
return values;
}
/// <summary>
/// 读取 Rust 当前线程保存的最近错误文本。
/// </summary>
/// <returns>UTF-8 错误文本;空指针时返回占位诊断。</returns>
private static string ReadLastErrorMessage()
{
nint messagePointer = NativeIcspMethods.FlyshotLastErrorMessage();
return messagePointer == nint.Zero
? "原生库未提供错误文本"
: Marshal.PtrToStringUTF8(messagePointer) ?? "原生错误文本不是有效 UTF-8";
}
}

View File

@@ -0,0 +1,43 @@
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
namespace Flyshot.Native.Planning;
/// <summary>
/// 将 Rust ICSP 规划器暴露为默认正式 icsp 后端。
/// </summary>
public sealed class RustIcspPlanningBackend : IPlanningBackend
{
private readonly RustIcspPlanner _planner;
/// <summary>
/// 初始化 Rust ICSP 后端。
/// </summary>
/// <param name="planner">可选的 Rust ICSP 规划器实例;为空时使用默认原生库解析规则。</param>
public RustIcspPlanningBackend(RustIcspPlanner? planner = null)
{
_planner = planner ?? new RustIcspPlanner();
}
/// <summary>
/// 获取外部调用 Rust ICSP 后端时使用的正式方法名。
/// </summary>
public string MethodName => "icsp";
/// <summary>
/// 获取 Rust ICSP 后端写入结果对象的规划方法。
/// </summary>
public PlanningMethod ResultMethod => PlanningMethod.Icsp;
/// <summary>
/// 执行 Rust ICSP 规划。
/// </summary>
/// <param name="request">包含机器人约束和示教路点的规划请求。</param>
/// <param name="context">规划上下文;Rust ICSP 当前不使用该参数。</param>
/// <returns>由 Rust 原生库生成的轨迹结果。</returns>
public PlannedTrajectory Plan(TrajectoryRequest request, PlanningBackendContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _planner.Plan(request);
}
}

View File

@@ -16,6 +16,9 @@
<Content Include="..\..\NLog.config" Link="NLog.config" CopyToOutputDirectory="PreserveNewest" />
<!-- 运行时配置根目录固定为输出目录 Config,调试和发布都复制仓库内固化配置。 -->
<Content Include="..\..\Config\**\*" Link="Config\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<!-- Rust ICSP 原生规划库:从 src/Libs 固定复制到运行与发布目录,供默认 method=icsp 加载。 -->
<Content Include="..\Libs\flyshot_planning.dll" Link="flyshot_planning.dll" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\Libs\flyshot_planning.dll')" />
<Content Include="..\Libs\libflyshot_planning.so" Link="libflyshot_planning.so" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" Condition="Exists('..\Libs\libflyshot_planning.so')" />
</ItemGroup>
<ItemGroup>

View File

@@ -46,6 +46,7 @@
规划方法
<select id="offline-planning-method">
<option value="icsp">icsp</option>
<option value="icsp-csharp">icsp-csharp</option>
<option value="self-adapt-icsp">self-adapt-icsp</option>
</select>
</label>
@@ -214,7 +215,7 @@
"description": "执行普通关节轨迹。该接口兼容两种历史请求体形态:一是对象形态 `{ waypoints, method, save_traj }`,二是直接传纯数组路点;查询参数里的 `method` 和 `save_traj` 仍可覆盖请求体值。",
"requestBodyFields": [
{ "name": "waypoints", "type": "array<array<number>> | array<object>", "required": true, "description": "历史兼容对象形态中的轨迹路点集合;每个路点既可直接是关节数组,也可写成 `{ joints: [...] }`。" },
{ "name": "method", "type": "string", "required": false, "description": "对象形态中的轨迹规划方法,默认 `icsp`。" },
{ "name": "method", "type": "string", "required": false, "description": "对象形态中的轨迹规划方法,默认 `icsp`;`icsp-csharp` 需要显式注册 C# 后端。" },
{ "name": "save_traj", "type": "boolean", "required": false, "description": "对象形态中的是否保存轨迹标记,默认 `false`。" },
{ "name": "根数组", "type": "array<array<number>> | array<object>", "required": true, "description": "纯数组形态时,请直接提交路点数组本身,而不是对象包裹层。" },
{ "name": "根数组[].joints", "type": "array<number>", "required": false, "description": "当路点元素是对象形态时,兼容层会读取其中的 `joints` 数组。" }
@@ -237,7 +238,7 @@
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "要执行的飞拍轨迹名称。" },
{ "name": "move_to_start", "type": "boolean", "required": false, "description": "是否先用临时 PTP 轨迹移动到飞拍起点,默认 `true`。" },
{ "name": "method", "type": "string", "required": false, "description": "飞拍轨迹生成方法,默认 `icsp`。" },
{ "name": "method", "type": "string", "required": false, "description": "飞拍轨迹生成方法,默认 `icsp`;`icsp-csharp` 和 `self-adapt-icsp` 需要显式注册 C# 后端。" },
{ "name": "save_traj", "type": "boolean", "required": false, "description": "是否保存轨迹信息,默认 `true`。" },
{ "name": "use_cache", "type": "boolean", "required": false, "description": "是否优先复用已缓存的轨迹结果,默认 `true`。" },
{ "name": "wait", "type": "boolean", "required": false, "description": "是否等待整条飞拍轨迹执行完成后再返回,默认 `true`。" }
@@ -248,7 +249,7 @@
"description": "仅要求机器人模型已加载;完成规划、触发绑定、最终 8ms 序列生成和文件导出,并返回完整规划结果。",
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "目标飞拍轨迹名称。" },
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`。" }
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`;`icsp-csharp` 和 `self-adapt-icsp` 需要显式注册 C# 后端。" }
]
},
"POST /is_flyShotTrajValid/": {
@@ -256,7 +257,7 @@
"description": "检查指定飞拍轨迹是否可生成有效执行结果,并返回轨迹时长;底层未实现时会返回 501。",
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "要校验的飞拍轨迹名称。" },
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`。" },
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`;`icsp-csharp` 和 `self-adapt-icsp` 需要显式注册 C# 后端。" },
{ "name": "save_traj", "type": "boolean", "required": false, "description": "是否同时保存轨迹信息,默认 `true`。" }
]
},

Binary file not shown.

Binary file not shown.

View File

@@ -27,8 +27,16 @@
<ProjectReference Include="..\..\src\Flyshot.Core.Config\Flyshot.Core.Config.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Core.Planning\Flyshot.Core.Planning.csproj" />
<ProjectReference Include="..\..\src\Flyshot.CSharp.Planning\Flyshot.CSharp.Planning.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Core.Triggering\Flyshot.Core.Triggering.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Native.Planning\Flyshot.Native.Planning.csproj" />
<ProjectReference Include="..\..\src\Flyshot.Runtime.Fanuc\Flyshot.Runtime.Fanuc.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Rust ICSP 原生规划库:测试运行时需要与托管程序集同目录加载。 -->
<Content Include="..\..\src\Libs\flyshot_planning.dll" Link="flyshot_planning.dll" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\src\Libs\flyshot_planning.dll')" />
<Content Include="..\..\src\Libs\libflyshot_planning.so" Link="libflyshot_planning.so" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\src\Libs\libflyshot_planning.so')" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
using Flyshot.Core.Config;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Planning.Export;
@@ -55,7 +56,7 @@ public sealed class OfflinePlanTests
var request = new TrajectoryRequest(
robot: scaledProfile,
program: program,
method: useSelfAdapt ? PlanningMethod.SelfAdaptIcsp : PlanningMethod.Icsp);
method: useSelfAdapt ? PlanningMethod.SelfAdaptIcsp : PlanningMethod.IcspCSharp);
PlannedTrajectory trajectory;
if (useSelfAdapt)

View File

@@ -1,4 +1,5 @@
using Flyshot.Core.Config;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Planning.Sampling;
@@ -27,7 +28,7 @@ public sealed class PlanningCompatibilityTests
new[] { 0.8, 0.3, 0.0, 0.0, 0.0, 0.0 },
new[] { 1.0, 0.2, 0.0, 0.0, 0.0, 0.0 }
}),
method: PlanningMethod.Icsp);
method: PlanningMethod.IcspCSharp);
var trajectory = new ICspPlanner().Plan(request);
@@ -51,7 +52,7 @@ public sealed class PlanningCompatibilityTests
new[] { 20.0 },
new[] { 30.0 }
}),
method: PlanningMethod.Icsp);
method: PlanningMethod.IcspCSharp);
var planner = new ICspPlanner(maxIterations: 0);

View File

@@ -1,4 +1,5 @@
using Flyshot.ControllerClientCompat;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Config;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
@@ -16,6 +17,19 @@ namespace Flyshot.Core.Tests;
/// </summary>
public sealed class RuntimeOrchestrationTests
{
/// <summary>
/// 创建显式注册 C# ICSP 后端的编排器,用于验证可选算法 DLL 的行为边界。
/// </summary>
/// <returns>包含 C# 普通 ICSP 与 self-adapt-icsp 后端的轨迹编排器。</returns>
private static ControllerClientTrajectoryOrchestrator CreateOrchestratorWithCSharpBackends()
{
return new ControllerClientTrajectoryOrchestrator(
[
new CSharpIcspPlanningBackend(),
new SelfAdaptIcspPlanningBackend()
]);
}
/// <summary>
/// 验证真机运行时会把 TCP 10010 状态通道健康度映射为可诊断连接状态。
/// </summary>
@@ -106,6 +120,155 @@ public sealed class RuntimeOrchestrationTests
Assert.Single(bundle.Result.TriggerTimeline);
}
/// <summary>
/// 验证普通轨迹默认 icsp 会真实调用 Rust DLL,并在结果中保留正式 icsp 方法标记。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_DefaultIcspUsesRustPlanner()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
var bundle = orchestrator.PlanOrdinaryTrajectory(
robot,
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.001, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.002, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.003, 0.0, 0.0, 0.0, 0.0, 0.0]
],
new TrajectoryExecutionOptions(method: "icsp"));
Assert.Equal(PlanningMethod.Icsp, bundle.PlannedTrajectory.Method);
Assert.Equal(PlanningMethod.Icsp, bundle.Result.Method);
}
/// <summary>
/// 验证旧的 icsp_rust 字串不再作为正式入口,并提示调用方改用 icsp。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_RejectsLegacyIcspRustName()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
var exception = Assert.Throws<ArgumentException>(() => orchestrator.PlanOrdinaryTrajectory(
robot,
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.001, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.002, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.003, 0.0, 0.0, 0.0, 0.0, 0.0]
],
new TrajectoryExecutionOptions(method: "icsp_rust")));
Assert.Contains("icsp", exception.Message);
Assert.Contains("icsp_rust", exception.Message);
}
/// <summary>
/// 验证未注册 C# 后端时,icsp-csharp 会明确报错而不是静默回退。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_RejectsCSharpIcspWhenBackendIsMissing()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
var exception = Assert.Throws<NotSupportedException>(() => orchestrator.PlanOrdinaryTrajectory(
robot,
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.001, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.002, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.003, 0.0, 0.0, 0.0, 0.0, 0.0]
],
new TrajectoryExecutionOptions(method: "icsp-csharp")));
Assert.Contains("icsp-csharp", exception.Message);
}
/// <summary>
/// 验证显式注册 C# 后端后,icsp-csharp 使用拆分后的托管规划器。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanOrdinaryTrajectory_UsesRegisteredCSharpIcspPlanner()
{
var orchestrator = CreateOrchestratorWithCSharpBackends();
var robot = TestRobotFactory.CreateRobotProfile();
var bundle = orchestrator.PlanOrdinaryTrajectory(
robot,
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.001, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.002, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.003, 0.0, 0.0, 0.0, 0.0, 0.0]
],
new TrajectoryExecutionOptions(method: "icsp-csharp"));
Assert.Equal(PlanningMethod.IcspCSharp, bundle.PlannedTrajectory.Method);
Assert.Equal(PlanningMethod.IcspCSharp, bundle.Result.Method);
}
/// <summary>
/// 验证未注册 C# 后端时,self-adapt-icsp 会明确报错而不是把行为藏在主服务里。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_RejectsSelfAdaptWhenBackendIsMissing()
{
var orchestrator = new ControllerClientTrajectoryOrchestrator();
var robot = TestRobotFactory.CreateRobotProfile();
var uploaded = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot();
var exception = Assert.Throws<NotSupportedException>(() => orchestrator.PlanUploadedFlyshot(
robot,
uploaded,
new FlyshotExecutionOptions(method: "self-adapt-icsp", useCache: false)));
Assert.Contains("self-adapt-icsp", exception.Message);
}
/// <summary>
/// 验证显式注册 C# 后端后,self-adapt-icsp 仍使用 C# 自适应规划器。
/// </summary>
[Fact]
public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_UsesRegisteredManagedSelfAdaptPlanner()
{
var orchestrator = CreateOrchestratorWithCSharpBackends();
var robot = new RobotProfile(
name: "ManagedSelfAdaptTestRobot",
modelPath: "Models/ManagedSelfAdaptTest.robot",
degreesOfFreedom: 6,
jointLimits: Enumerable.Range(1, 6)
.Select(static index => new JointLimit($"J{index}", 1.0, 2.0, 10.0))
.ToArray(),
jointCouplings: [],
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
var uploaded = new ControllerClientCompatUploadedTrajectory(
name: "managed-self-adapt",
waypoints:
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.4, 0.1, 0.0, 0.0, 0.0, 0.0],
[0.8, 0.3, 0.0, 0.0, 0.0, 0.0],
[1.0, 0.2, 0.0, 0.0, 0.0, 0.0]
],
shotFlags: [false, true, false, false],
offsetValues: [0, 0, 0, 0],
addressGroups: [[], [7], [], []]);
var bundle = orchestrator.PlanUploadedFlyshot(
robot,
uploaded,
new FlyshotExecutionOptions(method: "self-adapt-icsp", useCache: false));
Assert.Equal(PlanningMethod.SelfAdaptIcsp, bundle.PlannedTrajectory.Method);
Assert.Equal(PlanningMethod.SelfAdaptIcsp, bundle.Result.Method);
Assert.Single(bundle.Result.TriggerTimeline);
}
/// <summary>
/// 验证飞拍规划会把规划限速倍率纳入速度/加速度/Jerk 限制,而不是复用运行时下发倍率。
/// </summary>

View File

@@ -0,0 +1,136 @@
using Flyshot.Core.Domain;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Planning;
using Flyshot.Native.Planning;
namespace Flyshot.Core.Tests;
/// <summary>
/// 验证 Rust ICSP 托管互操作边界,包括原生库加载失败时的诊断信息。
/// </summary>
public sealed class RustIcspPlannerInteropTests
{
/// <summary>
/// 验证显式指定不存在的原生库时,规划器会抛出包含库路径的明确异常。
/// </summary>
[Fact]
public void RustIcspPlanner_ThrowsClearError_WhenNativeLibraryIsMissing()
{
var planner = new RustIcspPlanner(
options: NativeIcspPlannerOptions.Default,
libraryResolver: new NativePlanningLibraryResolver(_ => "missing-library"));
var exception = Assert.Throws<NativePlanningException>(() => planner.Plan(CreateSimpleRequest()));
Assert.Contains("missing-library", exception.Message);
}
/// <summary>
/// 验证实际原生规划结果会被复制为现有 <see cref="Flyshot.Core.Planning.PlannedTrajectory"/>。
/// </summary>
[Fact]
public void RustIcspPlanner_ReturnsManagedPlannedTrajectory_FromNativeResult()
{
var planner = new RustIcspPlanner(
NativeIcspPlannerOptions.Default with { EnforceFinalScale = false });
var result = planner.Plan(CreateSimpleRequest());
Assert.Equal(PlanningMethod.Icsp, result.Method);
Assert.Equal(result.PlannedWaypointCount, result.WaypointTimes.Count);
Assert.Equal(result.PlannedWaypointCount - 1, result.SegmentDurations.Count);
Assert.Equal(result.SegmentDurations.Count, result.SegmentScales.Count);
}
/// <summary>
/// 验证互操作测试夹具在两套实现中都由相同的最终 scale 规则拒绝。
/// </summary>
[Fact]
public void ManagedIcspPlanner_RejectsInteropFixtureWithExpectedFinalScale()
{
var planner = new ICspPlanner();
var exception = Assert.Throws<InvalidOperationException>(
() => planner.Plan(CreateSimpleRequest()));
Assert.Contains("global_scale=1.058684", exception.Message);
}
/// <summary>
/// 验证进程已加载默认 DLL 后,显式指定其他缺失库仍会暴露部署错误。
/// </summary>
[Fact]
public void RustIcspPlanner_DoesNotReuseLoadedLibrary_ForDifferentExplicitPath()
{
var defaultPlanner = new RustIcspPlanner(
NativeIcspPlannerOptions.Default with { EnforceFinalScale = false });
_ = defaultPlanner.Plan(CreateSimpleRequest());
var missingPlanner = new RustIcspPlanner(
options: NativeIcspPlannerOptions.Default,
libraryResolver: new NativePlanningLibraryResolver(_ => "different-missing-library"));
var exception = Assert.Throws<NativePlanningException>(
() => missingPlanner.Plan(CreateSimpleRequest()));
Assert.Contains("different-missing-library", exception.Message);
}
/// <summary>
/// 验证 Rust 未收敛错误码和线程局部错误文本会转换为可诊断的托管异常。
/// </summary>
[Fact]
public void RustIcspPlanner_ConvertsNativeNotConvergedError()
{
var planner = new RustIcspPlanner(
NativeIcspPlannerOptions.Default with { MaxIterations = 0 });
var exception = Assert.Throws<NativePlanningException>(
() => planner.Plan(CreateSimpleRequest()));
Assert.Equal(3, exception.ErrorCodeValue);
Assert.Contains("global_scale", exception.Message);
}
/// <summary>
/// 创建用于互操作边界测试的最小四路点单自由度规划请求。
/// </summary>
/// <returns>满足 ICSP 最小路点数量要求的轨迹请求。</returns>
private static TrajectoryRequest CreateSimpleRequest()
{
var robot = new RobotProfile(
name: "rust-interop-test",
modelPath: "test.robot",
degreesOfFreedom: 1,
jointLimits:
[
new JointLimit(
jointName: "J1",
velocityLimit: 2.0,
accelerationLimit: 4.0,
jerkLimit: 8.0)
],
jointCouplings: [],
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
var program = new FlyshotProgram(
name: "rust-interop-test",
waypoints:
[
new JointWaypoint([0.0]),
new JointWaypoint([0.5]),
new JointWaypoint([1.0]),
new JointWaypoint([1.5])
],
shotFlags: [false, false, false, false],
offsetValues: [0, 0, 0, 0],
addressGroups:
[
new IoAddressGroup([]),
new IoAddressGroup([]),
new IoAddressGroup([]),
new IoAddressGroup([])
]);
return new TrajectoryRequest(robot, program, PlanningMethod.Icsp);
}
}

View File

@@ -0,0 +1,161 @@
using Flyshot.Core.Domain;
using Flyshot.CSharp.Planning;
using Flyshot.Core.Planning;
using Flyshot.Native.Planning;
namespace Flyshot.Core.Tests;
/// <summary>
/// 验证 Rust ICSP 与现有 C# 基准实现保持逐项数值一致。
/// </summary>
public sealed class RustIcspPlannerParityTests
{
/// <summary>
/// 验证基础六自由度兼容样本的时间轴、段时长、段缩放和迭代信息完全对齐。
/// </summary>
[Fact]
public void RustIcspPlanner_MatchesManagedIcspPlanner_ForCompatibilityFixture()
{
TrajectoryRequest request = CreateCompatibilityRequest();
PlannedTrajectory managed = new ICspPlanner().Plan(request);
PlannedTrajectory native = new RustIcspPlanner().Plan(request);
Assert.Equal(PlanningMethod.IcspCSharp, managed.Method);
Assert.Equal(PlanningMethod.Icsp, native.Method);
AssertTrajectoryMatches(managed, native, tolerance: 1e-9);
}
/// <summary>
/// 验证关闭最终 scale 拒绝后,多自由度非均匀路点仍与托管算法逐项对齐。
/// </summary>
[Fact]
public void RustIcspPlanner_MatchesManagedIcspPlanner_ForNonUniformFixture()
{
TrajectoryRequest request = CreateNonUniformRequest();
var options = NativeIcspPlannerOptions.Default with { EnforceFinalScale = false };
PlannedTrajectory managed = new ICspPlanner(enforceFinalScale: false).Plan(request);
PlannedTrajectory native = new RustIcspPlanner(options).Plan(request);
Assert.Equal(PlanningMethod.IcspCSharp, managed.Method);
Assert.Equal(PlanningMethod.Icsp, native.Method);
AssertTrajectoryMatches(managed, native, tolerance: 1e-9);
}
/// <summary>
/// 创建与现有 PlanningCompatibilityTests 基础用例一致的六自由度请求。
/// </summary>
/// <returns>可由默认 ICSP 配置收敛的稳定规划请求。</returns>
private static TrajectoryRequest CreateCompatibilityRequest()
{
return CreateRequest(
velocityLimits: [1, 1, 1, 1, 1, 1],
accelerationLimits: [2, 2, 2, 2, 2, 2],
jerkLimits: [10, 10, 10, 10, 10, 10],
waypointRows:
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.4, 0.1, 0.0, 0.0, 0.0, 0.0],
[0.8, 0.3, 0.0, 0.0, 0.0, 0.0],
[1.0, 0.2, 0.0, 0.0, 0.0, 0.0]
]);
}
/// <summary>
/// 创建用于覆盖多自由度、非均匀段距离和不同约束的规划请求。
/// </summary>
/// <returns>关闭最终 scale 拒绝后可用于数值对拍的稳定请求。</returns>
private static TrajectoryRequest CreateNonUniformRequest()
{
return CreateRequest(
velocityLimits: [1.2, 0.9, 1.5],
accelerationLimits: [2.5, 1.8, 3.0],
jerkLimits: [12.0, 9.0, 15.0],
waypointRows:
[
[0.0, 0.0, 0.0],
[0.2, -0.1, 0.4],
[0.9, 0.3, 0.7],
[1.1, 0.8, 0.2],
[1.6, 0.4, -0.1]
]);
}
/// <summary>
/// 根据逐关节限制和路点矩阵创建完整领域请求。
/// </summary>
/// <param name="velocityLimits">逐关节速度上限。</param>
/// <param name="accelerationLimits">逐关节加速度上限。</param>
/// <param name="jerkLimits">逐关节 jerk 上限。</param>
/// <param name="waypointRows">按路点排列的关节位置矩阵。</param>
/// <returns>可同时传给 C# 与 Rust 规划器的请求。</returns>
private static TrajectoryRequest CreateRequest(
IReadOnlyList<double> velocityLimits,
IReadOnlyList<double> accelerationLimits,
IReadOnlyList<double> jerkLimits,
IReadOnlyList<double[]> waypointRows)
{
var robot = new RobotProfile(
name: "RustParityRobot",
modelPath: "Models/RustParity.robot",
degreesOfFreedom: velocityLimits.Count,
jointLimits: velocityLimits
.Select((velocity, index) => new JointLimit(
$"J{index + 1}",
velocity,
accelerationLimits[index],
jerkLimits[index]))
.ToArray(),
jointCouplings: [],
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
var program = new FlyshotProgram(
name: "rust-parity",
waypoints: waypointRows.Select(row => new JointWaypoint(row)).ToArray(),
shotFlags: Enumerable.Repeat(false, waypointRows.Count).ToArray(),
offsetValues: Enumerable.Repeat(0, waypointRows.Count).ToArray(),
addressGroups: Enumerable.Range(0, waypointRows.Count)
.Select(_ => new IoAddressGroup([]))
.ToArray());
return new TrajectoryRequest(robot, program, PlanningMethod.IcspCSharp);
}
/// <summary>
/// 逐字段比较两套规划实现的数值结果。
/// </summary>
/// <param name="managed">现有 C# ICSP 基准结果。</param>
/// <param name="native">Rust ICSP 原生结果。</param>
/// <param name="tolerance">所有浮点数组和 threshold 使用的绝对误差。</param>
private static void AssertTrajectoryMatches(
PlannedTrajectory managed,
PlannedTrajectory native,
double tolerance)
{
Assert.Equal(managed.Iterations, native.Iterations);
Assert.Equal(managed.WaypointTimes.Count, native.WaypointTimes.Count);
Assert.Equal(managed.SegmentDurations.Count, native.SegmentDurations.Count);
Assert.Equal(managed.SegmentScales.Count, native.SegmentScales.Count);
Assert.InRange(Math.Abs(managed.Threshold - native.Threshold), 0.0, tolerance);
AssertSequenceMatches(managed.WaypointTimes, native.WaypointTimes, tolerance);
AssertSequenceMatches(managed.SegmentDurations, native.SegmentDurations, tolerance);
AssertSequenceMatches(managed.SegmentScales, native.SegmentScales, tolerance);
}
/// <summary>
/// 按相同索引比较两个浮点序列的绝对误差。
/// </summary>
/// <param name="expected">托管基准序列。</param>
/// <param name="actual">Rust 原生序列。</param>
/// <param name="tolerance">允许的最大绝对误差。</param>
private static void AssertSequenceMatches(
IReadOnlyList<double> expected,
IReadOnlyList<double> actual,
double tolerance)
{
for (int index = 0; index < expected.Count; index++)
{
Assert.InRange(Math.Abs(expected[index] - actual[index]), 0.0, tolerance);
}
}
}