diff --git a/lib/main.dart b/lib/main.dart index 671907f..7701958 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -66,7 +66,7 @@ class _MyHomePageState extends State { // Services late final DeviceCommunicationService _deviceService; late final NetworkService _networkService; - late final DataProcessingService _dataService; + late final DmpDecoderService _dmpDecoder; late final FileService _fileService; late final FirmwareUpdateService _firmwareService; @@ -176,7 +176,7 @@ class _MyHomePageState extends State { // Initialize services _deviceService = DeviceCommunicationService(); _networkService = NetworkService(); - _dataService = DataProcessingService(); + _dmpDecoder = DmpDecoderService(); _fileService = FileService(); _firmwareService = FirmwareUpdateService(_deviceService); @@ -301,7 +301,7 @@ class _MyHomePageState extends State { for (final fileResult in successfulFiles) { if (fileResult.hasData) { transferBuffer = fileResult.data!; - final dataResult = await _dataService.processTransferBuffer(transferBuffer, unitType); + final dataResult = await _dmpDecoder.processTransferBuffer(transferBuffer, unitType); if (dataResult.success) { setState(() { @@ -375,7 +375,7 @@ class _MyHomePageState extends State { } Future _analyzeTransferBuffer() async { - final result = await _dataService.processTransferBuffer(transferBuffer, unitType); + final result = await _dmpDecoder.processTransferBuffer(transferBuffer, unitType); if (result.success) { setState(() { @@ -533,7 +533,48 @@ class _MyHomePageState extends State { // Export handlers Future _onSaveDMP() async { - await _handleExportOperation(() => _fileService.saveDMPFileFromSections(sections, unitType)); + try { + final result = await _fileService.saveDMPFileFromSections(sections, unitType); + + if (result.needsChoice && result.versionAnalysis != null && mounted) { + // Show dialog for mixed version handling + final choice = await MixedVersionDialog.show(context, result.versionAnalysis!); + + if (choice == MixedVersionChoice.cancel) { + return; + } + + // Get file path + final filePath = await _fileService.getSaveFilePathPublic("DMP", "dmp"); + if (filePath == null) { + return; + } + + FileResult finalResult; + if (choice == MixedVersionChoice.separate) { + finalResult = await _fileService.saveDMPFileSeparate(result.versionAnalysis!, filePath); + } else { + finalResult = await _fileService.saveDMPFileAsV6(sections.selectedSections, filePath); + } + + if (finalResult.success && mounted) { + _showMessage(finalResult.message ?? 'Export completed successfully'); + } else if (!finalResult.success && mounted) { + _showErrorMessage(finalResult.error ?? 'Export failed'); + } + } else { + // Normal result handling + if (result.success && mounted) { + _showMessage(result.message ?? 'Export completed successfully'); + } else if (!result.success && mounted) { + _showErrorMessage(result.error ?? 'Export failed'); + } + } + } catch (e) { + if (mounted) { + _showErrorMessage('Export failed: $e'); + } + } } Future _onExportXLS() async { diff --git a/lib/models/dmp_constants.dart b/lib/models/dmp_constants.dart new file mode 100644 index 0000000..55a67e3 --- /dev/null +++ b/lib/models/dmp_constants.dart @@ -0,0 +1,120 @@ +/// DMP (Data Memory Package) file format constants +/// +/// These constants define the binary structure of MNemo v2 DMP files. +/// See: doc/MNemo DMP File Format - Complete Documentation.md +class DmpConstants { + // Private constructor to prevent instantiation + DmpConstants._(); + + // ============================================================================ + // FILE VERSION MAGIC BYTES (Version 5+) + // ============================================================================ + + /// Magic byte A for file version header (version 5+) + static const int fileVersionMagicA = 68; // 0x44 + + /// Magic byte B for file version header (version 5+) + static const int fileVersionMagicB = 89; // 0x59 + + /// Magic byte C for file version header (version 5+) + static const int fileVersionMagicC = 101; // 0x65 + + // ============================================================================ + // SHOT RECORD MAGIC BYTES (Version 5+) + // ============================================================================ + + /// Magic byte A for shot record start (version 5+) + static const int shotStartMagicA = 57; // 0x39 + + /// Magic byte B for shot record start (version 5+) + static const int shotStartMagicB = 67; // 0x43 + + /// Magic byte C for shot record start (version 5+) + static const int shotStartMagicC = 77; // 0x4D + + /// Magic byte A for shot record end (version 5+) + static const int shotEndMagicA = 95; // 0x5F + + /// Magic byte B for shot record end (version 5+) + static const int shotEndMagicB = 25; // 0x19 + + /// Magic byte C for shot record end (version 5+) + static const int shotEndMagicC = 35; // 0x23 + + // ============================================================================ + // LIDAR DATA MAGIC BYTES (Version 6) + // ============================================================================ + + /// Magic byte A for Lidar data block start (version 6) + static const int lidarStartMagicA = 32; // 0x20 - VOLSTART_VALA + + /// Magic byte B for Lidar data block start (version 6) + static const int lidarStartMagicB = 33; // 0x21 - VOLSTART_VALB + + /// Magic byte C for Lidar data block start (version 6) + static const int lidarStartMagicC = 34; // 0x22 - VOLSTART_VALC + + // ============================================================================ + // FORMAT VERSION LIMITS + // ============================================================================ + + /// Minimum supported DMP file version + static const int minSupportedVersion = 2; + + /// Maximum supported DMP file version + static const int maxSupportedVersion = 6; + + /// First version with magic byte validation + static const int firstVersionWithMagicBytes = 5; + + /// First version with temperature and time data + static const int firstVersionWithTemperature = 3; + + /// First version with LRUD passage measurements + static const int firstVersionWithLRUD = 4; + + /// First version with optional Lidar data + static const int firstVersionWithLidar = 6; + + // ============================================================================ + // RECORD SIZES + // ============================================================================ + + /// Size of section header in bytes + static const int sectionHeaderSize = 13; + + /// Size of shot record in bytes (v5+, excluding optional Lidar data) + static const int shotRecordSize = 35; + + /// Size of each Lidar point in bytes + static const int lidarPointSize = 6; + + // ============================================================================ + // VALIDATION HELPERS + // ============================================================================ + + /// Check if a version number is valid + static bool isValidVersion(int version) { + return version >= minSupportedVersion && version <= maxSupportedVersion; + } + + /// Check if a version uses magic bytes + static bool usesMagicBytes(int version) { + return version >= firstVersionWithMagicBytes; + } + + /// Check if a version supports temperature data + static bool hasTemperature(int version) { + return version >= firstVersionWithTemperature; + } + + /// Check if a version supports LRUD data + static bool hasLRUD(int version) { + return version >= firstVersionWithLRUD; + } + + /// Check if a version supports Lidar data + static bool hasLidar(int version) { + return version >= firstVersionWithLidar; + } +} diff --git a/lib/models/models.dart b/lib/models/models.dart index f2dc2d0..009bd5a 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -4,4 +4,5 @@ export 'shot.dart'; export 'section.dart'; export 'section_list.dart'; export 'survey_quality.dart'; -export 'lidar_data.dart'; \ No newline at end of file +export 'lidar_data.dart'; +export 'dmp_constants.dart'; \ No newline at end of file diff --git a/lib/services/data_processing_service.dart b/lib/services/data_processing_service.dart deleted file mode 100644 index 5d5b076..0000000 --- a/lib/services/data_processing_service.dart +++ /dev/null @@ -1,647 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import '../models/models.dart'; - -/// Service for processing MNemo binary data and converting to survey models -class DataProcessingService { - // File format constants - static const int _fileVersionValueA = 68; - static const int _fileVersionValueB = 89; - static const int _fileVersionValueC = 101; - - static const int _shotStartValueA = 57; - static const int _shotStartValueB = 67; - static const int _shotStartValueC = 77; - - static const int _shotEndValueA = 95; - static const int _shotEndValueB = 25; - static const int _shotEndValueC = 35; - - // V6 format Lidar data constants - static const int _lidarStartValueA = 32; - static const int _lidarStartValueB = 33; - static const int _lidarStartValueC = 34; - - /// Process raw binary transfer buffer into survey sections - Future processTransferBuffer( - List transferBuffer, - UnitType unitType - ) async { - try { - if (transferBuffer.isEmpty) { - return DataProcessingResult.error("Transfer buffer is empty"); - } - - final sections =
[]; - int cursor = 0; - bool brokenSegmentDetected = false; - - final conversionFactor = unitType == UnitType.metric ? 1.0 : 3.28084; - - while (cursor < transferBuffer.length - 2) { - final sectionResult = await _processSection( - transferBuffer, - cursor, - conversionFactor - ); - - if (sectionResult.section != null) { - sections.add(sectionResult.section!); - } - - cursor = sectionResult.newCursor; - - if (sectionResult.brokenSegment) { - brokenSegmentDetected = true; - } - - if (sectionResult.shouldStop) { - break; - } - - // Safety check to prevent infinite loops - if (cursor <= 0 || cursor >= transferBuffer.length) { - break; - } - } - - return DataProcessingResult.success( - sections, - brokenSegmentDetected: brokenSegmentDetected - ); - } catch (e) { - if (kDebugMode) { - debugPrint("Error processing transfer buffer: $e"); - } - return DataProcessingResult.error("Failed to process data: $e"); - } - } - - /// Process a single section from the transfer buffer - Future<_SectionProcessingResult> _processSection( - List transferBuffer, - int startCursor, - double conversionFactor, - ) async { - int cursor = startCursor; - final section = Section(); - bool brokenSegment = false; - - if (kDebugMode) { - debugPrint("DataProcessingService: Starting new section at cursor $cursor"); - } - - // Find file version - int fileVersion = 0; - while (fileVersion != 2 && fileVersion != 3 && fileVersion != 4 && fileVersion != 5 && fileVersion != 6) { - if (cursor >= transferBuffer.length) { - return _SectionProcessingResult(null, cursor, false, true); - } - fileVersion = _readByteFromBuffer(transferBuffer, cursor); - cursor++; - } - - // Validate file version magic bytes for version 5+ - if (fileVersion >= 5) { - if (cursor + 2 >= transferBuffer.length) { - return _SectionProcessingResult(null, cursor, false, true); - } - - final checkByteA = _readByteFromBuffer(transferBuffer, cursor++); - final checkByteB = _readByteFromBuffer(transferBuffer, cursor++); - final checkByteC = _readByteFromBuffer(transferBuffer, cursor++); - - if (checkByteA != _fileVersionValueA || - checkByteB != _fileVersionValueB || - checkByteC != _fileVersionValueC) { - if (kDebugMode) { - debugPrint("DataProcessingService: Invalid file version magic bytes"); - } - return _SectionProcessingResult(null, cursor, false, true); - } - } - - // Read section metadata - if (cursor + 8 >= transferBuffer.length) { - return _SectionProcessingResult(null, cursor, false, true); - } - - final year = _readByteFromBuffer(transferBuffer, cursor++) + 2000; - final month = _readByteFromBuffer(transferBuffer, cursor++); - final day = _readByteFromBuffer(transferBuffer, cursor++); - final hour = _readByteFromBuffer(transferBuffer, cursor++); - final minute = _readByteFromBuffer(transferBuffer, cursor++); - - section.dateSurvey = DateTime(year, month, day, hour, minute); - - // Read section name (3 characters) - final nameBuilder = StringBuffer(); - for (int i = 0; i < 3; i++) { - if (cursor >= transferBuffer.length) { - return _SectionProcessingResult(null, cursor, false, true); - } - nameBuilder.write(utf8.decode([_readByteFromBuffer(transferBuffer, cursor++)])); - } - section.name = nameBuilder.toString(); - - // Read direction - if (cursor >= transferBuffer.length) { - return _SectionProcessingResult(null, cursor, false, true); - } - - final directionIndex = _readByteFromBuffer(transferBuffer, cursor++); - if (directionIndex == 0 || directionIndex == 1) { - section.direction = SurveyDirection.values[directionIndex]; - } else { - if (kDebugMode) { - debugPrint("DataProcessingService: Invalid direction index: $directionIndex"); - } - return _SectionProcessingResult(null, cursor, false, true); - } - - if (kDebugMode) { - debugPrint("DataProcessingService: Section ${section.name}, " - "date: ${section.dateSurvey}, direction: ${section.direction}"); - } - - // Process shots - Shot shot; - do { - final shotResult = await _processShot( - transferBuffer, - cursor, - fileVersion, - conversionFactor - ); - - shot = shotResult.shot; - cursor = shotResult.newCursor; - - if (shotResult.brokenSegment) { - section.brokenFlag = true; - brokenSegment = true; - break; - } - - section.shots.add(shot); - - } while (shot.typeShot != TypeShot.eoc && cursor < transferBuffer.length); - - // Only add section if it contains actual data (more than just EOC shot) - if (section.shots.length > 1) { - if (kDebugMode) { - debugPrint("DataProcessingService: Completed section with ${section.shots.length} shots"); - } - return _SectionProcessingResult(section, cursor, brokenSegment, false); - } else { - return _SectionProcessingResult(null, cursor, brokenSegment, false); - } - } - - /// Process a single shot from the transfer buffer - Future<_ShotProcessingResult> _processShot( - List transferBuffer, - int startCursor, - int fileVersion, - double conversionFactor, - ) async { - int cursor = startCursor; - final shot = Shot.zero(); - - - // Validate shot start magic bytes for version 5+ - if (fileVersion >= 5) { - if (cursor + 2 >= transferBuffer.length) { - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor, true); - } - - final checkByteA = _readByteFromBuffer(transferBuffer, cursor++); - final checkByteB = _readByteFromBuffer(transferBuffer, cursor++); - final checkByteC = _readByteFromBuffer(transferBuffer, cursor++); - - if (checkByteA != _shotStartValueA || - checkByteB != _shotStartValueB || - checkByteC != _shotStartValueC) { - if (kDebugMode) { - debugPrint("DataProcessingService: Invalid shot start magic bytes"); - } - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor - 3, true); - } - } - - // Read shot type - if (cursor >= transferBuffer.length) { - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor, true); - } - - final typeShot = _readByteFromBuffer(transferBuffer, cursor++); - if (typeShot > 3 || typeShot < 0) { - if (kDebugMode) { - debugPrint("DataProcessingService: Invalid shot type: $typeShot"); - } - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor, true); - } - - shot.typeShot = TypeShot.values[typeShot]; - - // This is handling broken sample file I got. Might not be needed in final version. - if (kDebugMode && shot.typeShot == TypeShot.eoc) { - debugPrint("DataProcessingService: EOC shot detected, cursor at $cursor"); - if (cursor + 20 < transferBuffer.length) { - debugPrint("DataProcessingService: Next 20 bytes after type: ${transferBuffer.sublist(cursor, cursor + 20)}"); - } - } - - // For EOC shots, only skip the 9 zero bytes and don't read shot fields - if (shot.typeShot == TypeShot.eoc) { - cursor += 9; // Skip the 9 zero bytes in EOC data - if (kDebugMode) { - debugPrint("DataProcessingService: EOC shot - skipped 9 zero bytes, cursor at $cursor"); - } - - // Some V6 files incorrectly have Lidar data after EOC shots - skip it - if (fileVersion >= 6) { - if (cursor + 2 < transferBuffer.length && - transferBuffer[cursor] == _lidarStartValueA && - transferBuffer[cursor + 1] == _lidarStartValueB && - transferBuffer[cursor + 2] == _lidarStartValueC) { - if (kDebugMode) { - debugPrint("DataProcessingService: Skipping invalid Lidar data after EOC shot at position $cursor"); - } - cursor += 3; // Skip Lidar magic bytes - - if (cursor + 1 < transferBuffer.length) { - final lidarLength = _readIntFromBuffer(transferBuffer, cursor); - cursor += 2; - cursor += lidarLength; // Skip Lidar data - - if (kDebugMode) { - debugPrint("DataProcessingService: Skipped $lidarLength bytes of Lidar data, cursor now at $cursor"); - } - } - } - } - - return _ShotProcessingResult(shot, cursor, false); - } - - // Read shot data (need at least 16 bytes for basic shot data) - if (cursor + 15 >= transferBuffer.length) { - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor, true); - } - - shot.headingIn = _readIntFromBuffer(transferBuffer, cursor) / 10.0; - cursor += 2; - - shot.headingOut = _readIntFromBuffer(transferBuffer, cursor) / 10.0; - cursor += 2; - - shot.length = _readIntFromBuffer(transferBuffer, cursor) * conversionFactor / 100.0; - cursor += 2; - - shot.depthIn = _readIntFromBuffer(transferBuffer, cursor) * conversionFactor / 100.0; - cursor += 2; - - shot.depthOut = _readIntFromBuffer(transferBuffer, cursor) * conversionFactor / 100.0; - cursor += 2; - - shot.pitchIn = _readIntFromBuffer(transferBuffer, cursor) / 10.0; - cursor += 2; - - shot.pitchOut = _readIntFromBuffer(transferBuffer, cursor) / 10.0; - cursor += 2; - - if (kDebugMode) { - debugPrint("DataProcessingService: Shot type=$typeShot, " - "heading=${shot.headingIn}/${shot.headingOut}, " - "length=${shot.length}, " - "depth=${shot.depthIn}/${shot.depthOut}, " - "pitch=${shot.pitchIn}/${shot.pitchOut}"); - } - - - // Read LRUD data for version 4+ - if (fileVersion >= 4) { - if (cursor + 7 >= transferBuffer.length) { - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor, true); - } - - shot.left = _readIntFromBuffer(transferBuffer, cursor) * conversionFactor / 100.0; - cursor += 2; - shot.right = _readIntFromBuffer(transferBuffer, cursor) * conversionFactor / 100.0; - cursor += 2; - shot.up = _readIntFromBuffer(transferBuffer, cursor) * conversionFactor / 100.0; - cursor += 2; - shot.down = _readIntFromBuffer(transferBuffer, cursor) * conversionFactor / 100.0; - cursor += 2; - - if (kDebugMode) { - debugPrint("DataProcessingService: LRUD: ${shot.left} ${shot.right} ${shot.up} ${shot.down}"); - } - } - - // Read temperature and time for version 3+ - if (fileVersion >= 3) { - if (cursor + 4 >= transferBuffer.length) { - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor, true); - } - - shot.temperature = _readIntFromBuffer(transferBuffer, cursor) / 10.0; - cursor += 2; - - shot.hr = _readByteFromBuffer(transferBuffer, cursor++); - shot.min = _readByteFromBuffer(transferBuffer, cursor++); - shot.sec = _readByteFromBuffer(transferBuffer, cursor++); - - if (kDebugMode) { - debugPrint("DataProcessingService: Temperature=${shot.temperature}, " - "Time=${shot.hr}:${shot.min}:${shot.sec}"); - } - } - - // Read marker index - if (cursor >= transferBuffer.length) { - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor, true); - } - - shot.markerIndex = _readByteFromBuffer(transferBuffer, cursor++); - - // Validate shot end magic bytes for version 5+ - if (fileVersion >= 5) { - if (cursor + 2 >= transferBuffer.length) { - shot.typeShot = TypeShot.eoc; - return _ShotProcessingResult(shot, cursor, true); - } - - final checkByteA = _readByteFromBuffer(transferBuffer, cursor++); - final checkByteB = _readByteFromBuffer(transferBuffer, cursor++); - final checkByteC = _readByteFromBuffer(transferBuffer, cursor++); - - if (checkByteA != _shotEndValueA || - checkByteB != _shotEndValueB || - checkByteC != _shotEndValueC) { - if (kDebugMode) { - debugPrint("DataProcessingService: Invalid shot end magic bytes"); - } - return _ShotProcessingResult(shot, cursor - 3, true); - } - } - - // Process optional Lidar data for V6 format (but not for EOC shots) - if (fileVersion >= 6 && shot.typeShot != TypeShot.eoc) { - final lidarResult = await _processLidarData(transferBuffer, cursor, conversionFactor); - shot.lidarData = lidarResult.lidarData; - cursor = lidarResult.newCursor; - - if (lidarResult.brokenSegment) { - return _ShotProcessingResult(shot, cursor, true); - } - } - - return _ShotProcessingResult(shot, cursor, false); - } - - /// Read a single byte from the buffer - int _readByteFromBuffer(List buffer, int address) { - if (address < 0 || address >= buffer.length) { - return 0; - } - return buffer[address]; - } - - /// Read a 16-bit integer from the buffer (little endian) - int _readIntFromBuffer(List buffer, int address) { - - if (address + 1 >= buffer.length) return 0; - - final bytes = Uint8List.fromList([buffer[address], buffer[address + 1]]); - final byteData = ByteData.sublistView(bytes); - return byteData.getInt16(0); - - } - - /// Read a 16-bit integer from the buffer (inverted endian) - int _readInvIntFromBuffer(List buffer, int address) { - if (address + 1 >= buffer.length) return 0; - - final bytes = Uint8List.fromList([buffer[address + 1], buffer[address]]); - final byteData = ByteData.sublistView(bytes); - return byteData.getInt16(0); - } - - int _readUInt16FromBuffer(List buffer, int address) { - if (address + 1 >= buffer.length) return 0; - - // notice the order of the bytes is reversed for uint16 - final bytes = Uint8List.fromList([buffer[address + 1 ], buffer[address]]); - final byteData = ByteData.sublistView(bytes); - return byteData.getUint16(0); - } - - - /// Process optional Lidar data from V6 format - Future<_LidarProcessingResult> _processLidarData( - List transferBuffer, - int startCursor, - double conversionFactor, - ) async { - int cursor = startCursor; - - // Check if there's enough space for Lidar header (3 magic bytes + 2 length bytes) - if (cursor + 4 >= transferBuffer.length) { - if (kDebugMode) { - debugPrint("DataProcessingService: No space for Lidar header, skipping"); - } - return _LidarProcessingResult(null, cursor, false); - } - - // Check for Lidar start magic bytes - final checkByteA = _readByteFromBuffer(transferBuffer, cursor); - final checkByteB = _readByteFromBuffer(transferBuffer, cursor + 1); - final checkByteC = _readByteFromBuffer(transferBuffer, cursor + 2); - - if (checkByteA != _lidarStartValueA || - checkByteB != _lidarStartValueB || - checkByteC != _lidarStartValueC) { - if (kDebugMode) { - debugPrint("DataProcessingService: No Lidar magic bytes found, skipping"); - } - return _LidarProcessingResult(null, cursor, false); - } - - cursor += 3; // Skip magic bytes - - // Read data length - final dataLength = _readIntFromBuffer(transferBuffer, cursor); - cursor += 2; - - // Validate data length and buffer space - if (dataLength == 0 || cursor + dataLength > transferBuffer.length) { - if (kDebugMode) { - debugPrint("DataProcessingService: Invalid Lidar data length or insufficient buffer"); - } - return _LidarProcessingResult(null, cursor, true); - } - - // Each Lidar point is 6 bytes (2 bytes each for YAW, PITCH, DISTANCE) - if (dataLength % 6 != 0) { - if (kDebugMode) { - debugPrint("DataProcessingService: Invalid Lidar data length (not divisible by 6)"); - } - return _LidarProcessingResult(null, cursor + dataLength, true); - } - - final pointCount = dataLength ~/ 6; - final lidarPoints = []; - - // Track statistics for debug output - double minYaw = 0, maxYaw = 0; - double minPitch = 0, maxPitch = 0; - double minDistance = 0, maxDistance = 0; - bool firstPoint = true; - - // Read each Lidar point - for (int i = 0; i < pointCount; i++) { - - final yaw = _readUInt16FromBuffer(transferBuffer, cursor) / 100.0; - cursor += 2; - - final pitch = _readInvIntFromBuffer(transferBuffer, cursor) / 100.0; - cursor += 2; - - final distance = _readUInt16FromBuffer(transferBuffer, cursor) * conversionFactor / 100.0; - cursor += 2; - - // Update statistics - if (kDebugMode) { - - if (firstPoint) { - minYaw = maxYaw = yaw; - minPitch = maxPitch = pitch; - minDistance = maxDistance = distance; - firstPoint = false; - } else { - if (yaw < minYaw) minYaw = yaw; - if (yaw > maxYaw) maxYaw = yaw; - if (pitch < minPitch) minPitch = pitch; - if (pitch > maxPitch) maxPitch = pitch; - if (distance < minDistance) minDistance = distance; - if (distance > maxDistance) maxDistance = distance; - } - } - - lidarPoints.add(LidarPoint( - yaw: yaw, - pitch: pitch, - distance: distance, - )); - } - - final lidarData = LidarData(points: lidarPoints); - - if (kDebugMode && lidarPoints.isNotEmpty) { - // Normalize angle ranges for better representation - final (normalizedMinYaw, normalizedMaxYaw) = _normalizeAngleRange(minYaw, maxYaw); - final (normalizedMinPitch, normalizedMaxPitch) = _normalizeAngleRange(minPitch, maxPitch); - - debugPrint("DataProcessingService: Lidar shot statistics - ${lidarPoints.length} points: " - "Yaw(${normalizedMinYaw.toStringAsFixed(1)}° - ${normalizedMaxYaw.toStringAsFixed(1)}°), " - "Pitch(${normalizedMinPitch.toStringAsFixed(1)}° - ${normalizedMaxPitch.toStringAsFixed(1)}°), " - "Distance(${minDistance}m-${maxDistance}m)"); - } - - return _LidarProcessingResult(lidarData, cursor, false); - } - - /// Normalize angle range to show the most logical representation - /// For example: -5° to +5° instead of 355° to 5° - /// Returns a tuple of (minAngle, maxAngle) in degrees, always with min <= max - (double, double) _normalizeAngleRange(double minAngleDegrees, double maxAngleDegrees) { - // Normalize angles to 0-360 range - double normalizedMin = minAngleDegrees % 360.0; - double normalizedMax = maxAngleDegrees % 360.0; - - if (normalizedMin < 0) normalizedMin += 360.0; - if (normalizedMax < 0) normalizedMax += 360.0; - - // Calculate direct span (clockwise from min to max) - final double directSpan = normalizedMax >= normalizedMin ? - normalizedMax - normalizedMin : - normalizedMin - normalizedMax + 360.0; - - // If direct span is <= 180°, use it; otherwise use boundary-crossing representation - if (directSpan <= 180.0) { - return normalizedMin <= normalizedMax ? - (normalizedMin, normalizedMax) : - (normalizedMax, normalizedMin); - } else { - // Use boundary-crossing representation (shorter path across 0°) - return normalizedMax >= normalizedMin ? - (normalizedMin, normalizedMax - 360.0) : - (normalizedMin - 360.0, normalizedMax); - } - } - -} - -/// Result of data processing operation -class DataProcessingResult { - final bool success; - final List
sections; - final bool brokenSegmentDetected; - final String? error; - - const DataProcessingResult._( - this.success, - this.sections, - this.brokenSegmentDetected, - this.error, - ); - - factory DataProcessingResult.success( - List
sections, { - bool brokenSegmentDetected = false, - }) => - DataProcessingResult._(true, sections, brokenSegmentDetected, null); - - factory DataProcessingResult.error(String error) => - DataProcessingResult._(false,
[], false, error); - - bool get hasData => sections.isNotEmpty; -} - -/// Internal result for section processing -class _SectionProcessingResult { - final Section? section; - final int newCursor; - final bool brokenSegment; - final bool shouldStop; - - _SectionProcessingResult(this.section, this.newCursor, this.brokenSegment, this.shouldStop); -} - -/// Internal result for shot processing -class _ShotProcessingResult { - final Shot shot; - final int newCursor; - final bool brokenSegment; - - _ShotProcessingResult(this.shot, this.newCursor, this.brokenSegment); -} - -/// Internal result for Lidar data processing -class _LidarProcessingResult { - final LidarData? lidarData; - final int newCursor; - final bool brokenSegment; - - _LidarProcessingResult(this.lidarData, this.newCursor, this.brokenSegment); -} \ No newline at end of file diff --git a/lib/services/dmp_decoder_service.dart b/lib/services/dmp_decoder_service.dart new file mode 100644 index 0000000..987a489 --- /dev/null +++ b/lib/services/dmp_decoder_service.dart @@ -0,0 +1,817 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:collection'; +import 'dart:math' as math; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import '../models/models.dart'; + +/// Memory pool for efficient buffer reuse +class _BufferPool { + final Queue> _pool = Queue(); + static const int _maxPoolSize = 5; + static const int _maxBufferSize = 50000; // Don't pool huge buffers + + List acquire() { + if (_pool.isNotEmpty) { + final buffer = _pool.removeFirst(); + buffer.clear(); + return buffer; + } + return []; + } + + void release(List buffer) { + if (_pool.length < _maxPoolSize && buffer.length < _maxBufferSize) { + _pool.add(buffer); + } + } +} + +/// Service for decoding DMP files (CSV to buffer to Section objects) +class DmpDecoderService { + static final _BufferPool _bufferPool = _BufferPool(); + + // ============================================================================ + // FILE-LEVEL OPERATIONS (CSV → Buffer) + // ============================================================================ + + /// Open and parse DMP file(s) - supports both single and multiple selection + Future openDMPFiles() async { + try { + FilePickerResult? result; + + if (Platform.isAndroid || Platform.isIOS) { + result = await FilePicker.platform.pickFiles( + dialogTitle: "Open DMP File(s)", + type: FileType.any, + allowMultiple: true, + ); + } else { + result = await FilePicker.platform.pickFiles( + dialogTitle: "Open DMP File(s) - Hold Ctrl/Cmd or Shift for multiple", + type: FileType.custom, + allowedExtensions: ["dmp"], + allowMultiple: true, + ); + } + + if (result == null) { + return MultiFileResult.cancelled(); + } + + final fileResults = []; + + for (int i = 0; i < result.files.length; i++) { + final platformFile = result.files[i]; + + if (platformFile.path == null) { + continue; + } + + try { + final file = File(platformFile.path!); + + // Early validation pipeline + final validationResult = await _validateDMPFile(file); + if (!validationResult.isValid) { + fileResults.add(FileProcessingResult.error( + fileName: platformFile.name, + error: validationResult.error!, + )); + continue; + } + + final transferBuffer = await parseDMPFileOptimized( + file, + onProgress: (progress) { + // Progress reporting could be exposed here if needed + }, + ); + + fileResults.add(FileProcessingResult.success( + fileName: platformFile.name, + data: transferBuffer, + )); + } catch (e) { + fileResults.add(FileProcessingResult.error( + fileName: platformFile.name, + error: "Failed to parse: $e", + )); + } + } + + return MultiFileResult.success(fileResults); + + } catch (e) { + return MultiFileResult.error("Failed to open DMP files: $e"); + } + } + + /// Early validation pipeline for DMP files + Future<_ValidationResult> _validateDMPFile(File file) async { + try { + if (!await file.exists()) { + return _ValidationResult.invalid("File does not exist"); + } + + final fileSize = await file.length(); + if (fileSize == 0) { + return _ValidationResult.invalid("File is empty (0 bytes)"); + } + + if (fileSize < 48) { + return _ValidationResult.invalid( + "File too small ($fileSize bytes) - minimum 48 bytes required for valid DMP format" + ); + } + + // Quick format validation - read first line to check file version + final firstBytes = await file.openRead(0, math.min(200, fileSize)).toList(); + final firstChunk = utf8.decode(firstBytes.expand((x) => x).toList()); + final firstLineEnd = firstChunk.indexOf('\n'); + final firstLine = firstLineEnd > 0 ? firstChunk.substring(0, firstLineEnd) : firstChunk; + final fields = firstLine.split(';'); + + if (fields.isEmpty) { + return _ValidationResult.invalid("Invalid CSV format - no fields found"); + } + + final version = _parseElementOptimized(fields.first); + if (version == null || !DmpConstants.isValidVersion(version)) { + return _ValidationResult.invalid( + "Invalid DMP file version: ${fields.first}. Supported versions: ${DmpConstants.minSupportedVersion}-${DmpConstants.maxSupportedVersion}" + ); + } + + return _ValidationResult.valid(); + } catch (e) { + return _ValidationResult.invalid("Validation failed: $e"); + } + } + + /// Optimized integer parsing with early type checking + int? _parseElementOptimized(dynamic element) { + if (element == null || element == "") return null; + + // Fast path for integers + if (element is int) return element; + + // Optimized string parsing + if (element is String) { + if (element.isEmpty) return null; + return int.tryParse(element); + } + + // Fallback for other types + return int.tryParse(element.toString()); + } + + /// Stream-based DMP file parsing with batching and progress reporting + Future> parseDMPFileOptimized( + File file, { + void Function(double progress)? onProgress, + }) async { + const batchSize = 1000; + final fileSize = await file.length(); + int processedBytes = 0; + + final transferBuffer = _bufferPool.acquire(); + + try { + final stream = file.openRead() + .transform(utf8.decoder) + .transform(LineSplitter()); + + await for (final line in stream) { + processedBytes += line.length + 1; // +1 for newline + + // Parse CSV line + final fields = line.split(';'); + if (fields.isEmpty) continue; + + // Process in batches to avoid blocking UI + for (int start = 0; start < fields.length; start += batchSize) { + final end = math.min(start + batchSize, fields.length); + + for (int i = start; i < end; i++) { + final value = _parseElementOptimized(fields[i]); + if (value != null) { + transferBuffer.add(value); + } + } + + // Yield control back to UI thread after each batch + if (end - start >= batchSize) { + await Future.delayed(Duration.zero); + } + } + + // Report progress + onProgress?.call(processedBytes / fileSize); + + // Only process first line for DMP files (they're single-line CSV) + break; + } + + if (transferBuffer.isEmpty) { + throw Exception("No valid numeric data found in DMP file"); + } + + // Create a copy since we're returning from the pool + final result = List.from(transferBuffer); + return result; + + } catch (e) { + rethrow; + } finally { + _bufferPool.release(transferBuffer); + } + } + + // ============================================================================ + // BUFFER-LEVEL OPERATIONS (Buffer → Section Objects) + // ============================================================================ + + /// Process raw binary transfer buffer into survey sections + Future processTransferBuffer( + List transferBuffer, + UnitType unitType + ) async { + try { + if (transferBuffer.isEmpty) { + return DataProcessingResult.error("Transfer buffer is empty"); + } + + final sections =
[]; + int cursor = 0; + bool brokenSegmentDetected = false; + + final conversionFactor = unitType == UnitType.metric ? 1.0 : 3.28084; + + while (cursor < transferBuffer.length - 2) { + final sectionResult = await _processSection( + transferBuffer, + cursor, + conversionFactor + ); + + if (sectionResult.section != null) { + sections.add(sectionResult.section!); + } + + cursor = sectionResult.newCursor; + + if (sectionResult.brokenSegment) { + brokenSegmentDetected = true; + } + + if (sectionResult.shouldStop) { + break; + } + + // Safety check to prevent infinite loops + if (cursor <= 0 || cursor >= transferBuffer.length) { + break; + } + } + + return DataProcessingResult.success( + sections, + brokenSegmentDetected: brokenSegmentDetected + ); + } catch (e) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Error processing transfer buffer: $e"); + } + return DataProcessingResult.error("Failed to process data: $e"); + } + } + + /// Process a single section from the transfer buffer + Future<_SectionProcessingResult> _processSection( + List transferBuffer, + int startCursor, + double conversionFactor, + ) async { + int cursor = startCursor; + final section = Section(); + bool brokenSegment = false; + + if (kDebugMode) { + debugPrint("DmpDecoderService: Starting new section at cursor $cursor"); + } + + // Find file version + int fileVersion = 0; + while (fileVersion != 2 && fileVersion != 3 && fileVersion != 4 && fileVersion != 5 && fileVersion != 6) { + if (cursor >= transferBuffer.length) { + return _SectionProcessingResult(null, cursor, false, true); + } + fileVersion = _readByteFromBuffer(transferBuffer, cursor); + cursor++; + } + + // Validate file version magic bytes for version 5+ + if (DmpConstants.usesMagicBytes(fileVersion)) { + if (cursor + 2 >= transferBuffer.length) { + return _SectionProcessingResult(null, cursor, false, true); + } + + final checkByteA = _readByteFromBuffer(transferBuffer, cursor++); + final checkByteB = _readByteFromBuffer(transferBuffer, cursor++); + final checkByteC = _readByteFromBuffer(transferBuffer, cursor++); + + if (checkByteA != DmpConstants.fileVersionMagicA || + checkByteB != DmpConstants.fileVersionMagicB || + checkByteC != DmpConstants.fileVersionMagicC) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Invalid file version magic bytes"); + } + return _SectionProcessingResult(null, cursor, false, true); + } + } + + // Read section metadata + if (cursor + 8 >= transferBuffer.length) { + return _SectionProcessingResult(null, cursor, false, true); + } + + final year = _readByteFromBuffer(transferBuffer, cursor++) + 2000; + final month = _readByteFromBuffer(transferBuffer, cursor++); + final day = _readByteFromBuffer(transferBuffer, cursor++); + final hour = _readByteFromBuffer(transferBuffer, cursor++); + final minute = _readByteFromBuffer(transferBuffer, cursor++); + + section.dateSurvey = DateTime(year, month, day, hour, minute); + + // Read section name (3 characters) + final nameBuilder = StringBuffer(); + for (int i = 0; i < 3; i++) { + if (cursor >= transferBuffer.length) { + return _SectionProcessingResult(null, cursor, false, true); + } + nameBuilder.write(utf8.decode([_readByteFromBuffer(transferBuffer, cursor++)])); + } + section.name = nameBuilder.toString(); + + // Read direction + if (cursor >= transferBuffer.length) { + return _SectionProcessingResult(null, cursor, false, true); + } + + final directionIndex = _readByteFromBuffer(transferBuffer, cursor++); + if (directionIndex == 0 || directionIndex == 1) { + section.direction = SurveyDirection.values[directionIndex]; + } else { + if (kDebugMode) { + debugPrint("DmpDecoderService: Invalid direction index: $directionIndex"); + } + return _SectionProcessingResult(null, cursor, false, true); + } + + if (kDebugMode) { + debugPrint("DmpDecoderService: Section ${section.name}, " + "date: ${section.dateSurvey}, direction: ${section.direction}"); + } + + // Process shots + Shot shot; + do { + final shotResult = await _processShot( + transferBuffer, + cursor, + fileVersion, + conversionFactor + ); + + shot = shotResult.shot; + cursor = shotResult.newCursor; + + if (shotResult.brokenSegment) { + section.brokenFlag = true; + brokenSegment = true; + break; + } + + section.shots.add(shot); + + } while (shot.typeShot != TypeShot.eoc && cursor < transferBuffer.length); + + // Only add section if it contains actual data (more than just EOC shot) + if (section.shots.length > 1) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Completed section with ${section.shots.length} shots"); + } + return _SectionProcessingResult(section, cursor, brokenSegment, false); + } else { + return _SectionProcessingResult(null, cursor, brokenSegment, false); + } + } + + /// Process a single shot from the transfer buffer + Future<_ShotProcessingResult> _processShot( + List transferBuffer, + int startCursor, + int fileVersion, + double conversionFactor, + ) async { + int cursor = startCursor; + final shot = Shot.zero(); + + // Validate shot start magic bytes for version 5+ + if (DmpConstants.usesMagicBytes(fileVersion)) { + if (cursor + 2 >= transferBuffer.length) { + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor, true); + } + + final checkByteA = _readByteFromBuffer(transferBuffer, cursor++); + final checkByteB = _readByteFromBuffer(transferBuffer, cursor++); + final checkByteC = _readByteFromBuffer(transferBuffer, cursor++); + + if (checkByteA != DmpConstants.shotStartMagicA || + checkByteB != DmpConstants.shotStartMagicB || + checkByteC != DmpConstants.shotStartMagicC) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Invalid shot start magic bytes"); + } + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor - 3, true); + } + } + + // Read shot type + if (cursor >= transferBuffer.length) { + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor, true); + } + + final typeShot = _readByteFromBuffer(transferBuffer, cursor++); + if (typeShot > 3 || typeShot < 0) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Invalid shot type: $typeShot"); + } + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor, true); + } + + shot.typeShot = TypeShot.values[typeShot]; + + // For EOC shots, only skip the 9 zero bytes and don't read shot fields + if (shot.typeShot == TypeShot.eoc) { + cursor += 9; // Skip the 9 zero bytes in EOC data + + // Some V6 files incorrectly have Lidar data after EOC shots - skip it + if (DmpConstants.hasLidar(fileVersion)) { + if (cursor + 2 < transferBuffer.length && + transferBuffer[cursor] == DmpConstants.lidarStartMagicA && + transferBuffer[cursor + 1] == DmpConstants.lidarStartMagicB && + transferBuffer[cursor + 2] == DmpConstants.lidarStartMagicC) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Skipping invalid Lidar data after EOC shot"); + } + cursor += 3; // Skip Lidar magic bytes + + if (cursor + 1 < transferBuffer.length) { + final lidarLength = _readInt16(transferBuffer, cursor); + cursor += 2; + cursor += lidarLength; // Skip Lidar data + } + } + } + + return _ShotProcessingResult(shot, cursor, false); + } + + // Read shot data (need at least 16 bytes for basic shot data) + if (cursor + 15 >= transferBuffer.length) { + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor, true); + } + + shot.headingIn = _readInt16(transferBuffer, cursor) / 10.0; + cursor += 2; + + shot.headingOut = _readInt16(transferBuffer, cursor) / 10.0; + cursor += 2; + + shot.length = _readInt16(transferBuffer, cursor) * conversionFactor / 100.0; + cursor += 2; + + shot.depthIn = _readInt16(transferBuffer, cursor) * conversionFactor / 100.0; + cursor += 2; + + shot.depthOut = _readInt16(transferBuffer, cursor) * conversionFactor / 100.0; + cursor += 2; + + shot.pitchIn = _readInt16(transferBuffer, cursor) / 10.0; + cursor += 2; + + shot.pitchOut = _readInt16(transferBuffer, cursor) / 10.0; + cursor += 2; + + // Read LRUD data for version 4+ + if (DmpConstants.hasLRUD(fileVersion)) { + if (cursor + 7 >= transferBuffer.length) { + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor, true); + } + + shot.left = _readInt16(transferBuffer, cursor) * conversionFactor / 100.0; + cursor += 2; + shot.right = _readInt16(transferBuffer, cursor) * conversionFactor / 100.0; + cursor += 2; + shot.up = _readInt16(transferBuffer, cursor) * conversionFactor / 100.0; + cursor += 2; + shot.down = _readInt16(transferBuffer, cursor) * conversionFactor / 100.0; + cursor += 2; + } + + // Read temperature and time for version 3+ + if (DmpConstants.hasTemperature(fileVersion)) { + if (cursor + 4 >= transferBuffer.length) { + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor, true); + } + + shot.temperature = _readInt16(transferBuffer, cursor) / 10.0; + cursor += 2; + + shot.hr = _readByteFromBuffer(transferBuffer, cursor++); + shot.min = _readByteFromBuffer(transferBuffer, cursor++); + shot.sec = _readByteFromBuffer(transferBuffer, cursor++); + } + + // Read marker index + if (cursor >= transferBuffer.length) { + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor, true); + } + + shot.markerIndex = _readByteFromBuffer(transferBuffer, cursor++); + + // Validate shot end magic bytes for version 5+ + if (DmpConstants.usesMagicBytes(fileVersion)) { + if (cursor + 2 >= transferBuffer.length) { + shot.typeShot = TypeShot.eoc; + return _ShotProcessingResult(shot, cursor, true); + } + + final checkByteA = _readByteFromBuffer(transferBuffer, cursor++); + final checkByteB = _readByteFromBuffer(transferBuffer, cursor++); + final checkByteC = _readByteFromBuffer(transferBuffer, cursor++); + + if (checkByteA != DmpConstants.shotEndMagicA || + checkByteB != DmpConstants.shotEndMagicB || + checkByteC != DmpConstants.shotEndMagicC) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Invalid shot end magic bytes"); + } + return _ShotProcessingResult(shot, cursor - 3, true); + } + } + + // Process optional Lidar data for V6 format (but not for EOC shots) + if (DmpConstants.hasLidar(fileVersion) && shot.typeShot != TypeShot.eoc) { + final lidarResult = await _processLidarData(transferBuffer, cursor, conversionFactor); + shot.lidarData = lidarResult.lidarData; + cursor = lidarResult.newCursor; + + if (lidarResult.brokenSegment) { + return _ShotProcessingResult(shot, cursor, true); + } + } + + return _ShotProcessingResult(shot, cursor, false); + } + + /// Process optional Lidar data from V6 format + Future<_LidarProcessingResult> _processLidarData( + List transferBuffer, + int startCursor, + double conversionFactor, + ) async { + int cursor = startCursor; + + // Check if there's enough space for Lidar header (3 magic bytes + 2 length bytes) + if (cursor + 4 >= transferBuffer.length) { + return _LidarProcessingResult(null, cursor, false); + } + + // Check for Lidar start magic bytes + final checkByteA = _readByteFromBuffer(transferBuffer, cursor); + final checkByteB = _readByteFromBuffer(transferBuffer, cursor + 1); + final checkByteC = _readByteFromBuffer(transferBuffer, cursor + 2); + + if (checkByteA != DmpConstants.lidarStartMagicA || + checkByteB != DmpConstants.lidarStartMagicB || + checkByteC != DmpConstants.lidarStartMagicC) { + return _LidarProcessingResult(null, cursor, false); + } + + cursor += 3; // Skip magic bytes + + // Read data length + final dataLength = _readInt16(transferBuffer, cursor); + cursor += 2; + + // Validate data length and buffer space + if (dataLength == 0 || cursor + dataLength > transferBuffer.length) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Invalid Lidar data length or insufficient buffer"); + } + return _LidarProcessingResult(null, cursor, true); + } + + // Each Lidar point is 6 bytes (2 bytes each for YAW, PITCH, DISTANCE) + if (dataLength % 6 != 0) { + if (kDebugMode) { + debugPrint("DmpDecoderService: Invalid Lidar data length (not divisible by 6)"); + } + return _LidarProcessingResult(null, cursor + dataLength, true); + } + + final pointCount = dataLength ~/ 6; + final lidarPoints = []; + + // Read each Lidar point (big-endian for Lidar data) + for (int i = 0; i < pointCount; i++) { + final yaw = _readUInt16Inverted(transferBuffer, cursor) / 100.0; + cursor += 2; + + final pitch = _readInt16Inverted(transferBuffer, cursor) / 100.0; + cursor += 2; + + final distance = _readUInt16Inverted(transferBuffer, cursor) * conversionFactor / 100.0; + cursor += 2; + + lidarPoints.add(LidarPoint( + yaw: yaw, + pitch: pitch, + distance: distance, + )); + } + + final lidarData = LidarData(points: lidarPoints); + + return _LidarProcessingResult(lidarData, cursor, false); + } + + // ============================================================================ + // BUFFER READING HELPERS + // ============================================================================ + + /// Read a single byte from the buffer + int _readByteFromBuffer(List buffer, int address) { + if (address < 0 || address >= buffer.length) { + return 0; + } + return buffer[address]; + } + + /// Read a 16-bit signed integer from the buffer (LSB; MSB - default format) + int _readInt16(List buffer, int address) { + if (address + 1 >= buffer.length) return 0; + + final bytes = Uint8List.fromList([buffer[address], buffer[address + 1]]); + final byteData = ByteData.sublistView(bytes); + return byteData.getInt16(0, Endian.big); + } + + /// Read a 16-bit unsigned integer from buffer (MSB; LSB - for Lidar data) + int _readUInt16Inverted(List buffer, int address) { + if (address + 1 >= buffer.length) return 0; + + final bytes = Uint8List.fromList([buffer[address + 1], buffer[address]]); + final byteData = ByteData.sublistView(bytes); + return byteData.getUint16(0, Endian.big); + } + + /// Read a 16-bit signed integer from buffer (MSB; LSB - for Lidar data) + int _readInt16Inverted(List buffer, int address) { + if (address + 1 >= buffer.length) return 0; + + final bytes = Uint8List.fromList([buffer[address + 1], buffer[address]]); + final byteData = ByteData.sublistView(bytes); + return byteData.getInt16(0, Endian.big); + } +} + +// ============================================================================ +// RESULT CLASSES +// ============================================================================ + +/// Result of data processing operation (buffer → sections) +class DataProcessingResult { + final bool success; + final List
sections; + final bool brokenSegmentDetected; + final String? error; + + const DataProcessingResult._( + this.success, + this.sections, + this.brokenSegmentDetected, + this.error, + ); + + factory DataProcessingResult.success( + List
sections, { + bool brokenSegmentDetected = false, + }) => + DataProcessingResult._(true, sections, brokenSegmentDetected, null); + + factory DataProcessingResult.error(String error) => + DataProcessingResult._(false,
[], false, error); + + bool get hasData => sections.isNotEmpty; +} + +/// Result of processing multiple files +class MultiFileResult { + final bool success; + final List? results; + final String? error; + + const MultiFileResult._(this.success, this.results, this.error); + + factory MultiFileResult.success(List results) => + MultiFileResult._(true, results, null); + + factory MultiFileResult.error(String error) => + MultiFileResult._(false, null, error); + + factory MultiFileResult.cancelled() => + MultiFileResult._(false, null, "Operation cancelled"); + + bool get hasResults => results != null && results!.isNotEmpty; + bool get isCancelled => !success && error == "Operation cancelled"; + + List get successfulFiles => + results?.where((r) => r.success).toList() ?? []; + + List get failedFiles => + results?.where((r) => !r.success).toList() ?? []; +} + +/// Result of processing a single file within a multi-file operation +class FileProcessingResult { + final bool success; + final String fileName; + final List? data; + final String? error; + + const FileProcessingResult._(this.success, this.fileName, this.data, this.error); + + factory FileProcessingResult.success({ + required String fileName, + required List data, + }) => FileProcessingResult._(true, fileName, data, null); + + factory FileProcessingResult.error({ + required String fileName, + required String error, + }) => FileProcessingResult._(false, fileName, null, error); + + bool get hasData => data != null && data!.isNotEmpty; +} + +/// Result of early file validation +class _ValidationResult { + final bool isValid; + final String? error; + + const _ValidationResult._(this.isValid, this.error); + + factory _ValidationResult.valid() => const _ValidationResult._(true, null); + factory _ValidationResult.invalid(String error) => _ValidationResult._(false, error); +} + +/// Internal result for section processing +class _SectionProcessingResult { + final Section? section; + final int newCursor; + final bool brokenSegment; + final bool shouldStop; + + _SectionProcessingResult(this.section, this.newCursor, this.brokenSegment, this.shouldStop); +} + +/// Internal result for shot processing +class _ShotProcessingResult { + final Shot shot; + final int newCursor; + final bool brokenSegment; + + _ShotProcessingResult(this.shot, this.newCursor, this.brokenSegment); +} + +/// Internal result for Lidar data processing +class _LidarProcessingResult { + final LidarData? lidarData; + final int newCursor; + final bool brokenSegment; + + _LidarProcessingResult(this.lidarData, this.newCursor, this.brokenSegment); +} diff --git a/lib/services/dmp_encoder_service.dart b/lib/services/dmp_encoder_service.dart new file mode 100644 index 0000000..dc67f72 --- /dev/null +++ b/lib/services/dmp_encoder_service.dart @@ -0,0 +1,239 @@ +import 'dart:io'; +import 'dart:typed_data'; +import '../models/models.dart'; + +/// Service for encoding sections to DMP format +class DmpEncoderService { + + /// Detect the appropriate DMP version for a section + /// Returns 6 if section has Lidar data OR all shots have depth == 0.0 + /// Returns 5 otherwise + int detectVersion(Section section) { + final shots = section.shots; + if (shots.isEmpty) return 5; + + // Check if any shot has Lidar data + for (final shot in shots) { + if (shot.hasLidarData()) { + return 6; + } + } + + // Check if all shots (excluding EOC) have depth == 0.0 + bool allDepthsZero = true; + for (int i = 0; i < shots.length - 1; i++) { // Exclude last shot (EOC) + final shot = shots[i]; + if (shot.depthIn != 0.0 || shot.depthOut != 0.0) { + allDepthsZero = false; + break; + } + } + + return allDepthsZero ? 6 : 5; + } + + /// Analyze selected sections to determine version distribution + VersionAnalysis analyzeVersions(List
sections) { + final v5Sections =
[]; + final v6Sections =
[]; + + for (final section in sections) { + final version = detectVersion(section); + if (version == 6) { + v6Sections.add(section); + } else { + v5Sections.add(section); + } + } + + return VersionAnalysis( + v5Sections: v5Sections, + v6Sections: v6Sections, + ); + } + + /// Encode multiple sections to a DMP buffer + List encodeSectionsToBuffer(List
sections, int version) { + final buffer = []; + + for (final section in sections) { + _encodeSectionToBuffer(section, version, buffer); + } + + return buffer; + } + + /// Encode a single section to the buffer + void _encodeSectionToBuffer(Section section, int version, List buffer) { + // Section header (13 bytes) + buffer.add(version); + + // Magic bytes for version 5+ + if (DmpConstants.usesMagicBytes(version)) { + buffer.add(DmpConstants.fileVersionMagicA); + buffer.add(DmpConstants.fileVersionMagicB); + buffer.add(DmpConstants.fileVersionMagicC); + } + + // Date/time + buffer.add(section.dateSurvey.year - 2000); + buffer.add(section.dateSurvey.month); + buffer.add(section.dateSurvey.day); + buffer.add(section.dateSurvey.hour); + buffer.add(section.dateSurvey.minute); + + // Section name (3 characters) + final nameBytes = section.name.padRight(3, ' ').substring(0, 3).codeUnits; + buffer.add(nameBytes[0]); + buffer.add(nameBytes[1]); + buffer.add(nameBytes[2]); + + // Direction + buffer.add(section.direction.index); + + // Encode all shots (including broken ones, but with correct magic bytes) + for (final shot in section.shots) { + _encodeShotToBuffer(shot, version, buffer); + } + + // Ensure section ends with an EOC shot (broken sections might not have one) + if (section.shots.isEmpty || section.shots.last.typeShot != TypeShot.eoc) { + _encodeShotToBuffer(Shot.zero()..typeShot = TypeShot.eoc, version, buffer); + } + } + + /// Encode a single shot to the buffer + void _encodeShotToBuffer(Shot shot, int version, List buffer) { + // Shot start magic bytes for version 5+ + if (DmpConstants.usesMagicBytes(version)) { + buffer.add(DmpConstants.shotStartMagicA); + buffer.add(DmpConstants.shotStartMagicB); + buffer.add(DmpConstants.shotStartMagicC); + } + + // Shot type + buffer.add(shot.typeShot.index); + + // Core measurements (14 bytes) + _writeInt16(buffer, (shot.headingIn * 10).round()); + _writeInt16(buffer, (shot.headingOut * 10).round()); + _writeInt16(buffer, (shot.length * 100).round()); + _writeInt16(buffer, (shot.depthIn * 100).round()); + _writeInt16(buffer, (shot.depthOut * 100).round()); + _writeInt16(buffer, (shot.pitchIn * 10).round()); + _writeInt16(buffer, (shot.pitchOut * 10).round()); + + // LRUD data (8 bytes) - version 4+ + if (DmpConstants.hasLRUD(version)) { + _writeInt16(buffer, (shot.left * 100).round()); + _writeInt16(buffer, (shot.right * 100).round()); + _writeInt16(buffer, (shot.up * 100).round()); + _writeInt16(buffer, (shot.down * 100).round()); + } + + // Temperature and time (5 bytes) - version 3+ + if (DmpConstants.hasTemperature(version)) { + _writeInt16(buffer, (shot.temperature * 10).round()); + buffer.add(shot.hr); + buffer.add(shot.min); + buffer.add(shot.sec); + } + + // Marker index (1 byte) + buffer.add(shot.markerIndex); + + // Shot end magic bytes for version 5+ + if (DmpConstants.usesMagicBytes(version)) { + buffer.add(DmpConstants.shotEndMagicA); + buffer.add(DmpConstants.shotEndMagicB); + buffer.add(DmpConstants.shotEndMagicC); + } + + // Lidar data for version 6 + if (DmpConstants.hasLidar(version) && shot.hasLidarData()) { + _encodeLidarData(shot.lidarData!, buffer); + } + } + + /// Encode Lidar data to buffer (V6 only) + void _encodeLidarData(LidarData lidarData, List buffer) { + // Lidar magic bytes + buffer.add(DmpConstants.lidarStartMagicA); + buffer.add(DmpConstants.lidarStartMagicB); + buffer.add(DmpConstants.lidarStartMagicC); + + // Data length + final dataLength = lidarData.points.length * 6; + _writeInt16(buffer, dataLength); + + // Lidar points (big-endian for yaw/pitch/distance) + for (final point in lidarData.points) { + _writeUInt16Inverted(buffer, (point.yaw * 100).round()); + _writeInt16Inverted(buffer, (point.pitch * 100).round()); + _writeUInt16Inverted(buffer, (point.distance * 100).round()); + } + } + + /// Write a 16-bit signed integer to buffer (LSB;MSB - DMP format default) + void _writeInt16(List buffer, int value) { + final bytes = Uint8List(2); + final byteData = ByteData.sublistView(bytes); + byteData.setInt16(0, value, Endian.big); + buffer.add(bytes[0]); + buffer.add(bytes[1]); + } + + /// Write a 16-bit unsigned integer to buffer (MSB; LSB - for Lidar data) + void _writeUInt16Inverted(List buffer, int value) { + final bytes = Uint8List(2); + final byteData = ByteData.sublistView(bytes); + byteData.setUint16(0, value, Endian.big); + buffer.add(bytes[1]); + buffer.add(bytes[0]); + } + + /// Write a 16-bit signed integer to buffer (MSB; LSB - for Lidar data) + void _writeInt16Inverted(List buffer, int value) { + final bytes = Uint8List(2); + final byteData = ByteData.sublistView(bytes); + byteData.setInt16(0, value, Endian.big); + buffer.add(bytes[1]); + buffer.add(bytes[0]); + } + + /// Write buffer to file in CSV format + Future writeBufferToFile(List buffer, File file) async { + final sink = file.openWrite(); + + for (int i = 0; i < buffer.length; i++) { + final value = (buffer[i] >= -128 && buffer[i] <= 127) + ? buffer[i] + : -(256 - buffer[i]); + sink.write("$value;"); + } + + await sink.flush(); + await sink.close(); + } +} + +/// Analysis result of version distribution in sections +class VersionAnalysis { + final List
v5Sections; + final List
v6Sections; + + VersionAnalysis({ + required this.v5Sections, + required this.v6Sections, + }); + + bool get hasV5 => v5Sections.isNotEmpty; + bool get hasV6 => v6Sections.isNotEmpty; + bool get isMixed => hasV5 && hasV6; + bool get isAllV5 => hasV5 && !hasV6; + bool get isAllV6 => hasV6 && !hasV5; + + int get v5Count => v5Sections.length; + int get v6Count => v6Sections.length; + int get totalCount => v5Count + v6Count; +} diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 7b31285..12a4e11 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -1,238 +1,28 @@ import 'dart:io'; -import 'dart:convert'; -import 'dart:collection'; -import 'dart:math' as math; import 'package:file_picker/file_picker.dart'; import '../models/models.dart'; import '../excelexport.dart'; import '../survexporter.dart'; import '../thexporter.dart'; - -/// Memory pool for efficient buffer reuse -class _BufferPool { - final Queue> _pool = Queue(); - static const int _maxPoolSize = 5; - static const int _maxBufferSize = 50000; // Don't pool huge buffers - - List acquire() { - if (_pool.isNotEmpty) { - final buffer = _pool.removeFirst(); - buffer.clear(); - return buffer; - } - return []; - } - - void release(List buffer) { - if (_pool.length < _maxPoolSize && buffer.length < _maxBufferSize) { - _pool.add(buffer); - } - } -} +import 'dmp_decoder_service.dart'; +import 'dmp_encoder_service.dart'; /// Service for handling file operations (import/export) class FileService { - static final _BufferPool _bufferPool = _BufferPool(); + final DmpDecoderService _dmpDecoder = DmpDecoderService(); + final DmpEncoderService _dmpEncoder = DmpEncoderService(); /// Open and parse DMP file(s) - supports both single and multiple selection Future openDMPFiles() async { - try { - FilePickerResult? result; - - if (Platform.isAndroid || Platform.isIOS) { - result = await FilePicker.platform.pickFiles( - dialogTitle: "Open DMP File(s)", - type: FileType.any, - allowMultiple: true, - ); - } else { - result = await FilePicker.platform.pickFiles( - dialogTitle: "Open DMP File(s) - Hold Ctrl/Cmd or Shift for multiple", - type: FileType.custom, - allowedExtensions: ["dmp"], - allowMultiple: true, - ); - } - - if (result == null) { - return MultiFileResult.cancelled(); - } - - final fileResults = []; - - for (int i = 0; i < result.files.length; i++) { - final platformFile = result.files[i]; - - if (platformFile.path == null) { - continue; - } - - try { - final file = File(platformFile.path!); - - // Early validation pipeline - final validationResult = await _validateDMPFile(file); - if (!validationResult.isValid) { - fileResults.add(FileProcessingResult.error( - fileName: platformFile.name, - error: validationResult.error!, - )); - continue; - } - - final transferBuffer = await parseDMPFileOptimized( - file, - onProgress: (progress) { - // Progress reporting could be exposed here if needed - }, - ); - - fileResults.add(FileProcessingResult.success( - fileName: platformFile.name, - data: transferBuffer, - )); - } catch (e) { - fileResults.add(FileProcessingResult.error( - fileName: platformFile.name, - error: "Failed to parse: $e", - )); - } - } - - return MultiFileResult.success(fileResults); - - } catch (e) { - return MultiFileResult.error("Failed to open DMP files: $e"); - } + return _dmpDecoder.openDMPFiles(); } - /// Early validation pipeline for DMP files - Future<_ValidationResult> _validateDMPFile(File file) async { - try { - if (!await file.exists()) { - return _ValidationResult.invalid("File does not exist"); - } - - final fileSize = await file.length(); - if (fileSize == 0) { - return _ValidationResult.invalid("File is empty (0 bytes)"); - } - - if (fileSize < 48) { - return _ValidationResult.invalid( - "File too small ($fileSize bytes) - minimum 48 bytes required for valid DMP format" - ); - } - - // Quick format validation - read first line to check file version - final firstBytes = await file.openRead(0, math.min(200, fileSize)).toList(); - final firstChunk = utf8.decode(firstBytes.expand((x) => x).toList()); - final firstLineEnd = firstChunk.indexOf('\n'); - final firstLine = firstLineEnd > 0 ? firstChunk.substring(0, firstLineEnd) : firstChunk; - final fields = firstLine.split(';'); - - if (fields.isEmpty) { - return _ValidationResult.invalid("Invalid CSV format - no fields found"); - } - - final version = _parseElementOptimized(fields.first); - if (version == null || version < 2 || version > 6) { - return _ValidationResult.invalid( - "Invalid DMP file version: ${fields.first}. Supported versions: 2-6" - ); - } - - return _ValidationResult.valid(); - } catch (e) { - return _ValidationResult.invalid("Validation failed: $e"); - } - } - - /// Optimized integer parsing with early type checking - int? _parseElementOptimized(dynamic element) { - if (element == null || element == "") return null; - - // Fast path for integers - if (element is int) return element; - - // Optimized string parsing - if (element is String) { - if (element.isEmpty) return null; - return int.tryParse(element); - } - - // Fallback for other types - return int.tryParse(element.toString()); - } - - /// Stream-based DMP file parsing with batching and progress reporting - Future> parseDMPFileOptimized( - File file, { - void Function(double progress)? onProgress, - }) async { - const batchSize = 1000; - final fileSize = await file.length(); - int processedBytes = 0; - - final transferBuffer = _bufferPool.acquire(); - - try { - final stream = file.openRead() - .transform(utf8.decoder) - .transform(LineSplitter()); - - await for (final line in stream) { - processedBytes += line.length + 1; // +1 for newline - - // Parse CSV line - final fields = line.split(';'); - if (fields.isEmpty) continue; - - // Process in batches to avoid blocking UI - for (int start = 0; start < fields.length; start += batchSize) { - final end = math.min(start + batchSize, fields.length); - - for (int i = start; i < end; i++) { - final value = _parseElementOptimized(fields[i]); - if (value != null) { - transferBuffer.add(value); - } - } - - // Yield control back to UI thread after each batch - if (end - start >= batchSize) { - await Future.delayed(Duration.zero); - } - } - - // Report progress - onProgress?.call(processedBytes / fileSize); - - // Only process first line for DMP files (they're single-line CSV) - break; - } - - if (transferBuffer.isEmpty) { - throw Exception("No valid numeric data found in DMP file"); - } - - // Create a copy since we're returning from the pool - final result = List.from(transferBuffer); - return result; - - } catch (e) { - rethrow; - } finally { - _bufferPool.release(transferBuffer); - } - } - - /// Legacy method for backward compatibility + /// Parse a single DMP file Future> parseDMPFile(File file) async { - return parseDMPFileOptimized(file); + return _dmpDecoder.parseDMPFileOptimized(file); } - /// Save data as DMP file + /// Save data as DMP file (raw buffer) Future saveDMPFile(List transferBuffer) async { try { final result = await _getSaveFilePath("DMP", "dmp"); @@ -241,51 +31,92 @@ class FileService { } final file = File(result); - final sink = file.openWrite(); - - for (var element in transferBuffer) { - final value = (element >= -128 && element <= 127) - ? element - : -(256 - element); - sink.write("$value;"); - } + await _dmpEncoder.writeBufferToFile(transferBuffer, file); - await sink.flush(); - await sink.close(); - return FileResult.success(null, message: "DMP file saved successfully"); - + } catch (e) { return FileResult.error("Failed to save DMP file: $e"); } } - /// Save selected sections as DMP file (simplified approach) + /// Save selected sections as DMP file Future saveDMPFileFromSections(SectionList sections, UnitType unitType) async { try { + final selectedSections = sections.selectedSections; + + if (selectedSections.isEmpty) { + return FileResult.error("No sections selected"); + } + + // Analyze version distribution + final analysis = _dmpEncoder.analyzeVersions(selectedSections); + + // If mixed versions, return special result for UI to handle + if (analysis.isMixed) { + return FileResult.needsUserChoice(analysis); + } + + // All sections are same version - proceed with export + final version = analysis.isAllV6 ? 6 : 5; + final result = await _getSaveFilePath("DMP (Selected)", "dmp"); if (result == null) { return FileResult.cancelled(); } - // For now, we'll use a simplified approach since full DMP reconstruction is complex - // We create a dummy DMP with just a placeholder for selected sections + final buffer = _dmpEncoder.encodeSectionsToBuffer(selectedSections, version); final file = File(result); - final sink = file.openWrite(); - - // Write a simple text representation of selected sections - sink.write("# Selected sections DMP export\n"); - sink.write("# ${sections.selectedSections.length} sections selected\n"); - for (final section in sections.selectedSections) { - sink.write("# Section: ${section.name} (${section.shots.length} shots)\n"); + await _dmpEncoder.writeBufferToFile(buffer, file); + + return FileResult.success(null, message: "DMP file saved successfully (v$version format)"); + + } catch (e) { + return FileResult.error("Failed to save DMP file: $e"); + } + } + + /// Save sections as separate v5 and v6 DMP files + Future saveDMPFileSeparate(VersionAnalysis analysis, String basePath) async { + try { + // Remove .dmp extension if present + final basePathWithoutExt = basePath.toLowerCase().endsWith('.dmp') + ? basePath.substring(0, basePath.length - 4) + : basePath; + + // Save v5 sections if any + if (analysis.hasV5) { + final v5Buffer = _dmpEncoder.encodeSectionsToBuffer(analysis.v5Sections, 5); + final v5File = File('${basePathWithoutExt}_v5.dmp'); + await _dmpEncoder.writeBufferToFile(v5Buffer, v5File); } - sink.write("# Note: This is a simplified export. Use other formats for full data.\n"); - await sink.flush(); - await sink.close(); - - return FileResult.success(null, message: "Selected sections info saved as DMP file"); - + // Save v6 sections if any + if (analysis.hasV6) { + final v6Buffer = _dmpEncoder.encodeSectionsToBuffer(analysis.v6Sections, 6); + final v6File = File('${basePathWithoutExt}_v6.dmp'); + await _dmpEncoder.writeBufferToFile(v6Buffer, v6File); + } + + return FileResult.success( + null, + message: "Saved ${analysis.v5Count} v5 sections and ${analysis.v6Count} v6 sections in separate files" + ); + + } catch (e) { + return FileResult.error("Failed to save separate DMP files: $e"); + } + } + + /// Save all sections as v6 DMP file (converts v5 to v6 format) + Future saveDMPFileAsV6(List
sections, String filePath) async { + try { + final buffer = _dmpEncoder.encodeSectionsToBuffer(sections, 6); + final file = File(filePath); + await _dmpEncoder.writeBufferToFile(buffer, file); + + return FileResult.success(null, message: "DMP file saved successfully (all as v6 format)"); + } catch (e) { return FileResult.error("Failed to save DMP file: $e"); } @@ -358,6 +189,11 @@ class FileService { } } + /// Public method to get save file path (for external use) + Future getSaveFilePathPublic(String dialogTitle, String extension) async { + return _getSaveFilePath(dialogTitle, extension); + } + /// Get file path for mobile platforms Future _getMobileFilePath(String extension) async { // TODO: Context needs to be provided by the caller for mobile file picker @@ -405,78 +241,30 @@ class FileResult { final List? data; final String? message; final String? error; - - const FileResult._(this.success, this.data, this.message, this.error); + final VersionAnalysis? versionAnalysis; + final bool needsChoice; + + const FileResult._( + this.success, + this.data, + this.message, + this.error, + this.versionAnalysis, + this.needsChoice, + ); factory FileResult.success(List? data, {String? message}) => - FileResult._(true, data, message, null); + FileResult._(true, data, message, null, null, false); factory FileResult.error(String error) => - FileResult._(false, null, null, error); + FileResult._(false, null, null, error, null, false); factory FileResult.cancelled() => - FileResult._(false, null, "Operation cancelled", null); - - bool get hasData => data != null && data!.isNotEmpty; - bool get isCancelled => !success && error == null; -} - -/// Result of processing multiple files -class MultiFileResult { - final bool success; - final List? results; - final String? error; - - const MultiFileResult._(this.success, this.results, this.error); - - factory MultiFileResult.success(List results) => - MultiFileResult._(true, results, null); - - factory MultiFileResult.error(String error) => - MultiFileResult._(false, null, error); - - factory MultiFileResult.cancelled() => - MultiFileResult._(false, null, "Operation cancelled"); + FileResult._(false, null, "Operation cancelled", null, null, false); - bool get hasResults => results != null && results!.isNotEmpty; - bool get isCancelled => !success && error == "Operation cancelled"; - - List get successfulFiles => - results?.where((r) => r.success).toList() ?? []; - - List get failedFiles => - results?.where((r) => !r.success).toList() ?? []; -} - -/// Result of processing a single file within a multi-file operation -class FileProcessingResult { - final bool success; - final String fileName; - final List? data; - final String? error; - - const FileProcessingResult._(this.success, this.fileName, this.data, this.error); - - factory FileProcessingResult.success({ - required String fileName, - required List data, - }) => FileProcessingResult._(true, fileName, data, null); - - factory FileProcessingResult.error({ - required String fileName, - required String error, - }) => FileProcessingResult._(false, fileName, null, error); + factory FileResult.needsUserChoice(VersionAnalysis analysis) => + FileResult._(false, null, null, null, analysis, true); bool get hasData => data != null && data!.isNotEmpty; -} - -/// Result of early file validation -class _ValidationResult { - final bool isValid; - final String? error; - - const _ValidationResult._(this.isValid, this.error); - - factory _ValidationResult.valid() => const _ValidationResult._(true, null); - factory _ValidationResult.invalid(String error) => _ValidationResult._(false, error); + bool get isCancelled => !success && error == null && !needsChoice; } \ No newline at end of file diff --git a/lib/services/lidar_coordinate_calculator.dart b/lib/services/lidar_coordinate_calculator.dart deleted file mode 100644 index 860e418..0000000 --- a/lib/services/lidar_coordinate_calculator.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'dart:math'; -import '../mapsurvey.dart'; -import '../models/models.dart'; - -/// Utility class for calculating Lidar global coordinates on-demand -class LidarCoordinateCalculator { - /// Calculate global coordinates for a Lidar point given shot position - static Point3d calculateGlobalCoordinates( - LidarPoint point, - double shotX, - double shotY, - double shotZ, - ) { - // Convert angles to radians - final yawRadians = point.yaw * pi / 180.0; - final pitchRadians = point.pitch * pi / 180.0; - final distanceMeters = point.distance; - - // Calculate global coordinates relative to shot position - // X: East-West (positive = East) - sin(yaw) where yaw=0 is north - // Y: North-South (positive = North) - cos(yaw) where yaw=0 is north - // Z: Vertical (positive = up) - final globalX = shotX + distanceMeters * cos(pitchRadians) * sin(yawRadians); - final globalY = shotY + distanceMeters * cos(pitchRadians) * cos(yawRadians); - final globalZ = shotZ + distanceMeters * sin(pitchRadians); - - return Point3d(globalX, globalY, globalZ); - } - - /// Calculate shot positions for a section (replicates MapSurvey.buildMap logic) - static List calculateShotPositions(Section section) { - final points = []; - final start = Point3d(0, 0, section.shots.first.depthIn); - points.add(start); - - for (int i = 0; i < section.shots.length; i++) { - final shot = section.shots[i]; - - // Use calculated length if shot is problematic, otherwise use original length - final lengthToUse = shot.hasProblematicLength() ? shot.getCalculatedLength() : shot.length; - - // Calculate horizontal distance using corrected length - final depthChange = shot.depthOut - shot.depthIn; // Preserve sign for direction - final absDepthChange = depthChange.abs(); - double horizontalDistance; - - if (lengthToUse <= absDepthChange) { - // For vertical or near-vertical shots, use minimal horizontal distance - horizontalDistance = 0.1; - } else { - horizontalDistance = sqrt(pow(lengthToUse, 2) - pow(absDepthChange, 2)); - } - - // Get the best vertical displacement (depth sensor or calculated from angles) - final verticalDisplacement = shot.getBestVerticalDisplacement(); - - points.add(Point3d( - points[i].x + horizontalDistance * sin(-shot.headingOut * pi / 180.0), - points[i].y + horizontalDistance * cos(shot.headingOut * pi / 180.0), - points[i].z + verticalDisplacement // Use calculated vertical displacement - )); - } - - return points; - } - - /// Get all Lidar points with global coordinates for a section - static List getAllLidarGlobalPoints(Section section) { - final shotPositions = calculateShotPositions(section); - final allLidarPoints = []; - - for (int shotIndex = 0; shotIndex < section.shots.length - 1; shotIndex++) { - final shot = section.shots[shotIndex]; - if (!shot.hasLidarData() || shotIndex + 1 >= shotPositions.length) continue; - - final lidarData = shot.lidarData!; - // Lidar measurements are taken at the arriving point (end) of the shot - final shotPosition = shotPositions[shotIndex + 1]; - - for (final lidarPoint in lidarData.points) { - final globalPoint = calculateGlobalCoordinates( - lidarPoint, - shotPosition.x, - shotPosition.y, - shotPosition.z, - ); - allLidarPoints.add(globalPoint); - } - } - - return allLidarPoints; - } -} \ No newline at end of file diff --git a/lib/services/services.dart b/lib/services/services.dart index a90e134..64a4b91 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -1,6 +1,7 @@ // Export file for all services export 'device_communication_service.dart'; export 'network_service.dart'; -export 'data_processing_service.dart'; export 'file_service.dart'; -export 'firmware_update_service.dart'; \ No newline at end of file +export 'firmware_update_service.dart'; +export 'dmp_decoder_service.dart'; +export 'dmp_encoder_service.dart'; \ No newline at end of file diff --git a/lib/widgets/mixed_version_dialog.dart b/lib/widgets/mixed_version_dialog.dart new file mode 100644 index 0000000..177bba3 --- /dev/null +++ b/lib/widgets/mixed_version_dialog.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import '../services/dmp_encoder_service.dart'; + +/// Dialog shown when user attempts to export mixed v5/v6 sections +class MixedVersionDialog extends StatelessWidget { + final VersionAnalysis analysis; + + const MixedVersionDialog({ + super.key, + required this.analysis, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Mixed DMP Versions Detected'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Your selection contains sections of different DMP versions:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Text('• ${analysis.v5Count} section${analysis.v5Count != 1 ? 's' : ''} in v5 format (underwater/depth data)'), + Text('• ${analysis.v6Count} section${analysis.v6Count != 1 ? 's' : ''} in v6 format (dry cave/Lidar data)'), + const SizedBox(height: 16), + const Text('How would you like to export?'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(MixedVersionChoice.cancel), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(MixedVersionChoice.separate), + child: const Text('Separate Files'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(MixedVersionChoice.unifiedV6), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: const Text('Export All as v6'), + ), + ], + ); + } + + /// Show the dialog and return user's choice + static Future show( + BuildContext context, + VersionAnalysis analysis, + ) async { + final choice = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => MixedVersionDialog(analysis: analysis), + ); + return choice ?? MixedVersionChoice.cancel; + } +} + +/// User's choice for handling mixed version export +enum MixedVersionChoice { + cancel, + separate, // Export in separate _v5.dmp and _v6.dmp files + unifiedV6, // Export all as v6 format +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index d59283c..17eacc8 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -7,4 +7,5 @@ export 'software_update_bar.dart'; export 'network_connection_panel.dart'; export 'data_toolbar.dart'; export 'cli_interface.dart'; -export 'welcome_screen.dart'; \ No newline at end of file +export 'welcome_screen.dart'; +export 'mixed_version_dialog.dart'; \ No newline at end of file diff --git a/test/dmp_export_test.dart b/test/dmp_export_test.dart new file mode 100644 index 0000000..a9b7ad1 --- /dev/null +++ b/test/dmp_export_test.dart @@ -0,0 +1,264 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mnemolink/models/models.dart'; +import 'package:mnemolink/services/dmp_encoder_service.dart'; +import 'dart:io'; + +void main() { + group('DMP Encoder Service Tests', () { + late DmpEncoderService encoder; + + setUp(() { + encoder = DmpEncoderService(); + }); + + test('detectVersion should return 6 for sections with Lidar data', () { + final shot = Shot( + typeShot: TypeShot.std, + length: 2.0, + headingIn: 90.0, + headingOut: 90.0, + pitchIn: 0.0, + pitchOut: 0.0, + depthIn: 0.0, + depthOut: 0.0, + lidarData: const LidarData(points: [ + LidarPoint(yaw: 90.0, pitch: 0.0, distance: 2.0), + ]), + ); + + final section = Section( + name: 'AA1', + dateSurvey: DateTime(2025, 9, 29), + shots: [shot, Shot(typeShot: TypeShot.eoc)], + ); + + expect(encoder.detectVersion(section), 6); + }); + + test('detectVersion should return 6 for sections with all zero depths', () { + final shot = Shot( + typeShot: TypeShot.std, + length: 2.0, + headingIn: 90.0, + headingOut: 90.0, + pitchIn: 0.0, + pitchOut: 0.0, + depthIn: 0.0, + depthOut: 0.0, + ); + + final section = Section( + name: 'AA1', + dateSurvey: DateTime(2025, 9, 29), + shots: [shot, Shot(typeShot: TypeShot.eoc)], + ); + + expect(encoder.detectVersion(section), 6); + }); + + test('detectVersion should return 5 for sections with non-zero depths', () { + final shot = Shot( + typeShot: TypeShot.std, + length: 2.0, + headingIn: 90.0, + headingOut: 90.0, + pitchIn: 0.0, + pitchOut: 0.0, + depthIn: 1.0, + depthOut: 2.0, + ); + + final section = Section( + name: 'AA1', + dateSurvey: DateTime(2025, 9, 29), + shots: [shot, Shot(typeShot: TypeShot.eoc)], + ); + + expect(encoder.detectVersion(section), 5); + }); + + test('analyzeVersions should correctly identify all v5 sections', () { + final section1 = Section( + name: 'AA1', + dateSurvey: DateTime(2025, 9, 29), + shots: [ + Shot(typeShot: TypeShot.std, depthIn: 1.0, depthOut: 2.0), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final section2 = Section( + name: 'AA2', + dateSurvey: DateTime(2025, 9, 29), + shots: [ + Shot(typeShot: TypeShot.std, depthIn: 2.0, depthOut: 3.0), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final analysis = encoder.analyzeVersions([section1, section2]); + + expect(analysis.isAllV5, true); + expect(analysis.isAllV6, false); + expect(analysis.isMixed, false); + expect(analysis.v5Count, 2); + expect(analysis.v6Count, 0); + }); + + test('analyzeVersions should correctly identify all v6 sections', () { + final section1 = Section( + name: 'AA1', + dateSurvey: DateTime(2025, 9, 29), + shots: [ + Shot(typeShot: TypeShot.std, depthIn: 0.0, depthOut: 0.0), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final section2 = Section( + name: 'AA2', + dateSurvey: DateTime(2025, 9, 29), + shots: [ + Shot(typeShot: TypeShot.std, depthIn: 0.0, depthOut: 0.0), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final analysis = encoder.analyzeVersions([section1, section2]); + + expect(analysis.isAllV5, false); + expect(analysis.isAllV6, true); + expect(analysis.isMixed, false); + expect(analysis.v5Count, 0); + expect(analysis.v6Count, 2); + }); + + test('analyzeVersions should correctly identify mixed versions', () { + final v5Section = Section( + name: 'AA1', + dateSurvey: DateTime(2025, 9, 29), + shots: [ + Shot(typeShot: TypeShot.std, depthIn: 1.0, depthOut: 2.0), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final v6Section = Section( + name: 'AA2', + dateSurvey: DateTime(2025, 9, 29), + shots: [ + Shot(typeShot: TypeShot.std, depthIn: 0.0, depthOut: 0.0), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final analysis = encoder.analyzeVersions([v5Section, v6Section]); + + expect(analysis.isAllV5, false); + expect(analysis.isAllV6, false); + expect(analysis.isMixed, true); + expect(analysis.v5Count, 1); + expect(analysis.v6Count, 1); + }); + + test('encodeSectionsToBuffer should produce valid v5 format', () { + final section = Section( + name: 'AA1', + dateSurvey: DateTime(2025, 9, 29, 16, 7), + direction: SurveyDirection.surveyIn, + shots: [ + Shot( + typeShot: TypeShot.std, + length: 2.09, + headingIn: 105.3, + headingOut: 104.5, + pitchIn: 4.9, + pitchOut: 4.8, + depthIn: 0.0, + depthOut: 0.47, + left: 0.49, + right: 0.0, + up: 0.48, + down: 0.0, + temperature: 16.0, + hr: 16, + min: 7, + sec: 31, + markerIndex: 0, + ), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final buffer = encoder.encodeSectionsToBuffer([section], 5); + + expect(buffer.isNotEmpty, true); + expect(buffer[0], 5); // Version + expect(buffer[1], 68); // Magic byte A + expect(buffer[2], 89); // Magic byte B + expect(buffer[3], 101); // Magic byte C + expect(buffer[4], 25); // Year (2025) + expect(buffer[5], 9); // Month + expect(buffer[6], 29); // Day + expect(buffer[7], 16); // Hour + expect(buffer[8], 7); // Minute + expect(buffer[9], 65); // 'A' + expect(buffer[10], 65); // 'A' + expect(buffer[11], 49); // '1' + expect(buffer[12], 0); // Direction IN + }); + + test('encodeSectionsToBuffer should include correct magic bytes for shots', () { + final section = Section( + name: 'TST', + dateSurvey: DateTime(2025, 1, 1), + shots: [ + Shot(typeShot: TypeShot.std), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final buffer = encoder.encodeSectionsToBuffer([section], 5); + + // Find shot start (after section header of 13 bytes) + expect(buffer[13], 57); // Shot start magic A + expect(buffer[14], 67); // Shot start magic B + expect(buffer[15], 77); // Shot start magic C + expect(buffer[16], 2); // TypeShot.std + }); + + test('writeBufferToFile should create valid CSV format', () async { + final buffer = [6, 68, 89, 101, 25, 9, 29]; + final tempDir = Directory.systemTemp.createTempSync(); + final testFile = File('${tempDir.path}/test.dmp'); + + await encoder.writeBufferToFile(buffer, testFile); + + final contents = await testFile.readAsString(); + expect(contents, '6;68;89;101;25;9;29;'); + + // Clean up + await testFile.delete(); + await tempDir.delete(); + }); + + test('broken shots should be included in export with correct format', () { + final section = Section( + name: 'BRK', + dateSurvey: DateTime(2025, 1, 1), + brokenFlag: true, // Section marked as broken + shots: [ + Shot(typeShot: TypeShot.std), + Shot(typeShot: TypeShot.eoc), + ], + ); + + final buffer = encoder.encodeSectionsToBuffer([section], 5); + + // Should still encode the section with proper magic bytes + expect(buffer.isNotEmpty, true); + expect(buffer[0], 5); // Version + expect(buffer[13], 57); // Shot start magic (correct format) + }); + }); +} diff --git a/test/dmp_roundtrip_test.dart b/test/dmp_roundtrip_test.dart new file mode 100644 index 0000000..ada1f97 --- /dev/null +++ b/test/dmp_roundtrip_test.dart @@ -0,0 +1,310 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mnemolink/models/models.dart'; +import 'package:mnemolink/services/dmp_decoder_service.dart'; +import 'package:mnemolink/services/dmp_encoder_service.dart'; + +void main() { + group('DMP Round-Trip Tests', () { + late DmpDecoderService decoder; + late DmpEncoderService encoder; + + setUp(() { + decoder = DmpDecoderService(); + encoder = DmpEncoderService(); + }); + + Future> loadAndProcessDmpFile(File file) async { + final buffer = await decoder.parseDMPFileOptimized(file); + final result = await decoder.processTransferBuffer(buffer, UnitType.metric); + + if (!result.success) { + throw Exception('Failed to process DMP file: ${result.error}'); + } + + return result.sections; + } + + Future> exportAndReload(List
sections, int version, Directory tempDir) async { + // Export sections to buffer + final exportBuffer = encoder.encodeSectionsToBuffer(sections, version); + + // Write buffer to temporary file + final tempFile = File('${tempDir.path}/test_export.dmp'); + await encoder.writeBufferToFile(exportBuffer, tempFile); + + // Reload the exported file + return loadAndProcessDmpFile(tempFile); + } + + void compareSections(Section original, Section exported, String context) { + expect(exported.name, original.name, reason: '$context: Section name mismatch'); + expect(exported.dateSurvey, original.dateSurvey, reason: '$context: Date mismatch'); + expect(exported.direction, original.direction, reason: '$context: Direction mismatch'); + expect(exported.shots.length, original.shots.length, reason: '$context: Shot count mismatch'); + + // Broken flag should be cleared after export/reload since we export with correct format + if (original.brokenFlag) { + expect(exported.brokenFlag, false, + reason: '$context: Broken sections should be fixed after round-trip'); + } + } + + void compareShots(Shot original, Shot exported, String context, {double tolerance = 0.01}) { + expect(exported.typeShot, original.typeShot, reason: '$context: Shot type mismatch'); + + // For EOC shots, only check type + if (original.typeShot == TypeShot.eoc) { + return; + } + + // Compare numeric values with tolerance for rounding + expect(exported.headingIn, closeTo(original.headingIn, tolerance), + reason: '$context: HeadingIn mismatch'); + expect(exported.headingOut, closeTo(original.headingOut, tolerance), + reason: '$context: HeadingOut mismatch'); + expect(exported.length, closeTo(original.length, tolerance), + reason: '$context: Length mismatch'); + expect(exported.depthIn, closeTo(original.depthIn, tolerance), + reason: '$context: DepthIn mismatch'); + expect(exported.depthOut, closeTo(original.depthOut, tolerance), + reason: '$context: DepthOut mismatch'); + expect(exported.pitchIn, closeTo(original.pitchIn, tolerance), + reason: '$context: PitchIn mismatch'); + expect(exported.pitchOut, closeTo(original.pitchOut, tolerance), + reason: '$context: PitchOut mismatch'); + + // LRUD data + expect(exported.left, closeTo(original.left, tolerance), + reason: '$context: Left mismatch'); + expect(exported.right, closeTo(original.right, tolerance), + reason: '$context: Right mismatch'); + expect(exported.up, closeTo(original.up, tolerance), + reason: '$context: Up mismatch'); + expect(exported.down, closeTo(original.down, tolerance), + reason: '$context: Down mismatch'); + + // Temperature and time + expect(exported.temperature, closeTo(original.temperature, tolerance), + reason: '$context: Temperature mismatch'); + expect(exported.hr, original.hr, reason: '$context: Hour mismatch'); + expect(exported.min, original.min, reason: '$context: Minute mismatch'); + expect(exported.sec, original.sec, reason: '$context: Second mismatch'); + + expect(exported.markerIndex, original.markerIndex, + reason: '$context: Marker index mismatch'); + + // Lidar data + expect(exported.hasLidarData(), original.hasLidarData(), + reason: '$context: Lidar data presence mismatch'); + + if (original.hasLidarData() && exported.hasLidarData()) { + final origLidar = original.lidarData!; + final expLidar = exported.lidarData!; + + expect(expLidar.points.length, origLidar.points.length, + reason: '$context: Lidar point count mismatch'); + + for (int i = 0; i < origLidar.points.length; i++) { + final origPoint = origLidar.points[i]; + final expPoint = expLidar.points[i]; + + expect(expPoint.yaw, closeTo(origPoint.yaw, tolerance), + reason: '$context: Lidar point $i yaw mismatch'); + expect(expPoint.pitch, closeTo(origPoint.pitch, tolerance), + reason: '$context: Lidar point $i pitch mismatch'); + expect(expPoint.distance, closeTo(origPoint.distance, tolerance), + reason: '$context: Lidar point $i distance mismatch'); + } + } + } + + test('Round-trip: simple-v6.dmp', () async { + final sampleFile = File('doc/samples/simple-v6.dmp'); + + if (!await sampleFile.exists()) { + // ignore: avoid_print + print('Skipping test: ${sampleFile.path} not found'); + return; + } + + // Load original + final originalSections = await loadAndProcessDmpFile(sampleFile); + expect(originalSections, isNotEmpty, reason: 'Should load sections from simple-v6.dmp'); + + // Detect version + final version = encoder.detectVersion(originalSections.first); + expect(version, 6, reason: 'simple-v6.dmp should be detected as v6'); + + // Export and reload + final tempDir = Directory.systemTemp.createTempSync('dmp_test_'); + try { + final exportedSections = await exportAndReload(originalSections, version, tempDir); + + // Compare + expect(exportedSections.length, originalSections.length); + + for (int i = 0; i < originalSections.length; i++) { + final context = 'simple-v6 section $i'; + compareSections(originalSections[i], exportedSections[i], context); + + for (int j = 0; j < originalSections[i].shots.length; j++) { + final shotContext = '$context shot $j'; + compareShots(originalSections[i].shots[j], exportedSections[i].shots[j], shotContext); + } + } + } finally { + await tempDir.delete(recursive: true); + } + }); + + test('Round-trip: real_v5-broken.dmp', () async { + final sampleFile = File('doc/samples/real_v5-broken.dmp'); + + if (!await sampleFile.exists()) { + // ignore: avoid_print + print('Skipping test: ${sampleFile.path} not found'); + return; + } + + // Load original + final originalSections = await loadAndProcessDmpFile(sampleFile); + expect(originalSections, isNotEmpty, reason: 'Should load sections from real_v5-broken.dmp'); + + // Detect version - should be v5 + final version = encoder.detectVersion(originalSections.first); + expect(version, 5, reason: 'real_v5-broken.dmp should be detected as v5'); + + // Check if original has broken flag + final hadBrokenSections = originalSections.any((s) => s.brokenFlag); + + // Export and reload + final tempDir = Directory.systemTemp.createTempSync('dmp_test_'); + try { + final exportedSections = await exportAndReload(originalSections, version, tempDir); + + // After round-trip, broken sections should be fixed + final hasBrokenAfterRoundTrip = exportedSections.any((s) => s.brokenFlag); + + if (hadBrokenSections) { + expect(hasBrokenAfterRoundTrip, false, + reason: 'Round-trip should fix broken sections by exporting with correct format'); + } + + // Compare sections (excluding broken flag which is expected to change) + expect(exportedSections.length, originalSections.length); + + for (int i = 0; i < originalSections.length; i++) { + final context = 'real_v5-broken section $i'; + + // Only compare if original section wasn't broken + if (!originalSections[i].brokenFlag) { + compareSections(originalSections[i], exportedSections[i], context); + + for (int j = 0; j < originalSections[i].shots.length; j++) { + final shotContext = '$context shot $j'; + compareShots(originalSections[i].shots[j], exportedSections[i].shots[j], shotContext); + } + } else { + // For broken sections, at least verify basic structure + expect(exportedSections[i].name, originalSections[i].name, + reason: '$context: Name should be preserved'); + expect(exportedSections[i].brokenFlag, false, + reason: '$context: Should not be broken after export'); + } + } + } finally { + await tempDir.delete(recursive: true); + } + }); + + test('Round-trip: real_v6-broken.dmp', () async { + final sampleFile = File('doc/samples/real_v6-broken.dmp'); + + if (!await sampleFile.exists()) { + // ignore: avoid_print + print('Skipping test: ${sampleFile.path} not found'); + return; + } + + // Load original + final originalSections = await loadAndProcessDmpFile(sampleFile); + expect(originalSections, isNotEmpty, reason: 'Should load sections from real_v6-broken.dmp'); + + // Detect version - should be v6 + final version = encoder.detectVersion(originalSections.first); + expect(version, 6, reason: 'real_v6-broken.dmp should be detected as v6'); + + // Check if original has broken flag + final hadBrokenSections = originalSections.any((s) => s.brokenFlag); + + // Export and reload + final tempDir = Directory.systemTemp.createTempSync('dmp_test_'); + try { + final exportedSections = await exportAndReload(originalSections, version, tempDir); + + // After round-trip, broken sections should be fixed + final hasBrokenAfterRoundTrip = exportedSections.any((s) => s.brokenFlag); + + if (hadBrokenSections) { + expect(hasBrokenAfterRoundTrip, false, + reason: 'Round-trip should fix broken sections by exporting with correct format'); + } + + // Compare sections + expect(exportedSections.length, originalSections.length); + + for (int i = 0; i < originalSections.length; i++) { + final context = 'real_v6-broken section $i'; + + // Only compare if original section wasn't broken + if (!originalSections[i].brokenFlag) { + compareSections(originalSections[i], exportedSections[i], context); + + for (int j = 0; j < originalSections[i].shots.length; j++) { + final shotContext = '$context shot $j'; + compareShots(originalSections[i].shots[j], exportedSections[i].shots[j], shotContext); + } + } else { + // For broken sections, at least verify basic structure + expect(exportedSections[i].name, originalSections[i].name, + reason: '$context: Name should be preserved'); + expect(exportedSections[i].brokenFlag, false, + reason: '$context: Should not be broken after export'); + } + } + } finally { + await tempDir.delete(recursive: true); + } + }); + + test('Version detection: v5 converted to v6 round-trip', () async { + final sampleFile = File('doc/samples/real_v5-broken.dmp'); + + if (!await sampleFile.exists()) { + // ignore: avoid_print + print('Skipping test: ${sampleFile.path} not found'); + return; + } + + // Load original v5 file + final originalSections = await loadAndProcessDmpFile(sampleFile); + expect(originalSections, isNotEmpty); + + // Export as v6 (conversion scenario) + final tempDir = Directory.systemTemp.createTempSync('dmp_test_'); + try { + final exportedSections = await exportAndReload(originalSections, 6, tempDir); + + // Should successfully reload + expect(exportedSections, isNotEmpty); + expect(exportedSections.length, originalSections.length); + + // Verify it was exported as v6 by checking depth values should be preserved + // (even though no Lidar data, it's valid v6) + } finally { + await tempDir.delete(recursive: true); + } + }); + }); +}