-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathMainWindow.xaml.cs
More file actions
2214 lines (1882 loc) · 98.2 KB
/
MainWindow.xaml.cs
File metadata and controls
2214 lines (1882 loc) · 98.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Standalone Point Cloud Converter https://github.com/unitycoder/PointCloudConverter
using PointCloudConverter.Logger;
using PointCloudConverter.Structs;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using Application = System.Windows.Application;
using Color = PointCloudConverter.Structs.Color;
using DataFormats = System.Windows.DataFormats;
using DragDropEffects = System.Windows.DragDropEffects;
using DragEventArgs = System.Windows.DragEventArgs;
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
using SaveFileDialog = Microsoft.Win32.SaveFileDialog;
using Newtonsoft.Json;
using Brushes = System.Windows.Media.Brushes;
using System.Threading.Tasks;
using PointCloudConverter.Readers;
using System.Collections.Concurrent;
using PointCloudConverter.Writers;
using System.Reflection;
using System.Globalization;
using System.Windows.Media;
using PointCloudConverter.Structs.Metadata;
// NOTE can test /3gb commandline arg for .NET 8 to allow large objects >2gb
namespace PointCloudConverter
{
public partial class MainWindow : Window
{
static readonly string version = "18.03.2026";
static readonly string appname = "PointCloud Converter - " + version;
static readonly string rootFolder = AppDomain.CurrentDomain.BaseDirectory;
// allow console output from WPF application https://stackoverflow.com/a/7559336/5452781
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AttachConsole(uint dwProcessId);
const uint ATTACH_PARENT_PROCESS = 0x0ffffffff;
// detach from console, otherwise file is locked https://stackoverflow.com/a/29572349/5452781
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool FreeConsole();
const uint WM_CHAR = 0x0102;
const int VK_ENTER = 0x0D;
[DllImport("kernel32.dll")]
static extern IntPtr GetConsoleWindow();
[DllImport("user32.dll")]
static extern int SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
public static MainWindow mainWindowStatic;
bool isInitialiazing = true;
static JobMetadata jobMetadata = new JobMetadata();
// progress bar data
static int progressPoint = 0;
static int progressTotalPoints = 0;
static int progressFile = 0;
static int progressTotalFiles = 0;
static long processedFileBytes = 0;
static long processedPoints = 0;
static long[] processedFileBytesInFlightBySlot = Array.Empty<long>();
static long[] processedPointsInFlightBySlot = Array.Empty<long>();
static int lastJobPercent = -1;
static bool jobProgressCompleted = false;
static DispatcherTimer progressTimerThread;
public static string lastStatusMessage = "";
public static int errorCounter = 0; // how many errors when importing or reading files (single file could have multiple errors)
private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
// True while `ProcessAllFiles` is active. Used to decide whether to cancel window close.
static volatile bool _isProcessing = false;
// filter by distance
private readonly float cellSize = 0.5f;
// TODO Replace ConcurrentDictionary<(int,int,int),byte> with a compact, contention-free structure
private static ConcurrentDictionary<(int, int, int), byte> occupiedCells = new();
// plugins
string externalFileFormats = "";
public MainWindow()
{
InitializeComponent();
mainWindowStatic = this;
Main();
}
public static Dictionary<string, Type> externalWriters = new Dictionary<string, Type>();
static ILogger Log;
private async void Main()
{
// check cmdline args
string[] args = Environment.GetCommandLineArgs();
Tools.FixDLLFoldersAndConfig(rootFolder);
Tools.ForceDotCultureSeparator();
// default logger
//Log.CreateLogger(isJSON: false, version: version);
Log = LoggerFactory.CreateLogger(isJSON: false);
//Log.CreateLogger(isJSON: false, version: "1.0");
// default code
Environment.ExitCode = (int)ExitCode.Success;
// load all plugins from plugins folder
//var testwriter = PointCloudConverter.Plugins.PluginLoader.LoadWriter("plugins/GLTFWriter.dll");
////testwriter.Close();
//externalWriters = AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly => assembly.GetTypes()).Where(type => typeof(IWriter).IsAssignableFrom(type) && !type.IsInterface);
// Get the directory of the running executable
var exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
// Build absolute path to plugins folder
var pluginsDirectory = Path.Combine(exeDir, "plugins");
if (Directory.Exists(pluginsDirectory))
{
//Log.Write("Plugins directory not found.");
// Get all DLL files in the plugins directory
var pluginFiles = Directory.GetFiles(pluginsDirectory, "*.dll");
foreach (var pluginDLL in pluginFiles)
{
try
{
// Load the DLL file as an assembly
var assembly = Assembly.LoadFrom(pluginDLL);
// Find all types in the assembly that implement IWriter
var writerTypes = assembly.GetTypes().Where(type => typeof(IWriter).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract);
foreach (var writerType in writerTypes)
{
// Derive a unique key for the writer (e.g., from its name or class name)
string writerName = writerType.Name;//.Replace("Writer", ""); // Customize the key generation logic
if (!externalWriters.ContainsKey(writerName))
{
// Add the writer type to the dictionary for later use
externalWriters.Add(writerName, writerType);
//Log.Write($"Found writer: {writerType.FullName} in {pluginDLL}");
// TODO take extensions from plugin? has 2: .glb and .gltf
externalFileFormats += "|" + writerName + " (" + writerType.FullName + ")|*." + writerName.ToLower();
}
}
}
catch (ReflectionTypeLoadException rex)
{
Log.Write($"Error loading plugin {pluginDLL}: {rex.Message}");
foreach (var loaderException in rex.LoaderExceptions)
{
Log.Write(" - " + loaderException?.Message);
}
}
catch (Exception ex)
{
Log.Write($"General error loading plugin {pluginDLL}: {ex.Message}");
}
}
} // if plugins folder exists
//return;
// for debug: print config file location in appdata local here directly
// string configFilePath = System.Configuration.ConfigurationManager.OpenExeConfiguration(System.Configuration.ConfigurationUserLevel.PerUserRoamingAndLocal).FilePath;
// Log.Write("Config file: " + configFilePath);
// using from commandline
if (args.Length > 1)
{
// hide window
this.Visibility = Visibility.Hidden;
AttachConsole(ATTACH_PARENT_PROCESS);
// check if have -jsonlog=true
foreach (var arg in args)
{
if (arg.ToLower().Contains("-json=true"))
{
//Log.CreateLogger(isJSON: true, version: version);
Log = LoggerFactory.CreateLogger(isJSON: true);
}
}
Console.ForegroundColor = ConsoleColor.Cyan;
Log.Write("\n::: " + appname + " :::\n");
//Console.WriteLine("\n::: " + appname + " :::\n");
Console.ForegroundColor = ConsoleColor.White;
IntPtr cw = GetConsoleWindow();
// check args, null here because we get the args later
var importSettings = ArgParser.Parse(null, rootFolder, Log);
// NOTE was not used?
//if (importSettings.useJSONLog)
//{
// importSettings.version = version;
// Log.SetSettings(importSettings);
//}
//if (importSettings.useJSONLog) log.Init(importSettings, version);
// get elapsed time using time
var startTime = DateTime.Now;
// if have files, process them
if (importSettings.errors.Count == 0)
{
// NOTE no background thread from commandline
var workerParams = new WorkerParams
{
ImportSettings = importSettings,
CancellationToken = _cancellationTokenSource.Token
};
InitProgressBars(importSettings);
await Task.Run(() => ProcessAllFiles(workerParams));
}
// print time
var endTime = DateTime.Now;
var elapsed = endTime - startTime;
string elapsedString = elapsed.ToString(@"hh\h\ mm\m\ ss\s\ ms\m\s");
// end output
Log.Write("Exited.\nElapsed: " + elapsedString);
if (importSettings.useJSONLog)
{
Log.Write("{\"event\": \"" + LogEvent.End + "\", \"elapsed\": \"" + elapsedString + "\",\"version\":\"" + version + "\",\"errors\":" + errorCounter + "}", LogEvent.End);
}
// https://stackoverflow.com/a/45620138/5452781
SendMessage(cw, WM_CHAR, (IntPtr)VK_ENTER, IntPtr.Zero);
FreeConsole();
Environment.Exit(Environment.ExitCode);
}
// regular WPF starts from here
this.Title = appname;
// disable accesskeys without alt
CoreCompatibilityPreferences.IsAltKeyRequiredInAccessKeyDefaultScope = true;
LoadSettings();
}
static Stopwatch stopwatch = new Stopwatch();
// main processing loop
private static async Task ProcessAllFiles(object workerParamsObject)
{
_isProcessing = true;
var workerParams = (WorkerParams)workerParamsObject;
var importSettings = workerParams.ImportSettings;
var cancellationToken = workerParams.CancellationToken;
try
{
processedFileBytes = 0;
processedPoints = 0;
if (processedFileBytesInFlightBySlot.Length > 0) Array.Clear(processedFileBytesInFlightBySlot, 0, processedFileBytesInFlightBySlot.Length);
if (processedPointsInFlightBySlot.Length > 0) Array.Clear(processedPointsInFlightBySlot, 0, processedPointsInFlightBySlot.Length);
lastJobPercent = -1;
jobProgressCompleted = false;
// Use cancellationToken to check for cancellation
if (cancellationToken.IsCancellationRequested)
{
Environment.ExitCode = (int)ExitCode.Cancelled;
return;
}
stopwatch.Reset();
stopwatch.Start();
// if user has set maxFiles param, loop only that many files
importSettings.maxFiles = importSettings.maxFiles > 0 ? importSettings.maxFiles : importSettings.inputFiles.Count;
importSettings.maxFiles = Math.Min(importSettings.maxFiles, importSettings.inputFiles.Count);
StartProgressTimer();
// loop input files
errorCounter = 0;
progressFile = 0;
progressTotalFiles = importSettings.maxFiles - 1;
if (progressTotalFiles < 0) progressTotalFiles = 0;
List<Double3> boundsListTemp = new List<Double3>();
// clear filter by distance
occupiedCells.Clear();
// get all file bounds, if in batch mode and RGB+INT+PACK
// TODO: check what happens if its too high? over 128/256?
//if (importSettings.useAutoOffset == true && importSettings.importIntensity == true && importSettings.importRGB == true && importSettings.packColors == true && importSettings.importMetadataOnly == false)
//Log.Write(importSettings.useAutoOffset + " && " + importSettings.importMetadataOnly + " || (" + importSettings.importIntensity + " && " + importSettings.importRGB + " && " + importSettings.packColors + " && " + importSettings.importMetadataOnly + ")");
//bool istrue1 = (importSettings.useAutoOffset == true && importSettings.importMetadataOnly == false);
//bool istrue2 = (importSettings.importIntensity == true && importSettings.importRGB == true && importSettings.packColors == true && importSettings.importMetadataOnly == false);
//Log.Write(istrue1 ? "1" : "0");
//Log.Write(istrue2 ? "1" : "0");
long totalFileSizes = 0;
long totalPointsRaw = 0;
long totalPointsEffective = 0;
if (importSettings.trackProgress == true)
{
for (int i = 0; i < importSettings.maxFiles; i++)
{
var headerData = PeekHeaderData(importSettings, i);
if (headerData.success == true)
{
totalFileSizes += headerData.fileSize;
totalPointsRaw += headerData.pointCount;
totalPointsEffective += AdjustPointCountForSettings(headerData.pointCount, importSettings);
}
}
Log.Write("Total input files size: " + Tools.HumanReadableFileSize(totalFileSizes));
Log.Write("Total input points (raw): " + Tools.HumanReadableCount(totalPointsRaw));
Log.Write("Total output points (effective): " + Tools.HumanReadableCount(totalPointsEffective));
}
jobMetadata.Job = new Job
{
ConverterVersion = version,
ImportSettings = importSettings,
StartTime = DateTime.Now,
TotalFileSizeBytes = totalFileSizes,
TotalPoints = totalPointsEffective > 0 ? totalPointsEffective : totalPointsRaw
};
jobMetadata.lasHeaders.Clear();
if ((importSettings.useAutoOffset == true && importSettings.importMetadataOnly == false) || ((importSettings.importIntensity == true || importSettings.importClassification == true) && importSettings.importRGB == true && importSettings.packColors == true && importSettings.importMetadataOnly == false))
{
int iterations = importSettings.offsetMode == "min" ? importSettings.maxFiles : 1; // 1 for legacy mode (first cloud only)
for (int i = 0, len = iterations; i < len; i++)
{
if (cancellationToken.IsCancellationRequested)
{
Environment.ExitCode = (int)ExitCode.Cancelled;
return; // Exit if cancellation is requested
}
progressFile = i;
Log.Write("\nReading bounds from file (" + (i + 1) + "/" + len + ") : " + importSettings.inputFiles[i] + " (" + Tools.HumanReadableFileSize(new FileInfo(importSettings.inputFiles[i]).Length) + ")");
var headerData = PeekHeaderData(importSettings, i);
if (headerData.success == true)
{
boundsListTemp.Add(new Double3(headerData.minX, headerData.minY, headerData.minZ));
}
else
{
errorCounter++;
if (importSettings.useJSONLog)
{
Log.Write("{\"event\": \"" + LogEvent.File + "\", \"path\": " + System.Text.Json.JsonSerializer.Serialize(Path.GetFileName(importSettings.inputFiles[i])) + ", \"status\": \"" + LogStatus.Processing + "\"}", LogEvent.Error);
}
else
{
Log.Write("Error> Failed to get bounds from file: " + importSettings.inputFiles[i], LogEvent.Error);
}
}
}
// NOTE this fails with some files? returns 0,0,0 for some reason
// find lowest bounds from boundsListTemp
float lowestX = float.MaxValue;
float lowestY = float.MaxValue;
float lowestZ = float.MaxValue;
for (int iii = 0; iii < boundsListTemp.Count; iii++)
{
if (boundsListTemp[iii].x < lowestX) lowestX = (float)boundsListTemp[iii].x;
if (boundsListTemp[iii].y < lowestY) lowestY = (float)boundsListTemp[iii].y;
if (boundsListTemp[iii].z < lowestZ) lowestZ = (float)boundsListTemp[iii].z;
}
//Log.Write("Lowest bounds: " + lowestX + " " + lowestY + " " + lowestZ);
// TODO could take center for XZ, and lowest for Y?
importSettings.offsetX = lowestX;
importSettings.offsetY = lowestY;
importSettings.offsetZ = lowestZ;
} // if useAutoOffset
//lasHeaders.Clear();
// clamp to maxfiles
int maxThreads = Math.Min(importSettings.maxThreads, importSettings.maxFiles);
// clamp to min 1
maxThreads = Math.Max(maxThreads, 1);
Log.Write("Using MaxThreads: " + maxThreads);
// init pool
importSettings.InitWriterPool(maxThreads, importSettings.exportFormat);
if (!EnsureMainWriterInitialized(importSettings))
{
Log.Write("Error> Failed to initialize main writer", LogEvent.Error);
Environment.ExitCode = (int)ExitCode.Error;
return;
}
var semaphore = new SemaphoreSlim(maxThreads);
var tasks = new List<Task>();
// launch tasks for all files
for (int i = 0, len = importSettings.maxFiles; i < len; i++)
{
if (cancellationToken.IsCancellationRequested)
{
Environment.ExitCode = (int)ExitCode.Cancelled;
break;
}
await semaphore.WaitAsync();
int slotIndex;
lock (progressSlotLock)
{
slotIndex = freeProgressSlots.Pop();
}
int index = i;
tasks.Add(Task.Run(() =>
{
int? taskId = Task.CurrentId;
try
{
Log.Write($"task:{taskId}, reading file ({index + 1}/{len}) : " +
$"{importSettings.inputFiles[index]} ({Tools.HumanReadableFileSize(new FileInfo(importSettings.inputFiles[index]).Length)})\n");
var ok = ParseFile(importSettings, index, taskId, cancellationToken, slotIndex);
if (!ok)
{
Interlocked.Increment(ref errorCounter);
if (cancellationToken.IsCancellationRequested)
{
Log.Write("Task was canceled.");
}
else if (importSettings.useJSONLog)
{
Log.Write(
"{\"event\":\"" + LogEvent.File + "\",\"path\":" +
System.Text.Json.JsonSerializer.Serialize(importSettings.inputFiles[index]) +
",\"status\":\"" + LogStatus.Processing + "\"}", LogEvent.Error);
}
else
{
Log.Write("files" + importSettings.inputFiles.Count + " i:" + index);
Log.Write("Error> Failed to parse file: " + importSettings.inputFiles[index], LogEvent.Error);
}
}
}
catch (OperationCanceledException)
{
Log.Write("Operation was canceled.");
}
catch (TimeoutException ex)
{
Log.Write("Timeout occurred: " + ex.Message, LogEvent.Error);
}
catch (Exception ex)
{
Log.Write("Exception> " + ex.Message, LogEvent.Error);
}
finally
{
Interlocked.Increment(ref progressFile);
lock (progressSlotLock)
{
freeProgressSlots.Push(slotIndex);
}
// release exactly once per WaitAsync
semaphore.Release();
}
}, cancellationToken));
} // for all files
try
{
await Task.WhenAll(tasks); // Wait for all tasks to complete or be canceled
}
catch (OperationCanceledException)
{
// Treat cancellation as a normal outcome
Environment.ExitCode = (int)ExitCode.Cancelled;
}
// now write header for for pcroot (using main writer)
if (importSettings.exportFormat != ExportFormat.UCPC)
{
importSettings.writer.Close();
// UCPC calls close in Save() itself
}
JsonSerializerSettings settings = new JsonSerializerSettings
{
StringEscapeHandling = StringEscapeHandling.Default // This prevents escaping of characters and write the WKT string properly
};
// add job date
jobMetadata.Job.EndTime = DateTime.Now;
jobMetadata.Job.Elapsed = jobMetadata.Job.EndTime - jobMetadata.Job.StartTime;
string jsonMeta = JsonConvert.SerializeObject(jobMetadata, settings);
// write metadata to file
if (importSettings.importMetadata == true)
{
string pathFolderName = Path.GetFileNameWithoutExtension(importSettings.outputFile);
// for gltf, there is no output filename
if (string.IsNullOrEmpty(pathFolderName))
{
// get final path folder name
pathFolderName = Path.GetFileName(Path.GetDirectoryName(importSettings.inputFiles[0]));
}
var jsonFile = Path.Combine(Path.GetDirectoryName(importSettings.outputFile), pathFolderName + ".json");
Log.Write("Writing metadata to file: " + jsonFile);
File.WriteAllText(jsonFile, jsonMeta);
}
// Emit final job progress after all output writing is complete.
Application.Current.Dispatcher.Invoke(() =>
{
EmitJobProgress(force: true);
jobProgressCompleted = true;
});
lastStatusMessage = "Done!";
Console.ForegroundColor = ConsoleColor.Green;
Log.Write("Finished!");
Console.ForegroundColor = ConsoleColor.White;
mainWindowStatic.Dispatcher.Invoke(() =>
{
if ((bool)mainWindowStatic.chkOpenOutputFolder.IsChecked)
{
var dir = Path.GetDirectoryName(importSettings.outputFile);
if (Directory.Exists(dir))
{
var psi = new ProcessStartInfo
{
FileName = dir,
UseShellExecute = true,
Verb = "open"
};
Process.Start(psi);
}
}
});
stopwatch.Stop();
Log.Write("Elapsed: " + (TimeSpan.FromMilliseconds(stopwatch.ElapsedMilliseconds)).ToString(@"hh\h\ mm\m\ ss\s\ ms\m\s"));
Application.Current.Dispatcher.Invoke(new Action(() =>
{
mainWindowStatic.HideProcessingPanel();
// call update one more time
ProgressTick(null, null);
// clear timer
progressTimerThread.Tick -= ProgressTick;
progressTimerThread.Stop();
progressTimerThread = null;
mainWindowStatic.progressBarFiles.Foreground = Brushes.Green;
}));
}
finally
{
_isProcessing = false;
}
} // ProcessAllFiles
void HideProcessingPanel()
{
gridProcessingPanel.Visibility = Visibility.Hidden;
}
static void StartProgressTimer()
{
//Log.Write("Starting progress timer..*-*************************");
progressTimerThread = new DispatcherTimer(DispatcherPriority.Background, Application.Current.Dispatcher);
progressTimerThread.Tick += ProgressTick;
progressTimerThread.Interval = TimeSpan.FromSeconds(1);
progressTimerThread.Start();
Application.Current.Dispatcher.Invoke(new Action(() =>
{
mainWindowStatic.progressBarFiles.Foreground = Brushes.Red;
//mainWindowStatic.progressBarPoints.Foreground = Brushes.Red;
mainWindowStatic.lblStatus.Content = "";
}));
}
private static List<ProgressInfo> progressInfos = new List<ProgressInfo>();
private static object lockObject = new object();
private static readonly object progressSlotLock = new object();
private static Stack<int> freeProgressSlots;
public class ProgressInfo
{
public int Index { get; internal set; } // Index of the ProgressBar in the UI
public long CurrentValue { get; internal set; } // Current progress value
public long MaxValue { get; internal set; } // Maximum value for the progress
public string FilePath { get; internal set; }
public bool UseJsonLog { get; internal set; }
public int LastPercent = -1;
}
static void InitProgressBars(ImportSettings importSettings)
{
ClearProgressBars();
int threadCount = importSettings.maxThreads;
// clamp to max files
threadCount = Math.Min(threadCount, importSettings.inputFiles.Count);
threadCount = Math.Max(threadCount, 1);
bool useJsonLog = importSettings.useJSONLog;
progressInfos.Clear();
processedFileBytesInFlightBySlot = new long[threadCount];
processedPointsInFlightBySlot = new long[threadCount];
freeProgressSlots = new Stack<int>(threadCount);
for (int i = 0; i < threadCount; i++)
{
ProgressBar newProgressBar = new ProgressBar
{
Height = 10,
Width = (420 / threadCount),
Value = 0,
Maximum = 100,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(1, 0, 1, 0),
Foreground = Brushes.Red,
Background = null,
};
var progressInfo = new ProgressInfo
{
Index = i,
CurrentValue = 0,
MaxValue = 100,
UseJsonLog = useJsonLog,
FilePath = null
};
progressInfos.Add(progressInfo);
mainWindowStatic.ProgressBarsContainer.Children.Add(newProgressBar);
freeProgressSlots.Push(i); // mark slot i as free
}
}
static void ClearProgressBars()
{
mainWindowStatic.ProgressBarsContainer.Children.Clear();
}
static void ProgressTick(object sender, EventArgs e)
{
Application.Current.Dispatcher.Invoke(() =>
{
//if (progressTotalPoints > 0)
//{
mainWindowStatic.progressBarFiles.Value = progressFile;
mainWindowStatic.progressBarFiles.Maximum = progressTotalFiles + 1;
mainWindowStatic.lblStatus.Content = lastStatusMessage;
mainWindowStatic.lblTimer.Content = (TimeSpan.FromMilliseconds(stopwatch.ElapsedMilliseconds)).ToString(@"hh\h\ mm\m\ ss\s");
// Update all progress bars based on the current values in the List
lock (lockObject) // Lock to safely read progressInfos
{
foreach (var info in progressInfos)
{
int idx = info.Index;
long cur = info.CurrentValue;
long max = info.MaxValue <= 0 ? 1 : info.MaxValue; // avoid /0
int percent = (int)(100L * cur / max);
// Update UI bar
if (idx >= 0 && idx < mainWindowStatic.ProgressBarsContainer.Children.Count &&
mainWindowStatic.ProgressBarsContainer.Children[idx] is ProgressBar bar)
{
bar.Maximum = max;
bar.Value = cur;
bar.Foreground = ((cur + 1 >= max) ? Brushes.Lime : Brushes.Red);
}
// skip printing, if no filepath set yet (happens when file is still initializing)
if (info.FilePath == null)
{
continue;
}
// Emit JSON ONLY when percentage changes
if (info.UseJsonLog && percent != info.LastPercent)
{
info.LastPercent = percent;
// Efficient JSON (no string concat)
LogExtensions.WriteProgressUtf8(Log, threadIndex: idx, current: cur, total: max, percent: percent, filePath: info.FilePath);
} // foreach progressinfo
} // lock
}
if (!jobProgressCompleted)
{
EmitJobProgress(force: false);
}
});
} // ProgressTick()
static void EmitJobProgress(bool force)
{
var s = jobMetadata?.Job?.ImportSettings;
if (s == null || !s.trackProgress || !s.useJSONLog) return;
if (!force && jobProgressCompleted) return;
long totalBytes = jobMetadata.Job.TotalFileSizeBytes;
long totalPoints = jobMetadata.Job.TotalPoints;
if (totalBytes <= 0) totalBytes = 1;
if (totalPoints <= 0) totalPoints = 1;
long bytesDone = Interlocked.Read(ref processedFileBytes);
long pointsDone = Interlocked.Read(ref processedPoints);
if (processedFileBytesInFlightBySlot.Length > 0)
{
for (int i = 0; i < processedFileBytesInFlightBySlot.Length; i++)
{
bytesDone += Interlocked.Read(ref processedFileBytesInFlightBySlot[i]);
}
}
if (processedPointsInFlightBySlot.Length > 0)
{
for (int i = 0; i < processedPointsInFlightBySlot.Length; i++)
{
pointsDone += Interlocked.Read(ref processedPointsInFlightBySlot[i]);
}
}
double fileBytesPercent = (double)bytesDone / totalBytes * 100.0;
double pointsPercent = (double)pointsDone / totalPoints * 100.0;
int percent = (int)Math.Clamp(Math.Max(fileBytesPercent, pointsPercent), 0.0, 100.0);
// ProgressTick already runs at 1 Hz; emit job progress every tick.
lastJobPercent = percent;
string jobJson = "{" +
"\"event\":\"" + LogEvent.Progress + "\"," +
"\"scope\":\"job\"," +
"\"processedBytes\":" + bytesDone + "," +
"\"totalBytes\":" + totalBytes + "," +
"\"processedPoints\":" + pointsDone + "," +
"\"totalPoints\":" + totalPoints + "," +
"\"percent\":" + percent +
"}";
Log.Write(jobJson, LogEvent.Progress);
}
static PeekHeaderData PeekHeaderData(ImportSettings importSettings, int fileIndex)
{
var res = importSettings.reader.InitReader(importSettings, fileIndex);
var hd = new PeekHeaderData
{
success = false,
minX = 0,
minY = 0,
minZ = 0
};
if (res == false)
{
Log.Write("Unknown error while initializing reader: " + importSettings.inputFiles[fileIndex]);
Environment.ExitCode = (int)ExitCode.Error;
return hd;
}
var bounds = importSettings.reader.GetBounds();
//Console.WriteLine(bounds.minX + " " + bounds.minY + " " + bounds.minZ);
hd.minX = bounds.minX;
hd.minY = bounds.minY;
hd.minZ = bounds.minZ;
hd.fileSize = new FileInfo(importSettings.inputFiles[fileIndex]).Length;
hd.pointCount = importSettings.reader.GetPointCount();
if (hd.pointCount == 0)
{
Log.Write("Warning> Point count is zero in file: " + importSettings.inputFiles[fileIndex]);
hd.success = false;
}
else
{
hd.success = true;
}
importSettings.reader.Close();
return hd;
}
// process single file
static bool ParseFile(ImportSettings importSettings, int fileIndex, int? taskId, CancellationToken cancellationToken, int slotIndex)
{
progressTotalPoints = 0;
Log.Write("Started processing file: " + importSettings.inputFiles[fileIndex]);
var thisFileSizeBytes = new FileInfo(importSettings.inputFiles[fileIndex]).Length;
IReader taskReader = importSettings.GetOrCreateReader(taskId);
ProgressInfo progressInfo = progressInfos[slotIndex];
bool success = false;
try
{
bool res;
try
{
res = taskReader.InitReader(importSettings, fileIndex);
}
catch (Exception)
{
throw new Exception("Error> Failed to initialize reader: " + importSettings.inputFiles[fileIndex]);
}
if (!res)
{
Log.Write("Unknown error while initializing reader: " + importSettings.inputFiles[fileIndex]);
Environment.ExitCode = (int)ExitCode.Error;
return false;
}
if (importSettings.importMetadataOnly == false)
{
long fullPointCount = taskReader.GetPointCount();
long pointCount = fullPointCount;
// show stats for decimations
if (importSettings.skipPoints == true)
{
var afterSkip = (int)Math.Floor(pointCount - (pointCount / (float)importSettings.skipEveryN));
Log.Write("Skip every X points is enabled, original points: " + fullPointCount + ", After skipping:" + afterSkip);
pointCount = afterSkip;
}
if (importSettings.keepPoints == true)
{
Log.Write("Keep every x points is enabled, original points: " + fullPointCount + ", After keeping:" + (pointCount / importSettings.keepEveryN));
pointCount = pointCount / importSettings.keepEveryN;
}
if (importSettings.useLimit == true)
{
Log.Write("Original points: " + pointCount + " Limited points: " + importSettings.limit);
pointCount = importSettings.limit > pointCount ? pointCount : importSettings.limit;
}
else
{
Log.Write("Points: " + pointCount + " (" + importSettings.inputFiles[fileIndex] + ")");
}
// NOTE only works with formats that have bounds defined in header, otherwise need to loop whole file to get bounds?
// dont use these bounds, in this case
if (importSettings.useAutoOffset == true || ((importSettings.importIntensity == true || importSettings.importClassification == true) && importSettings.importRGB == true && importSettings.packColors == true))
{
// TODO add manual offset here still?
// we use global bounds or Y offset to fix negative Y
}
else if (importSettings.useManualOffset == true)
{
importSettings.offsetX = importSettings.manualOffsetX;
importSettings.offsetY = importSettings.manualOffsetY;
importSettings.offsetZ = importSettings.manualOffsetZ;
}
else // no autooffset either
{
if (importSettings.useAutoOffset == false)
{
importSettings.offsetX = 0;
importSettings.offsetY = 0;
importSettings.offsetZ = 0;
}
}
//Log.Write("************** Offsets: " + importSettings.offsetX + " " + importSettings.offsetY + " " + importSettings.offsetZ);
var taskWriter = importSettings.GetOrCreateWriter(taskId);
// for saving pcroot header, we need this writer
//if (importSettings.exportFormat != ExportFormat.UCPC)
//{
// var mainWriterRes = importSettings.writer.InitWriter(importSettings, pointCount, Log);
// if (mainWriterRes == false)
// {
// Log.Write("Error> Failed to initialize main Writer, fileindex: " + fileIndex + " taskid:" + taskId);
// return false;
// }
//}
// init writer for this file
var writerRes = taskWriter.InitWriter(importSettings, pointCount, Log);
if (writerRes == false)
{
Log.Write("Error> Failed to initialize Writer, fileindex: " + fileIndex + " taskid:" + taskId);
return false;
}
//progressPoint = 0;
progressInfo.CurrentValue = 0;
progressInfo.MaxValue = importSettings.useLimit ? pointCount : fullPointCount;
progressInfo.FilePath = Path.GetFileName(importSettings.inputFiles[fileIndex]); // NOTE using filename only now 04/01/2026
progressInfo.LastPercent = -1;
lastStatusMessage = "Processing points..";
string jsonString = "{" +
"\"event\": \"" + LogEvent.File + "\"," +
"\"path\": " + System.Text.Json.JsonSerializer.Serialize(Path.GetFileName(importSettings.inputFiles[fileIndex])) + "," +
"\"size\": " + new FileInfo(importSettings.inputFiles[fileIndex]).Length + "," +
"\"points\": " + pointCount + "," +
"\"status\": \"" + LogStatus.Processing + "\"" +
"}";
Log.Write(jsonString, LogEvent.File);
long checkCancelEvery = Math.Max(1, fullPointCount / 128);
// detect is 0-255 or 0-65535 range
bool isCustomIntensityRange = false;
// Loop all points
// FIXME: would be nicer, if use different STEP value for skip, keep and limit..(to collect points all over the file, not just start)
long maxPointIterations = importSettings.useLimit ? pointCount : fullPointCount;
for (long i = 0; i < maxPointIterations; i++)
{
// check for cancel every 1% of points
if (i % checkCancelEvery == 0)
{
if (cancellationToken.IsCancellationRequested)
{
//Log.Write("Parse task (" + taskId + ") was canceled for: " + importSettings.inputFiles[fileIndex]);
return false;
}
}
// get point XYZ
var gotPointData = taskReader.GetXYZ(out double px, out double py, out double pz);
if (!gotPointData) break; // TODO display errors somewhere
// get point color
//Color rgb = (default);
float pr = 1f, pg = 1f, pb = 1f;
if (importSettings.importRGB == true)
{
//rgb = taskReader.GetRGB();
taskReader.GetRGB(out pr, out pg, out pb);
// convert from srg to linear (if your model seems too bright)