#nullable enable using System.Globalization; using System.Text.Json; using SharpDX; using SharpDX.Direct3D11; using T3.Core.Utils; namespace Lib.point.io; /// /// Imports a JSON point list into a GPU structured buffer and optionally exports it again. /// The node keeps the imported data in an internal buffer so the points stay visible even /// when the import button is released. /// [Guid("d4e5f6a7-b8c9-4d0e-1f2a-9b8c7d6e5f4a")] public sealed class DataPointImportExport : Instance { public DataPointImportExport() { // Use the subscription pattern – do not overwrite any existing callbacks. PointBufferOut.UpdateAction += Update; } #region Update ---------------------------------------------------------- private async void Update(EvaluationContext context) { var bufferIn = PointBufferIn.GetValue(context); var currentImportPath = ImportFilePath.GetValue(context); // Autoload logic: Check if the path changed and is not null/whitespace, // and it's not the path we just successfully imported. var pathChangedAndValid = !string.IsNullOrWhiteSpace(currentImportPath) && !currentImportPath.Equals(_lastImportedFilePath, StringComparison.Ordinal); // ------------------- IMPORT ------------------ - var explicitImportTriggered = MathUtils.WasTriggered(Import.GetValue(context), ref _importTriggered); if (explicitImportTriggered) { // Reset the button after we have detected the trigger. Import.SetTypedInputValue(false); await ImportFileAsync(context); } else if (pathChangedAndValid) { // Autoload triggered by file path change await ImportFileAsync(context); } // ------------------- EXPORT ------------------ - if (MathUtils.WasTriggered(Export.GetValue(context), ref _exportTriggered)) { // Reset the button after we have detected the trigger. Export.SetTypedInputValue(false); await ExportFileAsync(context, bufferIn); } // Keep the internal buffer alive. If we have imported data we always expose it, // otherwise we forward the incoming buffer (if any) downstream. PointBufferOut.Value = _pointBuffer ?? bufferIn; } #endregion #region Import ------------------------------------------------------------ private async Task ImportFileAsync(EvaluationContext context) { var importPath = ImportFilePath.GetValue(context); if (string.IsNullOrWhiteSpace(importPath)) { Log.Warning("Import file path is invalid.", this); return; } var resolvedPath = Path.IsPathRooted(importPath) ? importPath : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, importPath); if (!File.Exists(resolvedPath)) { Log.Warning($"Import file does not exist: {resolvedPath}", this); // Do not update _lastImportedFilePath on non-existence to allow autoload retry when the path is corrected. return; } try { var points = await LoadFromJsonAsync(resolvedPath); UpdateGpuBufferWithPoints(ref _pointBuffer, points); _lastImportedPoints = points; _lastImportedFilePath = importPath; // Update tracking path on success Log.Debug($"Successfully imported {points.Count} points from '{resolvedPath}'.", this); } catch (Exception ex) { Log.Error($"Failed to import data: {ex.Message}", this); // Do not update _lastImportedFilePath on failure. } } #endregion #region Export ------------------------------------------------------------ private async Task ExportFileAsync(EvaluationContext context, BufferWithViews? bufferIn) { var exportPath = ExportFilePath.GetValue(context); if (string.IsNullOrWhiteSpace(exportPath)) { Log.Warning("Export file path is empty.", this); return; } var resolvedPath = Path.IsPathRooted(exportPath) ? exportPath : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, exportPath); // Prefer the incoming buffer if the user has connected one, // otherwise fall back to the data we imported internally. var pointsToExport = bufferIn != null ? ToPointList(bufferIn) : _lastImportedPoints; if (pointsToExport == null || pointsToExport.Count == 0) { Log.Warning("No points available to export. Please import data or connect a point buffer.", this); return; } try { await SaveToJsonAsync(resolvedPath, pointsToExport); Log.Debug($"Successfully exported {pointsToExport.Count} points to '{resolvedPath}'.", this); } catch (Exception ex) { Log.Error($"Failed to export data: {ex.Message}", this); } } #endregion #region GPU Buffer ---------------------------------------------------------- private void UpdateGpuBufferWithPoints(ref BufferWithViews? buffer, List? list) { var count = list?.Count ?? 0; var currentCount = buffer?.Buffer != null ? buffer.Buffer.Description.SizeInBytes / Point.Stride : 0; // If the element count changed we must recreate the structured buffer. if (count != currentCount) { buffer?.Dispose(); buffer = null; } if (count == 0) return; var array = list?.ToArray(); if (buffer == null) { buffer = new BufferWithViews(); if (array != null) 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 { // Simple sub‑resource update – fast path. ResourceManager.Device.ImmediateContext.UpdateSubresource(array, buffer.Buffer); } Log.Debug($"GPU buffer updated with {count} points.", this); } #endregion #region Buffer → Point List (CPU read‑back) ---------------------------------- private List ToPointList(BufferWithViews? bufferWithViews) { if (bufferWithViews?.Buffer == null) return new List(); var device = ResourceManager.Device; var context = device.ImmediateContext; var desc = bufferWithViews.Buffer.Description; var pointCount = desc.SizeInBytes / Point.Stride; // Build a staging buffer that the CPU can read. var stagingDesc = new BufferDescription { SizeInBytes = desc.SizeInBytes, Usage = ResourceUsage.Staging, BindFlags = BindFlags.None, CpuAccessFlags = CpuAccessFlags.Read, OptionFlags = ResourceOptionFlags.BufferStructured, StructureByteStride = desc.StructureByteStride }; using var staging = new Buffer(device, stagingDesc); context.CopyResource(bufferWithViews.Buffer, staging); var dataBox = new DataBox(); try { dataBox = context.MapSubresource(staging, 0, MapMode.Read, MapFlags.None); using var stream = new DataStream(dataBox.DataPointer, dataBox.RowPitch, true, false); var points = stream.ReadRange(pointCount); return points.ToList(); } catch (Exception ex) { Log.Error($"Error reading GPU buffer: {ex.Message}", this); return new List(); } finally { if (dataBox.DataPointer != IntPtr.Zero) context.UnmapSubresource(staging, 0); } } #endregion #region Disposal ------------------------------------------------------------- protected override void Dispose(bool disposing) { if (disposing) _pointBuffer?.Dispose(); base.Dispose(disposing); } #endregion #region JSON IO -------------------------------------------------------------- private async Task> LoadFromJsonAsync(string filePath) { var jsonString = await File.ReadAllTextAsync(filePath); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var records = JsonSerializer.Deserialize>>(jsonString, options) ?? new List>(); Log.Debug($"Found {records.Count} records in JSON file.", this); var points = new List(records.Count); foreach (var rec in records) points.Add(ParseRecord(rec)); return points; } private async Task SaveToJsonAsync(string filePath, List 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 jsonString = JsonSerializer.Serialize(jsonPoints, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(filePath, jsonString); } #endregion #region Record → Point --------------------------------------------------------- private Point ParseRecord(IReadOnlyDictionary record) { // Position var posX = GetNestedFloat(record, "Position", "X"); var posY = GetNestedFloat(record, "Position", "Y"); var posZ = GetNestedFloat(record, "Position", "Z"); // Orientation – quaternion (fallback to identity if any component is missing) var rotX = GetNestedFloat(record, "Orientation", "X"); var rotY = GetNestedFloat(record, "Orientation", "Y"); var rotZ = GetNestedFloat(record, "Orientation", "Z"); var rotW = GetNestedFloat(record, "Orientation", "W", 1f); // default W = 1 (unit quaternion) // Scale (default = 1) var scaleX = GetNestedFloat(record, "Scale", "X", 1); var scaleY = GetNestedFloat(record, "Scale", "Y", 1); var scaleZ = GetNestedFloat(record, "Scale", "Z", 1); // Additional custom float (optional) var f1 = GetFloat(record, "F1"); return new Point { Position = new Vector3(posX, posY, posZ), Orientation = new Quaternion(rotX, rotY, rotZ, rotW), Scale = new Vector3(scaleX, scaleY, scaleZ), F1 = f1 }; } private static float GetFloat(IReadOnlyDictionary record, string key, float defaultValue = 0f) { if (!record.TryGetValue(key, out var element)) return defaultValue; if (element.ValueKind == JsonValueKind.Number && element.TryGetSingle(out var num)) return num; return ParseFloatString(element.GetString()) ?? defaultValue; } private static float GetNestedFloat(IReadOnlyDictionary record, string parentKey, string childKey, float defaultValue = 0f) { if (!record.TryGetValue(parentKey, out var parent) || parent.ValueKind != JsonValueKind.Object) return defaultValue; if (!parent.TryGetProperty(childKey, out var child)) return defaultValue; if (child.ValueKind == JsonValueKind.Number && child.TryGetSingle(out var num)) return num; return ParseFloatString(child.GetString()) ?? 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; } #endregion #region Fields & Slots ------------------------------------------------------- private bool _importTriggered, _exportTriggered; private BufferWithViews? _pointBuffer; // internal GPU buffer that holds the imported data private List? _lastImportedPoints; // cached point list for export when no input buffer is connected private string? _lastImportedFilePath; // track the last path we successfully imported from (for autoload) // ---------- INPUTS ---------- [Input(Guid = "27320742-221f-4383-8241-995083c87b8b")] public readonly InputSlot PointBufferIn = new(); [Input(Guid = "f8d41a2a-4c27-4a52-a0a2-1304cb04b80f")] public readonly InputSlot ImportFilePath = new("points.json"); [Input(Guid = "3e5b7c7d-8a9f-4b1e-9c2d-3e4f5a6b7c8d")] public readonly InputSlot ExportFilePath = new("points.json"); [Input(Guid = "a6b7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d")] public readonly InputSlot Import = new(); [Input(Guid = "b7c8d9e0-f1a2-4b3c-9d4e-5f6a7b8c9d0e")] public readonly InputSlot Export = new(); // ---------- OUTPUT ---------- [Output(Guid = "c8d9e0f1-a2b3-4c4d-8e5f-6a7b8c9d0e1f")] public readonly Slot PointBufferOut = new(); #endregion }