Files
2026-07-13 13:13:17 +08:00

432 lines
17 KiB
C#

#nullable enable
using System.Globalization;
using System.Text.Json;
using T3.Core.Utils;
namespace Lib.point.io;
/// <summary>
/// Converts point data from CSV or JSON files into a GPU structured buffer of <see cref="Point" /> objects.
/// It supports custom column mapping for CSV files and can export the converted points back to CSV or JSON.
/// </summary>
[Guid("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d")]
public sealed class DataPointConverter : Instance<DataPointConverter>
{
[Input(Guid = "3c4d5e6f-7a8b-4c1d-8e9f-3a4b5c6d7e8f")]
public readonly InputSlot<bool> Convert = new();
[Input(Guid = "d5f6a7b8-c9d0-4e1f-2a3b-8c7d6e5f4a3b")]
public readonly InputSlot<string> CsvF1Mapping = new("F1");
[Input(Guid = "4c7e6d5a-8f9b-4c1d-9e2a-3b4c5d6e7f8a")]
public readonly InputSlot<string> CsvPosXMapping = new("Position X");
[Input(Guid = "5d6f7a8b-9c1d-4e2f-8a3b-5c4d6e7f8a9b")]
public readonly InputSlot<string> CsvPosYMapping = new("Position Y");
[Input(Guid = "6e5a4b3c-9d2e-4f3a-8b4c-5d6e7f8a9b1c")]
public readonly InputSlot<string> CsvPosZMapping = new("Position Z");
[Input(Guid = "c4e5f6a7-b8c9-4d0e-8f2a-9b8c7d6e5f4d")]
public readonly InputSlot<string> CsvRotWMapping = new("Rotation W");
[Input(Guid = "7f4b5c6d-8e3f-4a1b-9c2d-3e4f5a6b7c8d")]
public readonly InputSlot<string> CsvRotXMapping = new("Rotation X");
[Input(Guid = "8a3c2d1e-9f4a-4b5c-d6e7-f8a9b0c1d2e3")]
public readonly InputSlot<string> CsvRotYMapping = new("Rotation Y");
[Input(Guid = "9b2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f")]
public readonly InputSlot<string> CsvRotZMapping = new("Rotation Z");
[Input(Guid = "a2c1e3d4-f5a6-4b7c-8d9e-0f1a2b3c4d5e")]
public readonly InputSlot<string> CsvScaleXMapping = new("Scale X");
[Input(Guid = "b3d4e5f6-a7b8-4c9d-2e1f-0a9b8c7d6e5f")]
public readonly InputSlot<string> CsvScaleYMapping = new("Scale Y");
[Input(Guid = "c4e5f6a7-b8c9-4d0e-8f2a-9b8c7d6e5f4c")]
public readonly InputSlot<string> CsvScaleZMapping = new("Scale Z");
[Input(Guid = "4d5e6f7a-8b9c-4d1e-9f8a-4b5c6d7e8f9a")]
public readonly InputSlot<bool> Export = new();
[Input(Guid = "2b3c4d5e-6f7a-4b1c-9d8e-2f3a4b5c6d7e")]
public readonly InputSlot<string> ExportFilePath = new("exported_points.json");
[Input(Guid = "1a2b3c4d-5e6f-4a1b-8c9d-1e2f3a4b5c6d")]
public readonly InputSlot<string> FilePath = new("points.json");
[Output(Guid = "5e6f7a8b-9c1d-4e2f-8a3b-5c4d6e7f8a9b")]
public readonly Slot<BufferWithViews?> PointBuffer = new();
private bool _convertTriggered, _exportTriggered;
private List<Point>? _lastConvertedPoints;
private BufferWithViews? _pointBuffer;
public DataPointConverter()
{
PointBuffer.UpdateAction += Update;
}
private async void Update(EvaluationContext context)
{
if (MathUtils.WasTriggered(Convert.GetValue(context), ref _convertTriggered))
{
Convert.SetTypedInputValue(false);
await ConvertFileAsync(context);
}
if (MathUtils.WasTriggered(Export.GetValue(context), ref _exportTriggered))
{
Export.SetTypedInputValue(false);
await ExportFileAsync(context);
}
PointBuffer.Value = _pointBuffer;
}
private async Task ConvertFileAsync(EvaluationContext context)
{
var filePath = FilePath.GetValue(context);
if (string.IsNullOrWhiteSpace(filePath))
{
Log.Warning("File path is invalid or empty.", this);
return;
}
var resolvedFilePath = Path.IsPathRooted(filePath)
? filePath
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, filePath);
if (!File.Exists(resolvedFilePath))
{
Log.Warning($"File does not exist: {resolvedFilePath}", this);
return;
}
Log.Debug($"Starting conversion from '{resolvedFilePath}'...", this);
try
{
var points = Path.GetExtension(resolvedFilePath).ToLowerInvariant() switch
{
".json" => await LoadFromJsonAsync(resolvedFilePath, context),
".csv" => await LoadFromCsvAsync(resolvedFilePath, context),
_ => throw new
NotSupportedException($"Unsupported file format '{Path.GetExtension(resolvedFilePath)}'. Please use .csv or .json.")
};
UpdateGpuBufferWithPoints(ref _pointBuffer, points);
_lastConvertedPoints = points;
Log.Debug($"Successfully converted {points.Count} points from '{resolvedFilePath}'.", this);
}
catch (Exception ex)
{
Log.Error($"Failed to convert data: {ex.Message}", this);
UpdateGpuBufferWithPoints(ref _pointBuffer, new List<Point>());
}
}
private async Task ExportFileAsync(EvaluationContext context)
{
var filePath = ExportFilePath.GetValue(context);
if (string.IsNullOrWhiteSpace(filePath))
{
Log.Warning("Export file path is empty.", this);
return;
}
if (_lastConvertedPoints == null || _lastConvertedPoints.Count == 0)
{
Log.Warning("No points available to export. Please convert data first.", this);
return;
}
var resolvedFilePath = Path.IsPathRooted(filePath)
? filePath
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, filePath);
try
{
var ext = Path.GetExtension(resolvedFilePath).ToLowerInvariant();
Log.Debug($"Starting export to '{resolvedFilePath}'...", this);
switch (ext)
{
case ".json":
await SaveToJsonAsync(resolvedFilePath, _lastConvertedPoints);
break;
case ".csv":
await SaveToCsvAsync(resolvedFilePath, _lastConvertedPoints);
break;
default:
throw new NotSupportedException($"Unsupported export format '{ext}'. Please use .csv or .json.");
}
}
catch (Exception ex)
{
Log.Error($"Failed to export data: {ex.Message}", this);
}
}
private async Task<List<Point>> LoadFromJsonAsync(string filePath, EvaluationContext context)
{
var jsonString = await File.ReadAllTextAsync(filePath);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var records = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(jsonString, options)
?? new List<Dictionary<string, JsonElement>>();
var mapping = GetColumnMapping(context);
var points = new List<Point>(records.Count);
foreach (var record in records)
points.Add(ParseJsonRecord(record, mapping));
return points;
}
private async Task<List<Point>> LoadFromCsvAsync(string filePath, EvaluationContext context)
{
var lines = await File.ReadAllLinesAsync(filePath);
if (lines.Length <= 1)
{
Log.Warning("CSV file is empty or contains only a header.", this);
return new List<Point>();
}
var headers = lines[0].Split(',').Select((h, i) => (Header: h.Trim(), Index: i))
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
var mapping = GetColumnMapping(context);
// Create a direct mapping from property name (e.g., "PosX") to column index
var indexMapping = mapping.ToDictionary(
kvp => kvp.Key,
kvp => headers.TryGetValue(kvp.Value, out var index) ? index : -1
);
var points = new List<Point>(lines.Length - 1);
for (var i = 1; i < lines.Length; i++)
{
var values = lines[i].Split(',');
points.Add(ParseCsvRecord(values, indexMapping));
}
return points;
}
private async Task SaveToJsonAsync(string filePath, List<Point> points)
{
var jsonPoints = points.Select(p => new
{
Position = new { p.Position.X, p.Position.Y, p.Position.Z },
Orientation = new { p.Orientation.W, p.Orientation.X, p.Orientation.Y, p.Orientation.Z },
Scale = new { p.Scale.X, p.Scale.Y, p.Scale.Z },
p.F1
}).ToList();
var options = new JsonSerializerOptions { WriteIndented = true };
var jsonString = JsonSerializer.Serialize(jsonPoints, options);
await File.WriteAllTextAsync(filePath, jsonString);
Log.Debug($"Successfully exported {points.Count} points to '{filePath}'.", this);
}
private async Task SaveToCsvAsync(string filePath, List<Point> points)
{
await using var writer = new StreamWriter(filePath);
await writer.WriteLineAsync("Position X,Position Y,Position Z,Rotation X,Rotation Y,Rotation Z,F1,Scale X,Scale Y,Scale Z");
foreach (var p in points)
{
var euler = ToEulerAngles(p.Orientation);
var line = string.Format(CultureInfo.InvariantCulture,
"{0},{1},{2},{3},{4},{5},{6},{7},{8},{9}",
p.Position.X, p.Position.Y, p.Position.Z,
euler.X, euler.Y, euler.Z,
p.F1,
p.Scale.X, p.Scale.Y, p.Scale.Z);
await writer.WriteLineAsync(line);
}
Log.Debug($"Successfully exported {points.Count} points to '{filePath}'.", this);
}
private Point ParseJsonRecord(IReadOnlyDictionary<string, JsonElement> record, Dictionary<string, string> mapping)
{
// Position
var posX = GetFloatFromJson(record, mapping.GetValueOrDefault("PosX"));
var posY = GetFloatFromJson(record, mapping.GetValueOrDefault("PosY"));
var posZ = GetFloatFromJson(record, mapping.GetValueOrDefault("PosZ"));
// Rotation
var rotX = GetFloatFromJson(record, mapping.GetValueOrDefault("RotX"));
var rotY = GetFloatFromJson(record, mapping.GetValueOrDefault("RotY"));
var rotZ = GetFloatFromJson(record, mapping.GetValueOrDefault("RotZ"));
var rotWKey = mapping.GetValueOrDefault("RotW");
Quaternion orientation;
var haveQuaternion = !string.IsNullOrEmpty(rotWKey) && record.ContainsKey(rotWKey);
if (haveQuaternion)
{
var rotW = GetFloatFromJson(record, rotWKey);
orientation = new Quaternion(rotX, rotY, rotZ, rotW);
}
else
{
orientation = Quaternion.CreateFromYawPitchRoll(rotY, rotX, rotZ);
}
// Scale
var scaleX = GetFloatFromJson(record, mapping.GetValueOrDefault("ScaleX"), 1);
var scaleY = GetFloatFromJson(record, mapping.GetValueOrDefault("ScaleY"), 1);
var scaleZ = GetFloatFromJson(record, mapping.GetValueOrDefault("ScaleZ"), 1);
return new Point
{
Position = new Vector3(posX, posY, posZ),
Orientation = orientation,
Scale = new Vector3(scaleX, scaleY, scaleZ),
F1 = GetFloatFromJson(record, mapping.GetValueOrDefault("F1"))
};
}
private Point ParseCsvRecord(string[] values, Dictionary<string, int> indexMapping)
{
// Position
var posX = GetFloatFromCsv(values, indexMapping["PosX"]);
var posY = GetFloatFromCsv(values, indexMapping["PosY"]);
var posZ = GetFloatFromCsv(values, indexMapping["PosZ"]);
// Rotation
var rotX = GetFloatFromCsv(values, indexMapping["RotX"]);
var rotY = GetFloatFromCsv(values, indexMapping["RotY"]);
var rotZ = GetFloatFromCsv(values, indexMapping["RotZ"]);
var rotWIndex = indexMapping["RotW"];
Quaternion orientation;
if (rotWIndex != -1)
{
var rotW = GetFloatFromCsv(values, rotWIndex);
orientation = new Quaternion(rotX, rotY, rotZ, rotW);
}
else
{
orientation = Quaternion.CreateFromYawPitchRoll(rotY, rotX, rotZ);
}
// Scale
var scaleX = GetFloatFromCsv(values, indexMapping["ScaleX"], 1);
var scaleY = GetFloatFromCsv(values, indexMapping["ScaleY"], 1);
var scaleZ = GetFloatFromCsv(values, indexMapping["ScaleZ"], 1);
return new Point
{
Position = new Vector3(posX, posY, posZ),
Orientation = orientation,
Scale = new Vector3(scaleX, scaleY, scaleZ),
F1 = GetFloatFromCsv(values, indexMapping["F1"])
};
}
private static float GetFloatFromJson(IReadOnlyDictionary<string, JsonElement> record, string? key, float defaultValue = 0f)
{
if (string.IsNullOrWhiteSpace(key) || !record.TryGetValue(key, out var element))
return defaultValue;
if (element.ValueKind == JsonValueKind.Number && element.TryGetSingle(out var num))
return num;
var raw = element.GetString();
return ParseFloatString(raw) ?? defaultValue;
}
private static float GetFloatFromCsv(string[] values, int index, float defaultValue = 0f)
{
if (index < 0 || index >= values.Length)
return defaultValue;
var raw = values[index];
return ParseFloatString(raw) ?? defaultValue;
}
private static float? ParseFloatString(string? raw)
{
var cleaned = raw?.Replace("m", string.Empty).Replace("°", string.Empty).Trim();
if (string.IsNullOrWhiteSpace(cleaned))
return null;
return float.TryParse(cleaned, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
}
private Dictionary<string, string> GetColumnMapping(EvaluationContext context)
{
return new Dictionary<string, string>
{
{ "PosX", CsvPosXMapping.GetValue(context) ?? "PosX" },
{ "PosY", CsvPosYMapping.GetValue(context) ?? "PosY" },
{ "PosZ", CsvPosZMapping.GetValue(context) ?? "PosZ" },
{ "RotX", CsvRotXMapping.GetValue(context) ?? "RotX" },
{ "RotY", CsvRotYMapping.GetValue(context) ?? "RotY" },
{ "RotZ", CsvRotZMapping.GetValue(context) ?? "RotZ" },
{ "RotW", CsvRotWMapping.GetValue(context) ?? "RotW" },
{ "F1", CsvF1Mapping.GetValue(context) ?? "F1"},
{ "ScaleX", CsvScaleXMapping.GetValue(context) ?? "ScaleX" },
{ "ScaleY", CsvScaleYMapping.GetValue(context) ?? "ScaleY" },
{ "ScaleZ", CsvScaleZMapping.GetValue(context) ?? "ScaleZ" }
};
}
private static Vector3 ToEulerAngles(Quaternion q)
{
// ... implementation remains the same ...
Vector3 angles = new();
double sinrCosp = 2 * (q.W * q.X + q.Y * q.Z);
double cosrCosp = 1 - 2 * (q.X * q.X + q.Y * q.Y);
angles.X = (float)Math.Atan2(sinrCosp, cosrCosp);
double sinp = 2 * (q.W * q.Y - q.Z * q.X);
if (Math.Abs(sinp) >= 1)
angles.Y = (float)Math.CopySign(Math.PI / 2, sinp);
else
angles.Y = (float)Math.Asin(sinp);
double sinyCosp = 2 * (q.W * q.Z + q.X * q.Y);
double cosyCosp = 1 - 2 * (q.Y * q.Y + q.Z * q.Z);
angles.Z = (float)Math.Atan2(sinyCosp, cosyCosp);
return angles;
}
private void UpdateGpuBufferWithPoints(ref BufferWithViews? buffer, List<Point> list)
{
var count = list.Count;
var currentCount = buffer?.Buffer != null ? buffer.Buffer.Description.SizeInBytes / Point.Stride : 0;
if (count != currentCount)
{
buffer?.Dispose();
buffer = null;
}
if (count == 0) return;
var array = list.ToArray();
if (buffer == null)
{
buffer = new BufferWithViews();
ResourceManager.SetupStructuredBuffer(array, Point.Stride * count, Point.Stride, ref buffer.Buffer);
ResourceManager.CreateStructuredBufferSrv(buffer.Buffer, ref buffer.Srv);
ResourceManager.CreateStructuredBufferUav(buffer.Buffer, UnorderedAccessViewBufferFlags.None, ref buffer.Uav);
}
else
{
ResourceManager.Device.ImmediateContext.UpdateSubresource(array, buffer.Buffer);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
_pointBuffer?.Dispose();
base.Dispose(disposing);
}
}