diff --git a/GasTank.usdz b/GasTank.usdz new file mode 100644 index 0000000..f940c4b Binary files /dev/null and b/GasTank.usdz differ diff --git a/GasTankAR.xcodeproj/project.pbxproj b/GasTankAR.xcodeproj/project.pbxproj index 4a934a2..89f1a12 100644 --- a/GasTankAR.xcodeproj/project.pbxproj +++ b/GasTankAR.xcodeproj/project.pbxproj @@ -200,11 +200,12 @@ }; }; buildConfigurationList = 95BEEDC83035F1C000D17653 /* Build configuration list for PBXProject "GasTankAR" */; - developmentRegion = en; + developmentRegion = de; hasScannedForEncodings = 0; knownRegions = ( en, Base, + de, ); mainGroup = 95BEEDC43035F1C000D17653; minimizedProjectReferenceProxies = 1; @@ -293,13 +294,16 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = GasTankAR/Info.plist; - INFOPLIST_KEY_NSCameraUsageDescription = CAMERA_USAGE_DESCRIPTION; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + INFOPLIST_KEY_NSCameraUsageDescription = "Die Kamera wird benötigt, um Gastanks im AR-Modus auf dem Grundstück zu visualisieren."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Der Standort wird verwendet, um aufgenommene Fotos mit ihrem Aufnahmeort zu verknüpfen und auf der Karte anzuzeigen."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit; INFOPLIST_KEY_UIStatusBarHidden = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -328,13 +332,16 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = GasTankAR/Info.plist; - INFOPLIST_KEY_NSCameraUsageDescription = CAMERA_USAGE_DESCRIPTION; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + INFOPLIST_KEY_NSCameraUsageDescription = "Die Kamera wird benötigt, um Gastanks im AR-Modus auf dem Grundstück zu visualisieren."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Der Standort wird verwendet, um aufgenommene Fotos mit ihrem Aufnahmeort zu verknüpfen und auf der Karte anzuzeigen."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit; INFOPLIST_KEY_UIStatusBarHidden = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/GasTankAR.xcodeproj/xcuserdata/dennis.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/GasTankAR.xcodeproj/xcuserdata/dennis.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..0c0ffb2 --- /dev/null +++ b/GasTankAR.xcodeproj/xcuserdata/dennis.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/GasTankAR/AppDelegate.swift b/GasTankAR/AppDelegate.swift index 3f95a51..189ea55 100644 --- a/GasTankAR/AppDelegate.swift +++ b/GasTankAR/AppDelegate.swift @@ -10,8 +10,6 @@ import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. return true @@ -31,6 +29,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - } diff --git a/GasTankAR/Assets.xcassets/Image.imageset/Contents.json b/GasTankAR/Assets.xcassets/Image.imageset/Contents.json new file mode 100644 index 0000000..a19a549 --- /dev/null +++ b/GasTankAR/Assets.xcassets/Image.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GasTankAR/Assets.xcassets/tank-lower.imageset/ChatGPT Image 26. Aug. 2026, 00_01_42.png b/GasTankAR/Assets.xcassets/tank-lower.imageset/ChatGPT Image 26. Aug. 2026, 00_01_42.png new file mode 100644 index 0000000..4184363 Binary files /dev/null and b/GasTankAR/Assets.xcassets/tank-lower.imageset/ChatGPT Image 26. Aug. 2026, 00_01_42.png differ diff --git a/GasTankAR/Assets.xcassets/tank-lower.imageset/Contents.json b/GasTankAR/Assets.xcassets/tank-lower.imageset/Contents.json new file mode 100644 index 0000000..f6fb7f6 --- /dev/null +++ b/GasTankAR/Assets.xcassets/tank-lower.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ChatGPT Image 26. Aug. 2026, 00_01_42.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GasTankAR/Assets.xcassets/tank-upper.imageset/ChatGPT Image 26. Aug. 2026, 00_01_57.png b/GasTankAR/Assets.xcassets/tank-upper.imageset/ChatGPT Image 26. Aug. 2026, 00_01_57.png new file mode 100644 index 0000000..cfbd2d5 Binary files /dev/null and b/GasTankAR/Assets.xcassets/tank-upper.imageset/ChatGPT Image 26. Aug. 2026, 00_01_57.png differ diff --git a/GasTankAR/Assets.xcassets/tank-upper.imageset/Contents.json b/GasTankAR/Assets.xcassets/tank-upper.imageset/Contents.json new file mode 100644 index 0000000..7d9e3e9 --- /dev/null +++ b/GasTankAR/Assets.xcassets/tank-upper.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ChatGPT Image 26. Aug. 2026, 00_01_57.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GasTankAR/ContentView.swift b/GasTankAR/ContentView.swift index b3b5019..5d22745 100644 --- a/GasTankAR/ContentView.swift +++ b/GasTankAR/ContentView.swift @@ -6,35 +6,16 @@ // import SwiftUI -import RealityKit - -struct ContentView : View { +import SwiftData +struct ContentView: View { var body: some View { - RealityView { content in - - // Create a cube model - let model = Entity() - let mesh = MeshResource.generateBox(size: 0.1, cornerRadius: 0.005) - let material = SimpleMaterial(color: .gray, roughness: 0.15, isMetallic: true) - model.components.set(ModelComponent(mesh: mesh, materials: [material])) - model.position = [0, 0.05, 0] - - // Create horizontal plane anchor for the content - let anchor = AnchorEntity(.plane(.horizontal, classification: .any, minimumBounds: SIMD2(0.2, 0.2))) - anchor.addChild(model) - - // Add the horizontal plane anchor to the scene - content.add(anchor) - - content.camera = .spatialTracking - - } - .edgesIgnoringSafeArea(.all) + ProjectListView() + .modelContainer(for: Project.self) } - } #Preview { - ContentView() + ProjectListView() + .modelContainer(PreviewSupport.container) } diff --git a/GasTankAR/GasTank.usdz b/GasTankAR/GasTank.usdz new file mode 100644 index 0000000..f940c4b Binary files /dev/null and b/GasTankAR/GasTank.usdz differ diff --git a/GasTankAR/Info.plist b/GasTankAR/Info.plist index 0eb786d..19e98f1 100644 --- a/GasTankAR/Info.plist +++ b/GasTankAR/Info.plist @@ -2,6 +2,11 @@ + CFBundleLocalizations + + de + en + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/GasTankAR/Model/Project.swift b/GasTankAR/Model/Project.swift new file mode 100644 index 0000000..f51d348 --- /dev/null +++ b/GasTankAR/Model/Project.swift @@ -0,0 +1,81 @@ +// +// Project.swift +// GasTankAR +// +// Created by Dennis Nemec on 25.08.26. +// + +import Foundation +import SwiftData +import ARKit + +@Model +final class Project { + /// Identifier given by the user for the project. In DOCUframe terms + /// it is the LeadID. + var leadID: String + + /// Customer name to link the project to. + var customerName: String + + var creationDate: Date + + /// Der für das Projekt ausgewählte Tank. Jedes Projekt hat genau einen Tank. + /// `Tank` ist `Codable`, daher persistiert SwiftData das direkt als Attribut. + var tank: Tank + + /// Archivierte `ARWorldMap` (via `NSKeyedArchiver`). Wegen der Größe extern gespeichert. + @Attribute(.externalStorage) var worldMapData: Data? + + /// Welt-Transform des platzierten Tanks (Position, Rotation, Skalierung) als + /// 16 Floats (spaltenweise). Zusammen mit der WorldMap wird der Tank so wiederhergestellt. + var tankTransform: [Float]? + + /// Im AR-Modus aufgenommene Fotos. Werden beim Löschen des Projekts mitentfernt. + @Relationship(deleteRule: .cascade, inverse: \ProjectPhoto.project) + var photos: [ProjectPhoto] = [] + + init( + leadID: String, + customerName: String, + creationDate: Date, + tank: Tank = Tank(), + worldMapData: Data? = nil, + tankTransform: [Float]? = nil + ) { + self.leadID = leadID + self.customerName = customerName + self.creationDate = creationDate + self.tank = tank + self.worldMapData = worldMapData + self.tankTransform = tankTransform + } + + /// Human-readable title combining the lead identifier and customer name. + var title: String { + "\(leadID) – \(customerName)" + } + + /// Up to two uppercased initials derived from the customer name, for use as an avatar. + var initials: String { + let letters = customerName + .split(whereSeparator: { $0 == " " || $0 == "&" }) + .prefix(2) + .compactMap { $0.first } + return String(letters).uppercased() + } + + /// Gespeicherte Session der getrackten Welt. (De-)serialisiert `worldMapData`. + /// Berechnete Property – wird von SwiftData nicht persistiert. + var worldMap: ARWorldMap? { + get { + guard let worldMapData else { return nil } + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: worldMapData) + } + set { + worldMapData = newValue.flatMap { + try? NSKeyedArchiver.archivedData(withRootObject: $0, requiringSecureCoding: true) + } + } + } +} diff --git a/GasTankAR/Model/ProjectPhoto.swift b/GasTankAR/Model/ProjectPhoto.swift new file mode 100644 index 0000000..669afaa --- /dev/null +++ b/GasTankAR/Model/ProjectPhoto.swift @@ -0,0 +1,49 @@ +// +// ProjectPhoto.swift +// GasTankAR +// +// Created by Dennis Nemec on 27.08.26. +// + +import Foundation +import SwiftData +import CoreLocation + +/// Ein im AR-Modus aufgenommenes Foto (Snapshot der Kamera-Sicht), das zu einem Projekt gehört. +/// Die Bilddaten liegen wegen ihrer Größe extern (`.externalStorage`). +@Model +final class ProjectPhoto { + /// Aufnahmezeitpunkt – Grundlage für die Gruppierung in der Galerie. + var creationDate: Date + + /// JPEG-kodierte Bilddaten. + @Attribute(.externalStorage) var imageData: Data + + /// Geografische Breite des Aufnahmeorts, sofern beim Aufnehmen ein Standort vorlag. + var latitude: Double? + /// Geografische Länge des Aufnahmeorts, sofern beim Aufnehmen ein Standort vorlag. + var longitude: Double? + + /// Zugehöriges Projekt (Rückbeziehung zu `Project.photos`). + var project: Project? + + init( + creationDate: Date, + imageData: Data, + latitude: Double? = nil, + longitude: Double? = nil, + project: Project? = nil + ) { + self.creationDate = creationDate + self.imageData = imageData + self.latitude = latitude + self.longitude = longitude + self.project = project + } + + /// Aufnahmeort als Koordinate – `nil`, wenn kein Standort gespeichert wurde. + var coordinate: CLLocationCoordinate2D? { + guard let latitude, let longitude else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} diff --git a/GasTankAR/Model/Tank.swift b/GasTankAR/Model/Tank.swift new file mode 100644 index 0000000..98d1819 --- /dev/null +++ b/GasTankAR/Model/Tank.swift @@ -0,0 +1,80 @@ +// +// Tank.swift +// GasTankAR +// +// Created by Dennis Nemec on 25.08.26. +// + +import Foundation + +/// Verfügbare Tankgrößen laut Projektplan Phase 4 (Modell + korrekter Maßstab). +enum TankSize: String, CaseIterable, Identifiable, Codable, Hashable { + case t1_2 + case t2_1 + case t2_9 + + var id: String { rawValue } + + var displayName: String { + switch self { + case .t1_2: "1,2 t" + case .t2_1: "2,1 t" + case .t2_9: "2,9 t" + } + } +} + +/// Verfügbare Tankfarben (Farbvariante mit Logo). +enum TankColor: String, CaseIterable, Identifiable, Codable, Hashable { + case white + case green + + var id: String { rawValue } + + var displayName: String { + switch self { + case .white: "Weiß" + case .green: "Grün" + } + } +} + +/// Aufstellungsart des Tanks. +enum TankPlacement: String, CaseIterable, Identifiable, Codable, Hashable { + case aboveGround + case belowGround + + var id: String { rawValue } + + var displayName: String { + switch self { + case .aboveGround: "Oberirdisch" + case .belowGround: "Unterirdisch" + } + } + + /// Name des Asset-Icons für diese Aufstellungsart. + var imageName: String { + switch self { + case .aboveGround: "tank-upper" + case .belowGround: "tank-lower" + } + } +} + +/// Ein für ein Projekt ausgewählter Tank. Kombiniert die in Phase 4 des +/// Projektplans vorgesehenen Auswahlmöglichkeiten: Größe, Farbe und +/// Aufstellungsart sowie die optionale halbtransparente Darstellung. +struct Tank: Hashable, Codable { + var size: TankSize = .t1_2 + var color: TankColor = .white + var placement: TankPlacement = .aboveGround + + /// Optionale Darstellung, bei der nur der Deckel sichtbar ist. + var showLidOnly: Bool = false + + /// Kurzbeschreibung für Listen und Detailanzeigen. + var displayName: String { + "\(size.displayName) · \(color.displayName) · \(placement.displayName)" + } +} diff --git a/GasTankAR/PreviewSupport.swift b/GasTankAR/PreviewSupport.swift new file mode 100644 index 0000000..af8dd1d --- /dev/null +++ b/GasTankAR/PreviewSupport.swift @@ -0,0 +1,134 @@ +// +// PreviewSupport.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +#if DEBUG +import Foundation +import SwiftData +import UIKit + +/// Beispieldaten und In-Memory-Container ausschließlich für SwiftUI-Previews. +/// Wird über `#if DEBUG` aus Release-Builds ausgeschlossen. +@MainActor +enum PreviewSupport { + /// Container mit einer Reihe an Beispielprojekten. + static let container: ModelContainer = { + let container = makeEmptyContainer() + for project in sampleProjects() { + container.mainContext.insert(project) + } + return container + }() + + /// Leerer In-Memory-Container (z. B. für den „Leer"-Zustand). + static func makeEmptyContainer() -> ModelContainer { + let configuration = ModelConfiguration(isStoredInMemoryOnly: true) + // swiftlint:disable:next force_try + return try! ModelContainer(for: Project.self, configurations: configuration) + } + + /// Ein einzelnes Beispielprojekt inklusive Container (für Detail-Previews). + static func singleProjectContainer() -> (container: ModelContainer, project: Project) { + let container = makeEmptyContainer() + let project = Project( + leadID: "LEAD-1001", + customerName: "Müller Heizungsbau", + creationDate: .now, + tank: Tank() + ) + container.mainContext.insert(project) + return (container, project) + } + + /// Projekt inklusive Beispiel-Fotos (mit generierten Test-Bildern) für die Galerie-Preview. + /// Deckt mehrere Aufnahmetage sowie Fotos mit und ohne Standort ab. + static func galleryProjectContainer() -> (container: ModelContainer, project: Project) { + let container = makeEmptyContainer() + let project = Project( + leadID: "LEAD-1042", + customerName: "Müller Heizungsbau", + creationDate: .now + ) + container.mainContext.insert(project) + + let day: TimeInterval = 86_400 + let samples: [(color: UIColor, label: String, offset: TimeInterval, lat: Double?, lon: Double?)] = [ + (.systemBlue, "1", 0, 52.5200, 13.4050), + (.systemGreen, "2", -3_600, 48.1372, 11.5756), + (.systemOrange, "3", -day, nil, nil), + (.systemPurple, "4", -3 * day, 50.1109, 8.6821), + ] + for sample in samples { + let photo = ProjectPhoto( + creationDate: Date.now.addingTimeInterval(sample.offset), + imageData: sampleImageData(color: sample.color, label: sample.label), + latitude: sample.lat, + longitude: sample.lon, + project: project + ) + container.mainContext.insert(photo) + } + return (container, project) + } + + /// Einzelnes Beispiel-Foto (mit Standort) für die Detail-Preview. + static func samplePhoto() -> ProjectPhoto { + ProjectPhoto( + creationDate: .now, + imageData: sampleImageData(color: .systemTeal, label: "AR"), + latitude: 52.5200, + longitude: 13.4050 + ) + } + + /// Erzeugt ein einfarbiges Test-Bild mit zentrierter Beschriftung als JPEG-Daten. + static func sampleImageData(color: UIColor, label: String) -> Data { + let size = CGSize(width: 1200, height: 900) + let image = UIGraphicsImageRenderer(size: size).image { context in + color.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + let text = label as NSString + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 240), + .foregroundColor: UIColor.white, + ] + let textSize = text.size(withAttributes: attributes) + let origin = CGPoint( + x: (size.width - textSize.width) / 2, + y: (size.height - textSize.height) / 2 + ) + text.draw(at: origin, withAttributes: attributes) + } + return image.jpegData(compressionQuality: 0.8) ?? Data() + } + + /// Erzeugt eine Reihe von Beispielprojekten. + static func sampleProjects(count: Int = 40) -> [Project] { + let names: [String] = [ + "Müller Heizungsbau", "Schmidt GmbH", "Weber & Söhne", "Fischer Sanitär", + "Becker Klimatechnik", "Hoffmann Anlagenbau", "Wagner & Partner", "Schulz Energie", + "Koch Gasversorgung", "Richter Industrie", "Klein Tankbau", "Wolf Technik", + "Neumann GmbH", "Schwarz Heizsysteme", "Zimmermann AG", "Braun Installationen", + ] + + var projects: [Project] = [] + for index in 0.. DocuframeError? { + switch statusCode { + case 400..<500: .clientError(statusCode: statusCode) + case 500..<600: .serverError(statusCode: statusCode) + default: nil + } + } +} + +extension HTTPURLResponse { + /// Wirft einen ``DocuframeError``, wenn der Statuscode einen 4xx-/5xx-Fehler darstellt. + func validateDocuframeStatus() throws { + if let error = DocuframeError.from(statusCode: statusCode) { + throw error + } + } +} + +/// Abstraktion des DOCUframe-Zugriffs (Upload & Mailversand). +/// +/// Clean-Architecture-Grenze: Die Präsentationsschicht hängt nur von diesem Protokoll ab, +/// nicht von der konkreten DOCUframe-Anbindung. Konkrete Implementierungen (Stub, echte +/// Anbindung, Mock für Tests) liegen in der Infrastruktur- bzw. Testschicht. +protocol DocuframeService: Sendable { + /// Lädt die übergebenen Fotos zum Projekt (Lead) in DOCUframe hoch. + func uploadPhotos(_ photos: [DocuframePhoto], projectLeadID: String) async throws + /// Versendet die übergebenen Fotos als E-Mail über DOCUframe. + func sendEmail(_ photos: [DocuframePhoto], projectLeadID: String) async throws +} diff --git a/GasTankAR/Services/StubDocuframeService.swift b/GasTankAR/Services/StubDocuframeService.swift new file mode 100644 index 0000000..730bb6f --- /dev/null +++ b/GasTankAR/Services/StubDocuframeService.swift @@ -0,0 +1,44 @@ +// +// StubDocuframeService.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import Foundation +import OSLog + +/// Platzhalter-Implementierung des ``DocuframeService``. Simuliert Latenz und protokolliert +/// die Aufrufe, führt aber noch keinen echten Upload/Mailversand aus. Die konkrete +/// DOCUframe-Anbindung ersetzt diesen Stub später, ohne dass die Präsentationsschicht sich ändert. +struct StubDocuframeService: DocuframeService { + private static let logger = Logger(subsystem: "GasTankAR", category: "DOCUframe") + + /// Optionaler HTTP-Statuscode, den der Stub simuliert. Bei 4xx/5xx wirft er den + /// entsprechenden ``DocuframeError`` – so lässt sich die Fehlerbehandlung durchspielen. + /// Standard `nil` = erfolgreicher Vorgang. + private let simulatedStatusCode: Int? + + init(simulatedStatusCode: Int? = nil) { + self.simulatedStatusCode = simulatedStatusCode + } + + func uploadPhotos(_ photos: [DocuframePhoto], projectLeadID: String) async throws { + try await simulate(operation: "Upload", photoCount: photos.count, projectLeadID: projectLeadID) + } + + func sendEmail(_ photos: [DocuframePhoto], projectLeadID: String) async throws { + try await simulate(operation: "Mailversand", photoCount: photos.count, projectLeadID: projectLeadID) + } + + private func simulate(operation: String, photoCount: Int, projectLeadID: String) async throws { + Self.logger.info("Stub-\(operation, privacy: .public) von \(photoCount) Foto(s) für Lead \(projectLeadID, privacy: .public)") + try await Task.sleep(for: .seconds(1)) + + // Simuliert eine Server-Antwort: Bei 4xx/5xx wird der passende Fehler geworfen. + if let simulatedStatusCode, let error = DocuframeError.from(statusCode: simulatedStatusCode) { + Self.logger.error("DOCUframe-\(operation, privacy: .public) fehlgeschlagen (HTTP \(simulatedStatusCode))") + throw error + } + } +} diff --git a/GasTankAR/Views/Docuframe/DocuframeView.swift b/GasTankAR/Views/Docuframe/DocuframeView.swift new file mode 100644 index 0000000..388a29e --- /dev/null +++ b/GasTankAR/Views/Docuframe/DocuframeView.swift @@ -0,0 +1,126 @@ +// +// DocuframeView.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import SwiftUI +import SwiftData + +/// DOCUframe-Tab: Auswahl aufgenommener Fotos und Übergabe an DOCUframe (Upload) bzw. Mailversand. +struct DocuframeView: View { + let project: Project + + @State private var viewModel = DocuframeViewModel() + + private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)] + + private var photos: [ProjectPhoto] { + project.photos.sorted { $0.creationDate > $1.creationDate } + } + + var body: some View { + Group { + if photos.isEmpty { + ContentUnavailableView( + "Keine Fotos", + systemImage: "photo.on.rectangle.angled", + description: Text("Nimm im AR-Modus Fotos auf, um sie an DOCUframe zu übergeben.") + ) + } else { + ScrollView { + LazyVGrid(columns: columns, spacing: 2) { + ForEach(photos) { photo in + selectableThumbnail(photo) + } + } + .padding(2) + } + } + } + .safeAreaInset(edge: .bottom) { + if !photos.isEmpty { actionBar } + } + .alert( + viewModel.result?.title ?? "", + isPresented: resultBinding, + presenting: viewModel.result + ) { _ in + Button("OK", role: .cancel) {} + } message: { result in + Text(result.message) + } + } + + private func selectableThumbnail(_ photo: ProjectPhoto) -> some View { + let selected = viewModel.isSelected(photo) + return PhotoThumbnail(imageData: photo.imageData) + .aspectRatio(1, contentMode: .fit) + .clipped() + .overlay(alignment: .topTrailing) { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, selected ? Color.accentColor : Color.black.opacity(0.35)) + .padding(6) + .shadow(radius: 2) + } + .overlay { + if selected { + Rectangle().strokeBorder(Color.accentColor, lineWidth: 3) + } + } + .contentShape(.rect) + .onTapGesture { viewModel.toggleSelection(photo) } + } + + private var actionBar: some View { + VStack(spacing: 10) { + Text(viewModel.hasSelection ? "\(viewModel.selectionCount) ausgewählt" : "Fotos zum Übertragen auswählen") + .font(.footnote) + .foregroundStyle(.secondary) + + VStack(spacing: 8) { + Button { + Task { await viewModel.upload(from: photos, leadID: project.leadID) } + } label: { + Label("In DOCUframe hochladen", systemImage: "arrow.up.doc") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + Button { + Task { await viewModel.sendEmail(from: photos, leadID: project.leadID) } + } label: { + Label("E-Mail senden", systemImage: "envelope") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .controlSize(.large) + .disabled(!viewModel.hasSelection || viewModel.isProcessing) + + if viewModel.isProcessing { + ProgressView() + } + } + .padding() + .background(.bar) + } + + private var resultBinding: Binding { + Binding( + get: { viewModel.result != nil }, + set: { if !$0 { viewModel.result = nil } } + ) + } +} + +#Preview { + let sample = PreviewSupport.galleryProjectContainer() + return NavigationStack { + DocuframeView(project: sample.project) + } + .modelContainer(sample.container) +} diff --git a/GasTankAR/Views/Docuframe/DocuframeViewModel.swift b/GasTankAR/Views/Docuframe/DocuframeViewModel.swift new file mode 100644 index 0000000..291fb3d --- /dev/null +++ b/GasTankAR/Views/Docuframe/DocuframeViewModel.swift @@ -0,0 +1,118 @@ +// +// DocuframeViewModel.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import Foundation +import SwiftData + +/// Präsentationslogik für den DOCUframe-Tab: verwaltet die Fotoauswahl und stößt Upload bzw. +/// Mailversand über den injizierten ``DocuframeService`` an. Kennt keine UI-Details. +@MainActor +@Observable +final class DocuframeViewModel { + /// Auswahl anhand der stabilen SwiftData-Identität der Fotos. + var selectedIDs: Set = [] + /// Läuft gerade ein Upload/Mailversand? + private(set) var isProcessing = false + /// Ergebnis des letzten Vorgangs für die Anzeige (nil = kein Hinweis). + var result: DocuframeActionResult? + + @ObservationIgnored private let service: DocuframeService + + /// Der Service wird injiziert (Default: Stub) – austauschbar für echte Anbindung oder Tests. + init(service: DocuframeService = StubDocuframeService()) { + self.service = service + } + + var hasSelection: Bool { !selectedIDs.isEmpty } + var selectionCount: Int { selectedIDs.count } + + func isSelected(_ photo: ProjectPhoto) -> Bool { + selectedIDs.contains(photo.persistentModelID) + } + + func toggleSelection(_ photo: ProjectPhoto) { + let id = photo.persistentModelID + if selectedIDs.contains(id) { + selectedIDs.remove(id) + } else { + selectedIDs.insert(id) + } + } + + func upload(from photos: [ProjectPhoto], leadID: String) async { + await perform(from: photos, leadID: leadID, successText: "in DOCUframe hochgeladen") { + try await service.uploadPhotos($0, projectLeadID: $1) + } + } + + func sendEmail(from photos: [ProjectPhoto], leadID: String) async { + await perform(from: photos, leadID: leadID, successText: "per E-Mail versendet") { + try await service.sendEmail($0, projectLeadID: $1) + } + } + + // MARK: - Gemeinsame Ausführung + + private func perform( + from photos: [ProjectPhoto], + leadID: String, + successText: String, + action: ([DocuframePhoto], String) async throws -> Void + ) async { + let selected = photos.filter { selectedIDs.contains($0.persistentModelID) } + guard !selected.isEmpty else { + result = .failure(message: DocuframeError.noPhotosSelected.localizedDescription) + return + } + + isProcessing = true + defer { isProcessing = false } + + let payload = selected.map(Self.makeDTO) + do { + try await action(payload, leadID) + result = .success(message: "\(selected.count) Foto(s) \(successText).") + } catch { + // Bei HTTP 4xx/5xx (und anderen Fehlern) den Nutzer informieren. + result = .failure(message: error.localizedDescription) + } + } + + /// Bildet das Persistenz-Model auf ein persistenzunabhängiges Domänen-DTO ab. + private static func makeDTO(_ photo: ProjectPhoto) -> DocuframePhoto { + DocuframePhoto( + imageData: photo.imageData, + creationDate: photo.creationDate, + latitude: photo.latitude, + longitude: photo.longitude + ) + } +} + +/// Ergebnis eines DOCUframe-Vorgangs für die Anzeige (Erfolg oder Fehler). +enum DocuframeActionResult { + case success(message: String) + case failure(message: String) + + var title: String { + switch self { + case .success: "Erledigt" + case .failure: "Fehler" + } + } + + var message: String { + switch self { + case .success(let message), .failure(let message): message + } + } + + var isError: Bool { + if case .failure = self { return true } + return false + } +} diff --git a/GasTankAR/Views/GalleryView.swift b/GasTankAR/Views/GalleryView.swift new file mode 100644 index 0000000..8053288 --- /dev/null +++ b/GasTankAR/Views/GalleryView.swift @@ -0,0 +1,263 @@ +// +// GalleryView.swift +// GasTankAR +// +// Created by Dennis Nemec on 27.08.26. +// + +import SwiftUI +import SwiftData +import MapKit +import CoreLocation + +/// Galerie der im AR-Modus aufgenommenen Fotos eines Projekts. +/// Anzeige wie in der Fotos-App (Raster), gruppiert nach Aufnahmedatum mit +/// angehefteten Abschnitts-Überschriften – analog zur Buchstaben-Gruppierung der Kontakte-App. +struct GalleryView: View { + @Bindable var project: Project + @Environment(\.modelContext) private var modelContext + + @State private var selectedPhoto: ProjectPhoto? + + private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)] + + /// Fotos nach Tag gruppiert und absteigend sortiert (neueste zuerst). + private var sections: [(date: Date, photos: [ProjectPhoto])] { + let groups = Dictionary(grouping: project.photos) { photo in + Calendar.current.startOfDay(for: photo.creationDate) + } + return groups + .map { (date: $0.key, photos: $0.value.sorted { $0.creationDate > $1.creationDate }) } + .sorted { $0.date > $1.date } + } + + var body: some View { + Group { + if project.photos.isEmpty { + ContentUnavailableView( + "Keine Fotos", + systemImage: "photo.on.rectangle.angled", + description: Text("Nimm im AR-Modus über den Kamera-Button ein Foto auf.") + ) + } else { + ScrollView { + LazyVGrid(columns: columns, spacing: 2, pinnedViews: [.sectionHeaders]) { + ForEach(sections, id: \.date) { section in + Section { + ForEach(section.photos) { photo in + thumbnail(for: photo) + } + } header: { + sectionHeader(for: section.date) + } + } + } + } + } + } + .fullScreenCover(item: $selectedPhoto) { photo in + PhotoViewer(photo: photo) { + delete(photo) + } + } + } + + private func thumbnail(for photo: ProjectPhoto) -> some View { + PhotoThumbnail(imageData: photo.imageData) + .aspectRatio(1, contentMode: .fit) + .clipped() + .contentShape(.rect) + .onTapGesture { selectedPhoto = photo } + .contextMenu { + Button("Löschen", systemImage: "trash", role: .destructive) { + delete(photo) + } + } + } + + private func sectionHeader(for date: Date) -> some View { + Text(Self.headerTitle(for: date)) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + + private func delete(_ photo: ProjectPhoto) { + if selectedPhoto == photo { selectedPhoto = nil } + modelContext.delete(photo) + try? modelContext.save() + } + + /// Überschrift wie in der Fotos-App: „Heute"/„Gestern", sonst das ausgeschriebene Datum. + private static func headerTitle(for date: Date) -> String { + let calendar = Calendar.current + if calendar.isDateInToday(date) { return "Heute" } + if calendar.isDateInYesterday(date) { return "Gestern" } + return date.formatted(.dateTime.day().month(.wide).year()) + } +} + +/// Lädt und dekodiert die Bilddaten außerhalb des Main-Threads und zeigt das Ergebnis quadratisch. +struct PhotoThumbnail: View { + let imageData: Data + + @State private var image: UIImage? + + var body: some View { + GeometryReader { geometry in + ZStack { + Color(.secondarySystemBackground) + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } + } + } + .task(id: imageData) { + image = await decode(imageData) + } + } + + private func decode(_ data: Data) async -> UIImage? { + await Task.detached(priority: .userInitiated) { + UIImage(data: data) + }.value + } +} + +/// Vollbild-Betrachter für ein einzelnes Foto mit Schließen- und Löschen-Aktion. +private struct PhotoViewer: View { + let photo: ProjectPhoto + let onDelete: () -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 0) { + if let image = UIImage(data: photo.imageData) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + if let coordinate = photo.coordinate { + VStack(alignment: .leading, spacing: 8) { + Text("Karte") + .font(.headline) + PhotoLocationCard(coordinate: coordinate) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + } + .background(Color(.systemGroupedBackground)) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Fertig") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Löschen", systemImage: "trash", role: .destructive) { + onDelete() + dismiss() + } + } + } + .toolbarBackground(.visible, for: .navigationBar) + .navigationTitle(photo.creationDate.formatted(.dateTime.day().month().year().hour().minute())) + .navigationBarTitleDisplayMode(.inline) + } + } +} + +/// Schwebende Karte, die den Aufnahmeort eines Fotos zeigt: Karten-Vorschau plus +/// (rückwärts-geokodierte) Adresse. Tippen öffnet den Ort in Apple Karten. +private struct PhotoLocationCard: View { + let coordinate: CLLocationCoordinate2D + + @State private var address: String? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Map( + initialPosition: .region( + MKCoordinateRegion(center: coordinate, latitudinalMeters: 350, longitudinalMeters: 350) + ) + ) { + Marker("Aufnahmeort", coordinate: coordinate) + .tint(.red) + } + .frame(height: 140) + .allowsHitTesting(false) + + HStack(spacing: 12) { + Image(systemName: "mappin.circle.fill") + .font(.title2) + .foregroundStyle(.red) + VStack(alignment: .leading, spacing: 2) { + Text("Aufnahmeort") + .font(.subheadline.weight(.semibold)) + Text(address ?? Self.coordinateText(coordinate)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 0) + Image(systemName: "arrow.up.forward.app.fill") + .font(.title3) + .foregroundStyle(.secondary) + } + .padding(12) + } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(.rect(cornerRadius: 12)) + .contentShape(.rect) + .onTapGesture { openInMaps() } + .accessibilityElement(children: .combine) + .accessibilityLabel("Aufnahmeort, in Apple Karten öffnen") + .task(id: coordinate.latitude + coordinate.longitude) { await reverseGeocode() } + } + + private func openInMaps() { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) + mapItem.name = address ?? "Aufnahmeort" + mapItem.openInMaps() + } + + private func reverseGeocode() async { + let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + guard let placemark = try? await CLGeocoder().reverseGeocodeLocation(location).first else { return } + let street = [placemark.thoroughfare, placemark.subThoroughfare] + .compactMap { $0 } + .joined(separator: " ") + let city = [placemark.postalCode, placemark.locality] + .compactMap { $0 } + .joined(separator: " ") + let composed = [street, city].filter { !$0.isEmpty }.joined(separator: ", ") + if !composed.isEmpty { address = composed } + } + + private static func coordinateText(_ coordinate: CLLocationCoordinate2D) -> String { + String(format: "%.5f, %.5f", coordinate.latitude, coordinate.longitude) + } +} + +#Preview("Galerie") { + let sample = PreviewSupport.galleryProjectContainer() + return NavigationStack { + GalleryView(project: sample.project) + } + .modelContainer(sample.container) +} + +#Preview("Foto-Detail") { + PhotoViewer(photo: PreviewSupport.samplePhoto()) {} +} diff --git a/GasTankAR/Views/ProjectCreate.swift b/GasTankAR/Views/ProjectCreate.swift new file mode 100644 index 0000000..23b055b --- /dev/null +++ b/GasTankAR/Views/ProjectCreate.swift @@ -0,0 +1,82 @@ +// +// ProjectCreate.swift +// GasTankAR +// +// Created by Dennis Nemec on 25.08.26. +// + +import SwiftUI +import SwiftData + +struct ProjectCreateView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var leadID: String = "" + @State private var customerName: String = "" + @State private var creationDate: Date = .now + @State private var tank: Tank = Tank() + + /// Whether the entered values are sufficient to create a project. + private var canSave: Bool { + !leadID.trimmingCharacters(in: .whitespaces).isEmpty + && !customerName.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + NavigationStack { + Form { + Section("Projektdaten") { + TextField("Lead-ID", text: $leadID) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + + TextField("Kundenname", text: $customerName) + .textInputAutocapitalization(.words) + + DatePicker( + "Erstellungsdatum", + selection: $creationDate, + displayedComponents: [.date] + ) + } + + Section("Tankauswahl") { + TankEditor(tank: $tank) + } + } + .navigationTitle("Projekt erstellen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen", systemImage: "xmark") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Speichern", systemImage: "checkmark") { + save() + } + .buttonStyle(.borderedProminent) + .disabled(!canSave) + } + } + } + } + + private func save() { + let project = Project( + leadID: leadID.trimmingCharacters(in: .whitespaces), + customerName: customerName.trimmingCharacters(in: .whitespaces), + creationDate: creationDate, + tank: tank + ) + modelContext.insert(project) + dismiss() + } +} + +#Preview { + ProjectCreateView() + .modelContainer(for: Project.self, inMemory: true) +} diff --git a/GasTankAR/Views/ProjectDetailView.swift b/GasTankAR/Views/ProjectDetailView.swift new file mode 100644 index 0000000..2bd425c --- /dev/null +++ b/GasTankAR/Views/ProjectDetailView.swift @@ -0,0 +1,66 @@ +// +// ProjectDetailView.swift +// GasTankAR +// +// Created by Dennis Nemec on 25.08.26. +// + +import SwiftUI +import SwiftData + +struct ProjectDetailView: View { + @Bindable var project: Project + + var body: some View { + TabView { + detailsTab + .tabItem { Label("Details", systemImage: "info.circle") } + + GalleryView(project: project) + .tabItem { Label("Galerie", systemImage: "photo.on.rectangle") } + + DocuframeView(project: project) + .tabItem { Label("DOCUframe", systemImage: "arrow.up.doc.on.clipboard") } + } + .navigationTitle(project.customerName) + .navigationBarTitleDisplayMode(.inline) + } + + private var detailsTab: some View { + List { + Section("Projektdaten") { + LabeledContent("Kunde", value: project.customerName) + LabeledContent("Lead-ID", value: project.leadID) + LabeledContent( + "Erstellt am", + value: project.creationDate.formatted(.dateTime.day().month().year()) + ) + } + + Section("Tankauswahl") { + TankEditor(tank: $project.tank) + } + } + .safeAreaInset(edge: .bottom) { + NavigationLink { + ARExperienceView(project: project) + } label: { + Label("AR-Modus starten", systemImage: "arkit") + .font(.headline) + .frame(maxWidth: .infinity) + .frame(height: 50) + } + .buttonStyle(.borderedProminent) + .padding() + } + } +} + +#Preview { + let sample = PreviewSupport.singleProjectContainer() + + return NavigationStack { + ProjectDetailView(project: sample.project) + } + .modelContainer(sample.container) +} diff --git a/GasTankAR/Views/ProjectListItem.swift b/GasTankAR/Views/ProjectListItem.swift new file mode 100644 index 0000000..63eef63 --- /dev/null +++ b/GasTankAR/Views/ProjectListItem.swift @@ -0,0 +1,58 @@ +// +// ProjectListItem.swift +// GasTankAR +// +// Created by Dennis Nemec on 25.08.26. +// + +import SwiftUI + +struct ProjectListItem: View { + let project: Project + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(.tint) + .frame(width: 40, height: 40) + .overlay { + Text(project.initials) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 2) { + Text(project.customerName) + .font(.body) + Text(project.leadID) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(project.creationDate, format: .dateTime.day().month().year()) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +#Preview { + List { + ProjectListItem( + project: Project( + leadID: "LEAD-1001", + customerName: "Müller Heizungsbau", + creationDate: .now + ) + ) + ProjectListItem( + project: Project( + leadID: "LEAD-1002", + customerName: "Weber & Söhne", + creationDate: .now + ) + ) + } +} diff --git a/GasTankAR/Views/ProjectListView.swift b/GasTankAR/Views/ProjectListView.swift new file mode 100644 index 0000000..455f5b7 --- /dev/null +++ b/GasTankAR/Views/ProjectListView.swift @@ -0,0 +1,99 @@ +// +// ProjectListView.swift +// GasTankAR +// +// Created by Dennis Nemec on 25.08.26. +// + +import SwiftUI +import SwiftData + +struct ProjectListView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \Project.creationDate, order: .reverse) private var projects: [Project] + + @State private var selectedProject: Project? + @State private var isCreatingProject = false + @State private var isShowingSettings = false + + var body: some View { + NavigationSplitView { + List(selection: $selectedProject) { + ForEach(projects) { project in + ProjectListItem(project: project) + .tag(project) + } + .onDelete(perform: deleteProjects) + } + .overlay { + if projects.isEmpty { + ContentUnavailableView( + "Keine Projekte", + systemImage: "tray", + description: Text("Tippe auf +, um dein erstes Projekt zu erstellen.") + ) + } + } + .navigationTitle(Text("Projekte")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Einstellungen", systemImage: "gearshape") { + isShowingSettings = true + } + } + } + .sheet(isPresented: $isShowingSettings) { + SettingsView() + } + .overlay(alignment: .bottomTrailing) { + GlassEffectContainer { + Button { + isCreatingProject = true + } label: { + Image(systemName: "plus") + .font(.title2.weight(.semibold)) + .frame(width: 56, height: 56) + .contentShape(.circle) + } + .buttonStyle(.plain) + .glassEffect(.regular.interactive(), in: .circle) + } + .padding() + .accessibilityLabel("Projekt erstellen") + } + .sheet(isPresented: $isCreatingProject) { + ProjectCreateView() + } + } detail: { + NavigationStack { + if let selectedProject { + ProjectDetailView(project: selectedProject) + } else { + ContentUnavailableView( + "Kein Projekt ausgewählt", + systemImage: "hand.point.up.left", + description: Text("Wähle links ein Projekt aus, um die Details zu sehen.") + ) + } + } + } + } + + private func deleteProjects(at offsets: IndexSet) { + for index in offsets { + let project = projects[index] + if project == selectedProject { selectedProject = nil } + modelContext.delete(project) + } + } +} + +#Preview("Gefüllt") { + ProjectListView() + .modelContainer(PreviewSupport.container) +} + +#Preview("Leer") { + ProjectListView() + .modelContainer(PreviewSupport.makeEmptyContainer()) +} diff --git a/GasTankAR/Views/SettingsView.swift b/GasTankAR/Views/SettingsView.swift new file mode 100644 index 0000000..7f34ab5 --- /dev/null +++ b/GasTankAR/Views/SettingsView.swift @@ -0,0 +1,54 @@ +// +// SettingsView.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import SwiftUI +import ARKit + +/// Zentraler Schlüssel für die LiDAR-Einstellung (in AppStorage/UserDefaults). +enum SettingsKeys { + static let lidarFeaturesEnabled = "lidarFeaturesEnabled" +} + +struct SettingsView: View { + @AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false + @Environment(\.dismiss) private var dismiss + + /// LiDAR ist verfügbar, wenn das Gerät Scene-Reconstruction (Mesh) unterstützt. + private var lidarSupported: Bool { + ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) + } + + var body: some View { + NavigationStack { + Form { + Section { + Toggle("LiDAR-Funktionen aktivieren", isOn: $lidarEnabled) + .disabled(!lidarSupported) + } header: { + Text("Augmented Reality") + } footer: { + if lidarSupported { + Text("Nutzt den LiDAR-Scanner für präzisere Flächenerkennung, Tiefe und realistische Verdeckung. Änderungen greifen beim nächsten Start des AR-Modus.") + } else { + Text("Dieses Gerät hat keinen LiDAR-Scanner. Die App nutzt automatisch das Verfahren ohne LiDAR.") + } + } + } + .navigationTitle("Einstellungen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Fertig") { dismiss() } + } + } + } + } +} + +#Preview { + SettingsView() +} diff --git a/GasTankAR/Views/TankAR/ARControlOverlay.swift b/GasTankAR/Views/TankAR/ARControlOverlay.swift new file mode 100644 index 0000000..970b9e3 --- /dev/null +++ b/GasTankAR/Views/TankAR/ARControlOverlay.swift @@ -0,0 +1,83 @@ +// +// ARControlOverlay.swift +// GasTankAR +// +// Created by Dennis Nemec on 27.08.26. +// + +import SwiftUI + +/// Runde Werkzeug-Buttons oben rechts über der AR-View. Untereinander angeordnet; +/// weitere Funktionen lassen sich als zusätzliche `roundButton` ergänzen. +struct ARControlOverlay: View { + let controller: ARSessionController + /// Öffnet die Strecken-Liste (Ausmessen). + let onMeasure: () -> Void + + var body: some View { + VStack { + HStack { + Spacer() + VStack(spacing: 12) { + roundButton( + systemImage: "shield.lefthalf.filled", + isActive: controller.showSafetyZones, + label: "Sicherheitsabstand" + ) { + controller.toggleSafetyZones() + } + + roundButton( + systemImage: "ruler", + isActive: controller.measurementPhase != .idle, + label: "Ausmessen" + ) { + onMeasure() + } + + roundButton( + systemImage: "point.3.connected.trianglepath.dotted", + isActive: controller.pipeRoutingPhase != .idle, + label: "Rohrführung" + ) { + if controller.pipeRoutingPhase == .idle { + controller.startPipeRouting() + } + } + + roundButton( + systemImage: "camera.fill", + isActive: false, + label: "Foto aufnehmen" + ) { + controller.captureSnapshot() + } + } + } + Spacer() + } + .padding() + } + + private func roundButton( + systemImage: String, + isActive: Bool, + label: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.title3) + .frame(width: 52, height: 52) + .foregroundStyle(isActive ? Color.white : Color.primary) + .background( + isActive ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.ultraThinMaterial), + in: .circle + ) + .shadow(radius: 3) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + .accessibilityAddTraits(isActive ? .isSelected : []) + } +} diff --git a/GasTankAR/Views/TankAR/ARExperienceView.swift b/GasTankAR/Views/TankAR/ARExperienceView.swift new file mode 100644 index 0000000..e05cbea --- /dev/null +++ b/GasTankAR/Views/TankAR/ARExperienceView.swift @@ -0,0 +1,284 @@ +// +// ARExperienceView.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import SwiftUI +import SwiftData + +/// Vollbild-AR-Erlebnis für ein Projekt: komponiert AR-View + Status-Overlay und +/// besitzt den `ARSessionController`. +struct ARExperienceView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false + + let project: Project + + @State private var controller: ARSessionController? + @State private var flashOpacity = 0.0 + @State private var showMeasurements = false + @State private var measurementName = "" + + var body: some View { + ZStack { + if let controller { + ARViewContainer(controller: controller) + .ignoresSafeArea() + ARStatusOverlay(controller: controller, onCancel: { dismiss() }) + if controller.state == .immersive { + ARControlOverlay(controller: controller) { + showMeasurements = true + } + .transition(.opacity) + } + if controller.measurementPhase == .awaitingFirstPoint + || controller.measurementPhase == .awaitingSecondPoint { + measuringOverlay(controller: controller) + } + if controller.pipeRoutingPhase == .placing { + pipeRoutingOverlay(controller: controller) + } + Color.white + .opacity(flashOpacity) + .ignoresSafeArea() + .allowsHitTesting(false) + .onChange(of: controller.snapshotFlashCounter) { + flashOpacity = 0.9 + withAnimation(.easeOut(duration: 0.35)) { flashOpacity = 0 } + } + } + } + .navigationTitle(project.customerName) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showMeasurements) { + if let controller { + MeasurementListView(controller: controller) { + showMeasurements = false + controller.startMeasurement() + } + } + } + .alert("Strecke benennen", isPresented: namingBinding) { + TextField("Bezeichnung", text: $measurementName) + Button("Speichern") { + controller?.finishMeasurement(name: measurementName) + measurementName = "" + } + Button("Abbrechen", role: .cancel) { + controller?.cancelMeasurement() + measurementName = "" + } + } message: { + Text("Gib der Strecke eine Bezeichnung. Sie erscheint auf der Linie.") + } + .task { + guard controller == nil else { return } + let persistence = ProjectPersistenceAdapter(project: project, modelContext: modelContext) + controller = ARSessionController(persistence: persistence, useLiDAR: lidarEnabled) + } + } + + /// Präsentiert den Benennungs-Alert, sobald beide Punkte gesetzt sind. + private var namingBinding: Binding { + Binding( + get: { controller?.measurementPhase == .naming }, + set: { isPresented in + // Wird der Alert ohne Button-Aktion geschlossen, Messvorgang abbrechen. + if !isPresented, controller?.measurementPhase == .naming { + controller?.cancelMeasurement() + } + } + ) + } + + /// Vollständiges Mess-Overlay: Fadenkreuz in der Mitte, Hinweis-/Distanz-Banner oben, + /// Auslöser-Button unten (wie beim Platzieren des Tanks). + private func measuringOverlay(controller: ARSessionController) -> some View { + ZStack { + // Fadenkreuz exakt in der Vollbild-Mitte – deckt sich mit dem Raycast-Punkt + // (arView.bounds-Mitte), der die Safe Area ignoriert. + measuringCrosshair + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + + VStack { + measuringBanner(for: controller) + Spacer() + measuringShutter(for: controller) + } + .padding() + } + .transition(.opacity) + } + + private var measuringCrosshair: some View { + ZStack { + Circle() + .stroke(.yellow.opacity(0.9), lineWidth: 2) + .frame(width: 44, height: 44) + Circle() + .fill(.yellow) + .frame(width: 5, height: 5) + } + .shadow(radius: 2) + .allowsHitTesting(false) + } + + private func measuringBanner(for controller: ARSessionController) -> some View { + HStack(spacing: 8) { + Image(systemName: "ruler") + Text(measuringBannerText(for: controller)) + .font(.subheadline.weight(.medium)) + .monospacedDigit() + Spacer(minLength: 0) + Button("Abbrechen") { controller.cancelMeasurement() } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: .capsule) + } + + private func measuringBannerText(for controller: ARSessionController) -> String { + switch controller.measurementPhase { + case .awaitingFirstPoint: + return "Ziele auf den Startpunkt." + case .awaitingSecondPoint: + if let distance = controller.liveDistance { + return String(format: "Abstand: %.2f m", distance) + } + return "Ziele auf den Endpunkt." + default: + return "" + } + } + + private func measuringShutter(for controller: ARSessionController) -> some View { + let title = controller.measurementPhase == .awaitingFirstPoint ? "Startpunkt setzen" : "Endpunkt setzen" + return VStack(spacing: 10) { + Text(title) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + + Button { + controller.captureMeasurementPoint() + } label: { + ZStack { + Circle() + .stroke(.white, lineWidth: 4) + .frame(width: 74, height: 74) + Circle() + .fill(.yellow) + .frame(width: 60, height: 60) + } + .shadow(radius: 4) + .contentShape(.circle) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + } + } + + // MARK: - Rohrführung-Overlay + + /// Overlay für die Rohrführung: pinkes Fadenkreuz (Vollbild-Mitte), Info-Banner oben, + /// Auslöser + Aktionen unten (gleiche Bedienung wie beim Messen). + private func pipeRoutingOverlay(controller: ARSessionController) -> some View { + ZStack { + pipeCrosshair + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + + VStack { + pipeBanner(for: controller) + Spacer() + pipeControls(for: controller) + } + .padding() + } + .transition(.opacity) + } + + private var pipeCrosshair: some View { + ZStack { + Circle() + .stroke(.pink.opacity(0.9), lineWidth: 2) + .frame(width: 44, height: 44) + Circle() + .fill(.pink) + .frame(width: 5, height: 5) + } + .shadow(radius: 2) + .allowsHitTesting(false) + } + + private func pipeBanner(for controller: ARSessionController) -> some View { + HStack(spacing: 8) { + Image(systemName: "point.3.connected.trianglepath.dotted") + Text(pipeBannerText(for: controller)) + .font(.subheadline.weight(.medium)) + .monospacedDigit() + Spacer(minLength: 0) + Button("Abbrechen") { controller.cancelPipeRouting() } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: .capsule) + } + + private func pipeBannerText(for controller: ARSessionController) -> String { + if controller.pipeWaypointCount == 0 { + return "Ziele auf den Startpunkt der Rohrführung." + } + return "Wegpunkte: \(controller.pipeWaypointCount) · Länge: \(controller.pipeRouteLengthText)" + } + + private func pipeControls(for controller: ARSessionController) -> some View { + VStack(spacing: 12) { + Text("Wegpunkt setzen") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + + Button { + controller.addPipeWaypoint() + } label: { + ZStack { + Circle() + .stroke(.white, lineWidth: 4) + .frame(width: 74, height: 74) + Circle() + .fill(.pink) + .frame(width: 60, height: 60) + } + .shadow(radius: 4) + .contentShape(.circle) + } + .buttonStyle(.plain) + .accessibilityLabel("Wegpunkt setzen") + + HStack(spacing: 12) { + Button("Rückgängig", systemImage: "arrow.uturn.backward") { + controller.undoLastPipeWaypoint() + } + .buttonStyle(.bordered) + .disabled(controller.pipeWaypointCount == 0) + + Button("Fertig", systemImage: "checkmark") { + controller.finishPipeRouting() + } + .buttonStyle(.borderedProminent) + .disabled(controller.pipeWaypointCount < 2) + } + } + } +} diff --git a/GasTankAR/Views/TankAR/ARFlowStateMachine.swift b/GasTankAR/Views/TankAR/ARFlowStateMachine.swift new file mode 100644 index 0000000..15679b2 --- /dev/null +++ b/GasTankAR/Views/TankAR/ARFlowStateMachine.swift @@ -0,0 +1,252 @@ +// +// ARFlowStateMachine.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import Foundation + +/// Zustände des AR-Modus gemäß State-Machine (ohne den entfernten „Choose Tank"-State – +/// stattdessen führt eine gefundene Location/ein erkannter Anker direkt in `immersive`). +enum ARFlowState: Equatable { + case worldNotInitialized + case initializingWorld + case initializingNotPossible + case userMovesAround + case showOptions + case anchorDetection + case askToCancel + case selectLocation + case immersive + case relocating + case relocationFailed +} + +/// Transparente ARKit-Statuslage (wird von der View in lokalisierten Text übersetzt). +enum ARStatus: Equatable { + case relocalizing + case excessiveMotion + case insufficientFeatures + case unavailable +} + +/// Quelle der Transform beim Platzieren des Tanks. +enum PlacementSource: Equatable { + case storedTransform + case screenCenter + case imageAnchor +} + +/// Eingaben in die State-Machine: ARKit-Events, Timer und Nutzeraktionen. +enum ARFlowEvent: Equatable { + case started + + // ARKit-Tracking + case trackingNormal + case trackingInitializing + case trackingRelocalizing + case trackingExcessiveMotion + case trackingInsufficientFeatures + case trackingUnavailable + + // Coaching / Session + case coachingWillActivate + case coachingDidDeactivate + case sessionInterruptionEnded + case imageAnchorDetected + + // Timer + case moveAroundTimerFired + case showOptionsTimerFired + case anchorDetectionTimerFired + case relocationTimerFired + + // Nutzeraktionen + case captureConfirmed // Raycast war erfolgreich (von der View vorgeprüft) + case keepMovingTapped + case placeAnchorMarkerTapped + case tryAgainAnchorTapped + case repositionTapped + case restartTapped + case retryRelocationTapped +} + +/// Seiteneffekte, die der Controller (unreine Schale) ausführt. +enum ARFlowCommand: Equatable { + case setStatus(ARStatus?) + case runConfiguration(useImages: Bool, useInitialMap: Bool, resetTracking: Bool) + case setCoachingAutomatic(Bool) + case startMoveAroundTimer + case startShowOptionsTimer + case cancelEscalationTimers + case startRelocationTimer + case cancelRelocationTimer + case startAnchorDetectionTimer + case cancelAnchorDetectionTimer + case placeTank(PlacementSource) + case removeTank + case persistWorldMap + case persistTankTransform + case clearStoredTransform +} + +/// Reine, testbare State-Machine des AR-Flows. Enthält keinerlei ARKit-/UI-Abhängigkeiten: +/// `reduce` verändert den Zustand und liefert die auszuführenden Kommandos zurück. +struct ARFlowStateMachine { + private(set) var state: ARFlowState = .worldNotInitialized + private(set) var hasPlacedTank = false + private(set) var hasStoredTransform: Bool + + init(hasStoredTransform: Bool) { + self.hasStoredTransform = hasStoredTransform + } + + mutating func reduce(_ event: ARFlowEvent) -> [ARFlowCommand] { + switch event { + case .started: + state = .worldNotInitialized + return [.runConfiguration(useImages: false, useInitialMap: true, resetTracking: false)] + + case .trackingNormal: + var commands: [ARFlowCommand] = [ + .setStatus(nil), .cancelEscalationTimers, .cancelRelocationTimer, .setCoachingAutomatic(true), + ] + commands += resolvePlacedOrRestore() + return commands + + case .trackingUnavailable: + return [.setStatus(.unavailable)] + + case .trackingInitializing: + if !hasPlacedTank, state == .worldNotInitialized || state == .initializingWorld { + state = .initializingWorld + } + return [.setStatus(nil)] + + case .trackingRelocalizing: + state = .relocating + return [.setStatus(.relocalizing), .cancelRelocationTimer, .startRelocationTimer] + + case .trackingExcessiveMotion: + return [.setStatus(.excessiveMotion)] + + case .trackingInsufficientFeatures: + var commands: [ARFlowCommand] = [.setStatus(.insufficientFeatures)] + let blockedStates: [ARFlowState] = [.anchorDetection, .showOptions, .askToCancel] + if !hasPlacedTank, !blockedStates.contains(state), state != .userMovesAround { + state = .initializingNotPossible + commands += [.cancelEscalationTimers, .startMoveAroundTimer] + } + return commands + + case .moveAroundTimerFired: + guard state == .initializingNotPossible else { return [] } + state = .userMovesAround + return [.startShowOptionsTimer] + + case .showOptionsTimerFired: + guard state == .userMovesAround else { return [] } + state = .showOptions + return [.setCoachingAutomatic(false)] + + case .keepMovingTapped: + state = .userMovesAround + return [.setCoachingAutomatic(true), .cancelEscalationTimers, .startShowOptionsTimer] + + case .placeAnchorMarkerTapped: + state = .anchorDetection + return [ + .cancelEscalationTimers, + .setCoachingAutomatic(false), + .runConfiguration(useImages: true, useInitialMap: false, resetTracking: false), + .cancelAnchorDetectionTimer, + .startAnchorDetectionTimer, + ] + + case .anchorDetectionTimerFired: + guard state == .anchorDetection else { return [] } + state = .askToCancel + return [] + + case .tryAgainAnchorTapped: + state = .showOptions + return [ + .cancelAnchorDetectionTimer, + .runConfiguration(useImages: false, useInitialMap: false, resetTracking: false), + .setCoachingAutomatic(false), + ] + + case .imageAnchorDetected: + guard state == .anchorDetection else { return [] } + state = .immersive + hasPlacedTank = true + return [.cancelAnchorDetectionTimer, .placeTank(.imageAnchor), .persistWorldMap, .persistTankTransform] + + case .captureConfirmed: + guard state == .selectLocation else { return [] } + state = .immersive + hasPlacedTank = true + return [.placeTank(.screenCenter), .persistWorldMap, .persistTankTransform] + + case .repositionTapped: + state = .selectLocation + hasPlacedTank = false + hasStoredTransform = false + return [.removeTank, .clearStoredTransform] + + case .restartTapped: + state = .worldNotInitialized + hasPlacedTank = false + hasStoredTransform = false + return [ + .cancelRelocationTimer, + .removeTank, + .clearStoredTransform, + .setStatus(nil), + .runConfiguration(useImages: false, useInitialMap: false, resetTracking: true), + ] + + case .retryRelocationTapped: + state = .relocating + return [.setStatus(.relocalizing), .cancelRelocationTimer, .startRelocationTimer] + + case .relocationTimerFired: + guard state == .relocating else { return [] } + state = .relocationFailed + return [.setStatus(nil)] + + case .coachingWillActivate: + if !hasPlacedTank { state = .initializingWorld } + return [] + + case .coachingDidDeactivate: + return [.cancelEscalationTimers] + resolvePlacedOrRestore() + + case .sessionInterruptionEnded: + var commands: [ARFlowCommand] = [.setStatus(nil)] + if hasPlacedTank { + state = .relocating + commands += [.cancelRelocationTimer, .startRelocationTimer] + } + return commands + } + } + + /// Gemeinsame Logik für „Tracking normal / Coaching fertig": Tank wiederherstellen, + /// sonst in die Platzieren-UI wechseln (sofern nicht gerade Anker-Erkennung läuft). + private mutating func resolvePlacedOrRestore() -> [ARFlowCommand] { + if hasPlacedTank { + state = .immersive + return [] + } + guard state != .anchorDetection else { return [] } + if hasStoredTransform { + state = .immersive + hasPlacedTank = true + return [.placeTank(.storedTransform)] + } + state = .selectLocation + return [] + } +} diff --git a/GasTankAR/Views/TankAR/ARProjectPersisting.swift b/GasTankAR/Views/TankAR/ARProjectPersisting.swift new file mode 100644 index 0000000..8fa6989 --- /dev/null +++ b/GasTankAR/Views/TankAR/ARProjectPersisting.swift @@ -0,0 +1,67 @@ +// +// ARProjectPersisting.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import ARKit +import CoreLocation +import SwiftData + +/// Abstraktion der Projekt-Persistenz für den AR-Modus (Dependency Inversion): +/// Der Controller kennt so kein SwiftData und ist unit-testbar. +@MainActor +protocol ARProjectPersisting: AnyObject { + var tank: Tank { get } + var worldMap: ARWorldMap? { get } + var tankTransform: [Float]? { get } + + func saveWorldMap(_ data: Data) + func saveTankTransform(_ transform: [Float]) + func clearTankTransform() + func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?) +} + +/// SwiftData-Adapter: verbindet das `Project`-Model mit dem `ARProjectPersisting`-Protokoll. +@MainActor +final class ProjectPersistenceAdapter: ARProjectPersisting { + private let project: Project + private let modelContext: ModelContext + + init(project: Project, modelContext: ModelContext) { + self.project = project + self.modelContext = modelContext + } + + var tank: Tank { project.tank } + var worldMap: ARWorldMap? { project.worldMap } + var tankTransform: [Float]? { project.tankTransform } + + func saveWorldMap(_ data: Data) { + project.worldMapData = data + try? modelContext.save() + } + + func saveTankTransform(_ transform: [Float]) { + project.tankTransform = transform + try? modelContext.save() + } + + func clearTankTransform() { + project.tankTransform = nil + try? modelContext.save() + } + + func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?) { + let photo = ProjectPhoto( + creationDate: date, + imageData: imageData, + latitude: coordinate?.latitude, + longitude: coordinate?.longitude, + project: project + ) + modelContext.insert(photo) + try? modelContext.save() + } +} diff --git a/GasTankAR/Views/TankAR/ARSessionController.swift b/GasTankAR/Views/TankAR/ARSessionController.swift new file mode 100644 index 0000000..4eada74 --- /dev/null +++ b/GasTankAR/Views/TankAR/ARSessionController.swift @@ -0,0 +1,873 @@ +// +// ARSessionController.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import SwiftUI +import RealityKit +import ARKit +import CoreLocation +import Combine + +/// Orchestriert den AR-Modus: hält die `ARView`/Session, empfängt ARKit-Events, +/// treibt die reine `ARFlowStateMachine` und führt deren Kommandos aus. +/// Delegiert Konfiguration, Entity-Bau, Referenzbilder und Persistenz an Kollaborateure. +@MainActor +@Observable +final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayViewDelegate, CLLocationManagerDelegate { + // Von der UI beobachtete Zustände. + private(set) var state: ARFlowState = .worldNotInitialized + private(set) var statusMessage: String? + private(set) var worldMappingStatus: ARFrame.WorldMappingStatus = .notAvailable + private(set) var isTankSelected = false { + didSet { updateSelectionHighlight() } + } + /// Ob die Sicherheitsabstände (Ringe um den Tank) eingeblendet sind. + private(set) var showSafetyZones = false + /// Wird bei jedem erfolgreichen Foto erhöht – die UI nutzt das für ein kurzes Aufblitzen. + private(set) var snapshotFlashCounter = 0 + /// Angelegte Strecken (nur für die laufende Sitzung gehalten). + private(set) var measurements: [Measurement] = [] + /// Aktuelle Phase des Messvorgangs. + private(set) var measurementPhase: MeasurementPhase = .idle + /// Live-Distanz (Meter) zwischen erstem Punkt und aktueller Bildschirmmitte – für die UI. + private(set) var liveDistance: Float? + + /// Aktuelle Rohrführung (Wegpunkt-Pfad) der laufenden Sitzung. + private(set) var pipeRoute = PipeRoute() + /// Aktuelle Phase des Rohr-Routings. + private(set) var pipeRoutingPhase: PipeRoutingPhase = .idle + /// Anzahl gesetzter Wegpunkte (für die UI). + var pipeWaypointCount: Int { pipeRoute.count } + /// Gesamtlänge der Rohrführung als Text (für die UI). + var pipeRouteLengthText: String { pipeRoute.formattedLength } + + @ObservationIgnored private let persistence: ARProjectPersisting + @ObservationIgnored private let useLiDAR: Bool + @ObservationIgnored private let configFactory: SessionConfigurationFactory + @ObservationIgnored private let entityFactory = TankEntityFactory() + @ObservationIgnored private var machine: ARFlowStateMachine + + @ObservationIgnored private weak var arView: ARView? + @ObservationIgnored private weak var coaching: ARCoachingOverlayView? + + @ObservationIgnored private var tankAnchor: AnchorEntity? + @ObservationIgnored private var tankEntity: ModelEntity? + @ObservationIgnored private var safetyZoneEntity: Entity? + + /// Anker für alle Mess-Visualisierungen (unabhängig vom Tank). + @ObservationIgnored private var measurementAnchor: AnchorEntity? + /// Container der fertigen Strecken, nach `Measurement.id`. + @ObservationIgnored private var measurementEntities: [UUID: Entity] = [:] + /// Label-Halter für das Billboarding (Ausrichtung zur Kamera). + @ObservationIgnored private var measurementLabels: [Entity] = [] + /// Vorläufige Punkt-Kugeln während eines laufenden Messvorgangs. + @ObservationIgnored private var previewPointEntities: [Entity] = [] + @ObservationIgnored private var pendingMeasurementStart: SIMD3? + @ObservationIgnored private var pendingMeasurementEnd: SIMD3? + + /// Live-Vorschau (folgt der Bildschirmmitte): gezogene Linie und Distanz-Label. + /// Das Fadenkreuz stellt selbst das aktuelle Linienende dar (kein zusätzlicher Cursor). + @ObservationIgnored private var liveLineEntity: ModelEntity? + @ObservationIgnored private var liveLabelEntity: Entity? + /// Zuletzt gezeigte Live-Distanz in Zentimetern (drosselt die Label-/UI-Aktualisierung). + @ObservationIgnored private var lastLiveDistanceCm: Int? + + /// Anker + Entities der Rohrführung. + @ObservationIgnored private var pipeAnchor: AnchorEntity? + @ObservationIgnored private var pipeWaypointEntities: [Entity] = [] + @ObservationIgnored private var pipeSegmentEntities: [Entity] = [] + /// Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte. + @ObservationIgnored private var pipeLiveSegment: ModelEntity? + /// Per-Frame-Callback zum Ausrichten der Labels zur Kamera. + @ObservationIgnored private var sceneUpdateSubscription: (any Cancellable)? + + /// Liefert den Standort für die Verortung aufgenommener Fotos. + @ObservationIgnored private let locationManager = CLLocationManager() + /// Zuletzt bekannter Standort (für die GPS-Verortung von Fotos). + @ObservationIgnored private var currentLocation: CLLocation? + + @ObservationIgnored private lazy var initialWorldMap: ARWorldMap? = persistence.worldMap + @ObservationIgnored private lazy var storedTransform: simd_float4x4? = + persistence.tankTransform.flatMap { simd_float4x4(floatArray: $0) } + /// Transform für die nächste Platzierung aus Raycast/Bild-Anker. + @ObservationIgnored private var pendingTransform: simd_float4x4? + + @ObservationIgnored private var moveAroundTask: Task? + @ObservationIgnored private var showOptionsTask: Task? + @ObservationIgnored private var anchorDetectionTask: Task? + @ObservationIgnored private var relocationTask: Task? + @ObservationIgnored private var mappingPollTask: Task? + + private static let moveAroundDelay: Duration = .seconds(3) + private static let showOptionsDelay: Duration = .seconds(6) + private static let anchorDetectionTimeout: Duration = .seconds(20) + private static let relocationTimeout: Duration = .seconds(15) + + init(persistence: ARProjectPersisting, useLiDAR: Bool) { + self.persistence = persistence + self.useLiDAR = useLiDAR + self.configFactory = SessionConfigurationFactory( + useLiDAR: useLiDAR, + referenceImages: ReferenceImageProvider.load() + ) + self.machine = ARFlowStateMachine(hasStoredTransform: persistence.tankTransform != nil) + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + } + + // MARK: - Lifecycle (vom ARViewContainer) + + func attach(to view: ARView) { + arView = view + view.session.delegate = self + view.automaticallyConfigureSession = false + view.environment.sceneUnderstanding.options = useLiDAR ? [.occlusion] : [] + + let coachingView = ARCoachingOverlayView() + coachingView.session = view.session + coachingView.goal = .horizontalPlane + coachingView.activatesAutomatically = true + coachingView.delegate = self + coachingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(coachingView) + NSLayoutConstraint.activate([ + coachingView.topAnchor.constraint(equalTo: view.topAnchor), + coachingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + coachingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + coachingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + coaching = coachingView + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + view.addGestureRecognizer(tap) + + // Pro Frame die Mess-Labels zur Kamera ausrichten (Billboarding). + sceneUpdateSubscription = view.scene.subscribe(to: SceneEvents.Update.self) { [weak self] _ in + guard let self else { return } + MainActor.assumeIsolated { self.onSceneUpdate() } + } + + entityFactory.preload() + startMappingPoll() + startLocationUpdates() + send(.started) + } + + func detach() { + [moveAroundTask, showOptionsTask, anchorDetectionTask, relocationTask, mappingPollTask] + .forEach { $0?.cancel() } + locationManager.stopUpdatingLocation() + sceneUpdateSubscription?.cancel() + arView?.session.pause() + } + + // MARK: - Nutzeraktionen (von der Overlay-UI) + + func capture() { + guard state == .selectLocation, let arView else { return } + let center = CGPoint(x: arView.bounds.midX, y: arView.bounds.midY) + guard let result = arView.raycast(from: center, allowing: .estimatedPlane, alignment: .horizontal).first + else { return } + pendingTransform = result.worldTransform + send(.captureConfirmed) + } + + func keepMoving() { send(.keepMovingTapped) } + func placeAnchorMarker() { send(.placeAnchorMarkerTapped) } + func tryAgainAnchor() { send(.tryAgainAnchorTapped) } + func reposition() { send(.repositionTapped) } + func restart() { send(.restartTapped) } + func retryRelocation() { send(.retryRelocationTapped) } + func deselectTank() { isTankSelected = false } + + // MARK: - Tank-Feinjustierung (über das Steuer-Panel) + + private static let heightStep: Float = 0.05 + private static let moveStep: Float = 0.05 + private static let rotationStep: Float = .pi / 36 // 5° + + /// Verändert die Höhe des Tanks (y-Achse). Ein Schritt pro Aufruf (auch beim Halten wiederholt). + func adjustTankHeight(up: Bool) { + guard let tankEntity else { return } + tankEntity.position.y += up ? Self.heightStep : -Self.heightStep + refreshAfterEdit() + } + + /// Verschiebt den Tank auf dem Boden – links/rechts relativ zur Blickrichtung. + func moveTank(left: Bool) { + moveOnGround(right: left ? -1 : 1, forward: 0) + } + + /// Verschiebt den Tank auf dem Boden – vor/zurück relativ zur Blickrichtung. + func moveTank(forward: Bool) { + moveOnGround(right: 0, forward: forward ? 1 : -1) + } + + /// Dreht den Tank um die Hochachse. + func rotateTank(clockwise: Bool) { + guard let tankEntity else { return } + let angle = (clockwise ? -1 : 1) * Self.rotationStep + let rotation = simd_quatf(angle: angle, axis: SIMD3(0, 1, 0)) + tankEntity.transform.rotation = rotation * tankEntity.transform.rotation + refreshAfterEdit() + } + + /// Sichert die Transform nach Abschluss einer Anpassung (z. B. beim Loslassen des Buttons). + func commitTankEdit() { + persistTankTransform() + } + + /// Verschiebt den Tank in der Bodenebene entlang der kamerarelativen Rechts-/Vorwärts-Achse. + private func moveOnGround(right rightAmount: Float, forward forwardAmount: Float) { + guard let tankEntity, let arView else { return } + let cameraMatrix = arView.cameraTransform.matrix + + var right = SIMD3(cameraMatrix.columns.0.x, 0, cameraMatrix.columns.0.z) + right = length(right) > 1e-4 ? normalize(right) : SIMD3(1, 0, 0) + + // Kamera-Vorwärts = -Z der Kamera, auf den Boden projiziert. + var forward = SIMD3(-cameraMatrix.columns.2.x, 0, -cameraMatrix.columns.2.z) + forward = length(forward) > 1e-4 ? normalize(forward) : SIMD3(0, 0, -1) + + tankEntity.position += (right * rightAmount + forward * forwardAmount) * Self.moveStep + refreshAfterEdit() + } + + /// Aktualisiert abhängige Visuals nach einer Anpassung – ohne zu persistieren + /// (das Speichern erfolgt gebündelt via ``commitTankEdit()``). + private func refreshAfterEdit() { + updateSafetyZones() + updateDepthFadeGroundY() + } + + /// Blendet die Eck-Markierung passend zur Auswahl ein/aus. + private func updateSelectionHighlight() { + guard let tankEntity, + let highlight = tankEntity.findEntity(named: TankHighlightFactory.name) else { return } + highlight.isEnabled = isTankSelected + } + + /// Blendet die Sicherheitsabstände um den Tank ein bzw. aus. + func toggleSafetyZones() { + showSafetyZones.toggle() + updateSafetyZones() + } + + /// Nimmt einen Snapshot der aktuellen AR-Sicht auf und speichert ihn – samt aktuellem + /// Standort für die spätere Karten-Verortung – im Projekt. + func captureSnapshot() { + guard let arView else { return } + let coordinate = currentLocation?.coordinate + arView.snapshot(saveToHDR: false) { [weak self] image in + guard let image else { return } + let date = Date() + guard let data = PhotoEncoder.jpegData(from: image, coordinate: coordinate, date: date) else { return } + Task { @MainActor in + guard let self else { return } + self.persistence.savePhoto(data, date: date, coordinate: coordinate) + self.snapshotFlashCounter += 1 + } + } + } + + // MARK: - Ausmessen (Strecken) + + /// Startet einen neuen Messvorgang; der Nutzer tippt danach zwei Punkte an. + func startMeasurement() { + clearMeasurementPreview() + pendingMeasurementStart = nil + pendingMeasurementEnd = nil + measurementPhase = .awaitingFirstPoint + } + + /// Bricht den laufenden Messvorgang ab und entfernt vorläufige Punkte. + func cancelMeasurement() { + clearMeasurementPreview() + hideLivePreview() + pendingMeasurementStart = nil + pendingMeasurementEnd = nil + measurementPhase = .idle + } + + /// Schließt den Messvorgang mit der eingegebenen Bezeichnung ab und rendert die Strecke. + func finishMeasurement(name: String) { + guard let start = pendingMeasurementStart, let end = pendingMeasurementEnd else { + cancelMeasurement() + return + } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let measurement = Measurement( + name: trimmed.isEmpty ? "Strecke \(measurements.count + 1)" : trimmed, + start: start, + end: end + ) + measurements.append(measurement) + renderMeasurement(measurement) + + clearMeasurementPreview() + hideLivePreview() + pendingMeasurementStart = nil + pendingMeasurementEnd = nil + measurementPhase = .idle + } + + /// Entfernt Strecken (aus der Liste per Swipe). + func deleteMeasurements(at offsets: IndexSet) { + for index in offsets { + let measurement = measurements[index] + if let entity = measurementEntities.removeValue(forKey: measurement.id) { + if let label = entity.findEntity(named: MeasurementEntityFactory.labelName) { + measurementLabels.removeAll { $0 === label } + } + entity.removeFromParent() + } + } + measurements.remove(atOffsets: offsets) + } + + /// Setzt den aktuellen Punkt aus der Bildschirmmitte (wie beim Platzieren des Tanks). + func captureMeasurementPoint() { + guard measurementPhase == .awaitingFirstPoint || measurementPhase == .awaitingSecondPoint else { return } + guard let position = raycastScreenCenter() else { + statusMessage = "Kein Punkt erkannt – ziele auf eine erkannte Fläche." + return + } + addMeasurementPreviewPoint(at: position) + + if measurementPhase == .awaitingFirstPoint { + pendingMeasurementStart = position + measurementPhase = .awaitingSecondPoint + } else { + pendingMeasurementEnd = position + measurementPhase = .naming + hideLivePreview() + } + } + + /// Raycast aus der Bildschirmmitte auf eine (geschätzte) Fläche. + private func raycastScreenCenter() -> SIMD3? { + guard let arView else { return nil } + let center = CGPoint(x: arView.bounds.midX, y: arView.bounds.midY) + guard let result = arView.raycast(from: center, allowing: .estimatedPlane, alignment: .any).first + else { return nil } + let column = result.worldTransform.columns.3 + return SIMD3(column.x, column.y, column.z) + } + + private func addMeasurementPreviewPoint(at position: SIMD3) { + let anchor = ensureMeasurementAnchor() + let point = MeasurementEntityFactory.makePoint(at: position) + anchor.addChild(point) + previewPointEntities.append(point) + } + + private func renderMeasurement(_ measurement: Measurement) { + let anchor = ensureMeasurementAnchor() + let entity = MeasurementEntityFactory.makeMeasurement(measurement) + anchor.addChild(entity) + measurementEntities[measurement.id] = entity + if let label = entity.findEntity(named: MeasurementEntityFactory.labelName) { + measurementLabels.append(label) + } + } + + private func ensureMeasurementAnchor() -> AnchorEntity { + if let measurementAnchor { return measurementAnchor } + let anchor = AnchorEntity(world: matrix_identity_float4x4) + arView?.scene.addAnchor(anchor) + measurementAnchor = anchor + return anchor + } + + private func clearMeasurementPreview() { + previewPointEntities.forEach { $0.removeFromParent() } + previewPointEntities.removeAll() + } + + /// Pro-Frame-Aufgaben: Live-Vorschau aktualisieren und alle Labels zur Kamera ausrichten. + private func onSceneUpdate() { + updateLivePreview() + updatePipeLivePreview() + billboardLabels() + } + + /// Aktualisiert Cursor, Live-Linie und Distanz-Label anhand der aktuellen Bildschirmmitte. + private func updateLivePreview() { + guard measurementPhase == .awaitingFirstPoint || measurementPhase == .awaitingSecondPoint, + let position = raycastScreenCenter() else { + return + } + + ensureLivePreviewEntities() + + guard measurementPhase == .awaitingSecondPoint, let start = pendingMeasurementStart else { + liveLineEntity?.isEnabled = false + liveLabelEntity?.isEnabled = false + liveDistance = nil + lastLiveDistanceCm = nil + return + } + + if let liveLineEntity { + liveLineEntity.isEnabled = true + MeasurementEntityFactory.configureLine(liveLineEntity, from: start, to: position) + } + + let distance = simd_distance(start, position) + liveLabelEntity?.isEnabled = true + liveLabelEntity?.position = MeasurementEntityFactory.labelPosition(from: start, to: position) + + // Text/Distanz nur bei Änderung (auf cm gerundet) aktualisieren – spart Text-Neuaufbau. + let cm = Int((distance * 100).rounded()) + if cm != lastLiveDistanceCm { + lastLiveDistanceCm = cm + liveDistance = distance + if let holder = liveLabelEntity { + holder.children.forEach { $0.removeFromParent() } + holder.addChild(MeasurementEntityFactory.makeText(String(format: "%.2f m", distance))) + } + } + } + + private func ensureLivePreviewEntities() { + let anchor = ensureMeasurementAnchor() + if liveLineEntity == nil { + let line = MeasurementEntityFactory.makeLineBase() + line.isEnabled = false + anchor.addChild(line) + liveLineEntity = line + } + if liveLabelEntity == nil { + let label = MeasurementEntityFactory.makeLabelHolder() + label.isEnabled = false + anchor.addChild(label) + liveLabelEntity = label + } + } + + private func hideLivePreview() { + liveLineEntity?.isEnabled = false + liveLabelEntity?.isEnabled = false + liveDistance = nil + lastLiveDistanceCm = nil + } + + /// Richtet alle sichtbaren Labels (inkl. Live-Label) nur um die Gier-Achse zur Kamera aus. + private func billboardLabels() { + guard let arView else { return } + let camera = arView.cameraTransform.translation + var labels = measurementLabels + if let liveLabelEntity, liveLabelEntity.isEnabled { labels.append(liveLabelEntity) } + guard !labels.isEmpty else { return } + + for label in labels { + let position = label.position(relativeTo: nil) + let direction = camera - position + let yaw = atan2(direction.x, direction.z) + label.setOrientation(simd_quatf(angle: yaw, axis: SIMD3(0, 1, 0)), relativeTo: nil) + } + } + + // MARK: - Rohrführung (Wegpunkt-Pfad) + + /// Startet eine neue Rohrführung. Beendet andere Interaktionsmodi und setzt den Pfad zurück. + func startPipeRouting() { + cancelMeasurement() + isTankSelected = false + clearPipeRoute() + pipeRoutingPhase = .placing + } + + /// Setzt einen Wegpunkt aus der Bildschirmmitte und verbindet ihn mit dem vorherigen. + func addPipeWaypoint() { + guard pipeRoutingPhase == .placing else { return } + guard let position = raycastScreenCenter() else { + statusMessage = "Kein Punkt erkannt – ziele auf eine erkannte Fläche." + return + } + let anchor = ensurePipeAnchor() + if let previous = pipeRoute.waypoints.last { + let segment = PipeRouteEntityFactory.makeSegment(from: previous, to: position) + anchor.addChild(segment) + pipeSegmentEntities.append(segment) + } + let waypoint = PipeRouteEntityFactory.makeWaypoint(at: position) + anchor.addChild(waypoint) + pipeWaypointEntities.append(waypoint) + pipeRoute.add(position) + } + + /// Entfernt den zuletzt gesetzten Wegpunkt (und sein Segment). + func undoLastPipeWaypoint() { + guard pipeRoute.removeLast() != nil else { return } + pipeWaypointEntities.popLast()?.removeFromParent() + pipeSegmentEntities.popLast()?.removeFromParent() + } + + /// Beendet das Setzen; der fertige Pfad bleibt sichtbar. + func finishPipeRouting() { + pipeRoutingPhase = .idle + hidePipeLivePreview() + } + + /// Verwirft die komplette Rohrführung. + func cancelPipeRouting() { + clearPipeRoute() + pipeRoutingPhase = .idle + } + + private func ensurePipeAnchor() -> AnchorEntity { + if let pipeAnchor { return pipeAnchor } + let anchor = AnchorEntity(world: matrix_identity_float4x4) + arView?.scene.addAnchor(anchor) + pipeAnchor = anchor + return anchor + } + + private func clearPipeRoute() { + pipeWaypointEntities.forEach { $0.removeFromParent() } + pipeSegmentEntities.forEach { $0.removeFromParent() } + pipeWaypointEntities.removeAll() + pipeSegmentEntities.removeAll() + hidePipeLivePreview() + pipeRoute.clear() + } + + /// Zeichnet das Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte. + private func updatePipeLivePreview() { + guard pipeRoutingPhase == .placing, + let last = pipeRoute.waypoints.last, + let position = raycastScreenCenter() else { + hidePipeLivePreview() + return + } + let segment = ensurePipeLiveSegment() + segment.isEnabled = true + PipeRouteEntityFactory.configureSegment(segment, from: last, to: position) + } + + private func ensurePipeLiveSegment() -> ModelEntity { + if let pipeLiveSegment { return pipeLiveSegment } + let segment = PipeRouteEntityFactory.makeSegmentBase() + segment.isEnabled = false + ensurePipeAnchor().addChild(segment) + pipeLiveSegment = segment + return segment + } + + private func hidePipeLivePreview() { + pipeLiveSegment?.isEnabled = false + } + + // MARK: - Standort (für die GPS-Verortung von Fotos) + + private func startLocationUpdates() { + switch locationManager.authorizationStatus { + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + case .authorizedWhenInUse, .authorizedAlways: + locationManager.startUpdatingLocation() + default: + break + } + } + + // MARK: - State-Machine + + private func send(_ event: ARFlowEvent) { + let commands = machine.reduce(event) + state = machine.state + if state != .immersive { isTankSelected = false } + commands.forEach(execute) + } + + private func execute(_ command: ARFlowCommand) { + switch command { + case .setStatus(let status): + statusMessage = status.map(Self.text(for:)) + case .runConfiguration(let useImages, let useInitialMap, let resetTracking): + runConfiguration(useImages: useImages, useInitialMap: useInitialMap, resetTracking: resetTracking) + case .setCoachingAutomatic(let enabled): + setCoachingAutomatic(enabled) + case .startMoveAroundTimer: + moveAroundTask = schedule(Self.moveAroundDelay) { $0.send(.moveAroundTimerFired) } + case .startShowOptionsTimer: + showOptionsTask = schedule(Self.showOptionsDelay) { $0.send(.showOptionsTimerFired) } + case .cancelEscalationTimers: + moveAroundTask?.cancel() + showOptionsTask?.cancel() + case .startRelocationTimer: + relocationTask = schedule(Self.relocationTimeout) { $0.send(.relocationTimerFired) } + case .cancelRelocationTimer: + relocationTask?.cancel() + case .startAnchorDetectionTimer: + anchorDetectionTask = schedule(Self.anchorDetectionTimeout) { $0.send(.anchorDetectionTimerFired) } + case .cancelAnchorDetectionTimer: + anchorDetectionTask?.cancel() + case .placeTank(let source): + placeTank(from: source) + case .removeTank: + removeTank() + case .persistWorldMap: + persistWorldMap() + case .persistTankTransform: + persistTankTransform() + case .clearStoredTransform: + storedTransform = nil + persistence.clearTankTransform() + } + } + + private func schedule(_ duration: Duration, _ action: @escaping (ARSessionController) -> Void) -> Task { + Task { @MainActor [weak self] in + try? await Task.sleep(for: duration) + guard !Task.isCancelled, let self else { return } + action(self) + } + } + + // MARK: - ARSessionDelegate + + nonisolated func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { + let trackingState = camera.trackingState + Task { @MainActor in self.handleTrackingState(trackingState) } + } + + nonisolated func session(_ session: ARSession, didFailWithError error: Error) { + let message = error.localizedDescription + Task { @MainActor in self.statusMessage = "AR-Fehler: \(message)" } + } + + nonisolated func sessionWasInterrupted(_ session: ARSession) { + Task { @MainActor in self.statusMessage = "AR unterbrochen …" } + } + + nonisolated func sessionInterruptionEnded(_ session: ARSession) { + Task { @MainActor in self.send(.sessionInterruptionEnded) } + } + + nonisolated func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { + let transform = anchors.compactMap { $0 as? ARImageAnchor }.first?.transform + guard let transform else { return } + Task { @MainActor in + self.pendingTransform = transform + self.send(.imageAnchorDetected) + } + } + + private func handleTrackingState(_ trackingState: ARCamera.TrackingState) { + switch trackingState { + case .normal: send(.trackingNormal) + case .notAvailable: send(.trackingUnavailable) + case .limited(.initializing): send(.trackingInitializing) + case .limited(.relocalizing): send(.trackingRelocalizing) + case .limited(.excessiveMotion): send(.trackingExcessiveMotion) + case .limited(.insufficientFeatures): send(.trackingInsufficientFeatures) + case .limited: break + @unknown default: break + } + } + + // MARK: - ARCoachingOverlayViewDelegate + + func coachingOverlayViewWillActivate(_ coachingOverlayView: ARCoachingOverlayView) { + send(.coachingWillActivate) + } + + func coachingOverlayViewDidDeactivate(_ coachingOverlayView: ARCoachingOverlayView) { + send(.coachingDidDeactivate) + } + + // MARK: - CLLocationManagerDelegate + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + Task { @MainActor in self.currentLocation = location } + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task { @MainActor in self.startLocationUpdates() } + } + + // MARK: - Session-Konfiguration + + private func runConfiguration(useImages: Bool, useInitialMap: Bool, resetTracking: Bool) { + let config = configFactory.make( + withImages: useImages, + initialWorldMap: useInitialMap ? initialWorldMap : nil + ) + let options: ARSession.RunOptions = resetTracking ? [.resetTracking, .removeExistingAnchors] : [] + arView?.session.run(config, options: options) + } + + private func setCoachingAutomatic(_ enabled: Bool) { + coaching?.activatesAutomatically = enabled + if !enabled { coaching?.setActive(false, animated: true) } + } + + // MARK: - Platzierung & Manipulation + + private func placeTank(from source: PlacementSource) { + let transform: simd_float4x4? + switch source { + case .storedTransform: transform = storedTransform + case .screenCenter, .imageAnchor: transform = pendingTransform + } + guard let transform else { return } + placeTank(at: transform) + } + + private func placeTank(at worldTransform: simd_float4x4) { + let anchor: AnchorEntity + if let tankAnchor { + anchor = tankAnchor + } else { + anchor = AnchorEntity(world: matrix_identity_float4x4) + arView?.scene.addAnchor(anchor) + tankAnchor = anchor + } + + let container: ModelEntity + if let tankEntity { + container = tankEntity + } else { + container = entityFactory.makeContainer(for: persistence.tank) + anchor.addChild(container) + tankEntity = container + installManipulationGestures(on: container) + } + + container.transform = Transform(matrix: worldTransform) + updateDepthFadeGroundY() + updateSceneOcclusion() + updateSafetyZones() + } + + /// Baut die Sicherheitsabstände auf, aktualisiert ihre Bodenposition oder entfernt sie. + /// Die Ringe hängen am Tank-Anker (nicht am Tank selbst), damit Skalierung/Rotation + /// des Tanks sie nicht verzerrt; sie werden nur auf die Bodenmitte des Tanks gesetzt. + private func updateSafetyZones() { + guard showSafetyZones, let tankAnchor, let tankEntity else { + safetyZoneEntity?.removeFromParent() + safetyZoneEntity = nil + return + } + + let entity: Entity + if let safetyZoneEntity { + entity = safetyZoneEntity + } else { + entity = SafetyZoneFactory.makeEntity() + tankAnchor.addChild(entity) + safetyZoneEntity = entity + } + entity.position = tankEntity.position(relativeTo: tankAnchor) + } + + private func installManipulationGestures(on entity: ModelEntity) { + guard let arView else { return } + let recognizers = arView.installGestures([.translation, .rotation, .scale], for: entity) + for recognizer in recognizers { + recognizer.addTarget(self, action: #selector(handleManipulationChanged(_:))) + } + } + + @objc private func handleManipulationChanged(_ recognizer: UIGestureRecognizer) { + updateSafetyZones() // Ringe live mit dem Tank mitführen. + switch recognizer.state { + case .ended, .cancelled, .failed: persistTankTransform() + default: break + } + } + + /// Einzel-Tap: den Tank an-/abwählen (blendet die „Neu platzieren"-Option ein). + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + guard state == .immersive, let arView else { return } + + // Während Messen/Rohrführung erfolgt die Auswahl über Fadenkreuz + Button – Taps ignorieren. + guard measurementPhase == .idle, pipeRoutingPhase == .idle else { return } + + let point = gesture.location(in: arView) + guard let tankEntity else { return } + if let hit = arView.entity(at: point), Self.entity(hit, isPartOf: tankEntity) { + isTankSelected.toggle() + } else { + isTankSelected = false + } + } + + private func removeTank() { + if let tankAnchor { arView?.scene.removeAnchor(tankAnchor) } + tankAnchor = nil + tankEntity = nil + safetyZoneEntity = nil + isTankSelected = false + } + + /// Scene-Mesh-Occlusion (LiDAR) nur für oberirdische Tanks – sonst würde das + /// rekonstruierte Boden-Mesh den halbtransparenten, vergrabenen Teil verdecken. + private func updateSceneOcclusion() { + guard let arView else { return } + let wantsOcclusion = useLiDAR && persistence.tank.placement != .belowGround + arView.environment.sceneUnderstanding.options = wantsOcclusion ? [.occlusion] : [] + } + + private func updateDepthFadeGroundY() { + guard persistence.tank.placement == .belowGround, let tankEntity else { return } + let groundY = tankEntity.position(relativeTo: nil).y + TankDepthFadeMaterial.updateGroundY(groundY, on: tankEntity) + } + + // MARK: - Persistenz & Status + + private func persistWorldMap() { + arView?.session.getCurrentWorldMap { map, _ in + // Completion läuft nicht auf dem Main-Thread – hier off-main archivieren. + guard let map, let data = WorldMapArchiver.archive(map) else { return } + Task { @MainActor [weak self] in self?.persistence.saveWorldMap(data) } + } + } + + private func persistTankTransform() { + guard let tankEntity else { return } + updateDepthFadeGroundY() + persistence.saveTankTransform(tankEntity.transformMatrix(relativeTo: nil).floatArray) + } + + private func startMappingPoll() { + mappingPollTask?.cancel() + mappingPollTask = Task { @MainActor in + while !Task.isCancelled { + if let status = self.arView?.session.currentFrame?.worldMappingStatus { + self.worldMappingStatus = status + } + try? await Task.sleep(for: .seconds(1)) + } + } + } + + // MARK: - Helfer + + private static func text(for status: ARStatus) -> String { + switch status { + case .relocalizing: "Position wird wiederhergestellt …" + case .excessiveMotion: "Zu schnelle Bewegung – bitte langsamer." + case .insufficientFeatures: "Zu wenige Merkmale – geh bitte umher und sorge für mehr Licht." + case .unavailable: "AR ist auf diesem Gerät nicht verfügbar." + } + } + + /// Prüft, ob eine getroffene Entity der Tank-Container oder eines seiner Kinder ist. + private static func entity(_ entity: Entity, isPartOf root: Entity) -> Bool { + var current: Entity? = entity + while let node = current { + if node === root { return true } + current = node.parent + } + return false + } +} diff --git a/GasTankAR/Views/TankAR/ARStatusOverlay.swift b/GasTankAR/Views/TankAR/ARStatusOverlay.swift new file mode 100644 index 0000000..c50f2ba --- /dev/null +++ b/GasTankAR/Views/TankAR/ARStatusOverlay.swift @@ -0,0 +1,234 @@ +// +// ARStatusOverlay.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import SwiftUI +import ARKit + +/// Dezente, nicht-distraktive Overlays über der AR-View: ein schmaler Status-Banner +/// oben sowie Aktions-Karten unten für die Entscheidungs-States der State-Machine. +struct ARStatusOverlay: View { + let controller: ARSessionController + let onCancel: () -> Void + + var body: some View { + ZStack { + // Fadenkreuz exakt im Bildschirmzentrum – dort trifft der Raycast. + if controller.state == .selectLocation { + crosshair + } + + VStack { + if let message = controller.statusMessage { + statusBanner(message) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + Spacer() + + bottomContent + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + .padding() + } + .animation(.easeInOut(duration: 0.25), value: controller.statusMessage) + .animation(.easeInOut(duration: 0.25), value: controller.state) + .animation(.easeInOut(duration: 0.25), value: controller.isTankSelected) + } + + // MARK: - Fadenkreuz & Auslöser (Kamera-Style) + + private var crosshair: some View { + ZStack { + Circle() + .stroke(.white.opacity(0.9), lineWidth: 2) + .frame(width: 44, height: 44) + Circle() + .fill(.white) + .frame(width: 5, height: 5) + } + .shadow(radius: 2) + .allowsHitTesting(false) + } + + private var shutterButton: some View { + VStack(spacing: 10) { + Text("Tank platzieren") + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + + Button { + controller.capture() + } label: { + ZStack { + Circle() + .stroke(.white, lineWidth: 4) + .frame(width: 74, height: 74) + Circle() + .fill(.white) + .frame(width: 60, height: 60) + } + .shadow(radius: 4) + .contentShape(.circle) + } + .buttonStyle(.plain) + .accessibilityLabel("Tank platzieren") + } + } + + // MARK: - Status-Banner + + private func statusBanner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(message) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: .capsule) + .frame(maxWidth: .infinity) + } + + // MARK: - Untere Aktions-Inhalte je State + + @ViewBuilder + private var bottomContent: some View { + switch controller.state { + case .selectLocation: + shutterButton + + case .showOptions: + actionCard( + title: "Zu wenige Merkmale erkannt", + message: "Die Umgebung bietet ARKit zu wenig Struktur." + ) { + Button("Weiter umhergehen") { controller.keepMoving() } + .buttonStyle(.borderedProminent) + Button("Marker platzieren") { controller.placeAnchorMarker() } + .buttonStyle(.bordered) + Button("Abbrechen", role: .cancel, action: onCancel) + .buttonStyle(.bordered) + } + + case .anchorDetection: + hintCard( + icon: "viewfinder", + text: "Richte die Kamera auf den Marker, um ihn zu erkennen.", + showsProgress: true, + onCancel: onCancel + ) + + case .askToCancel: + actionCard( + title: "Marker nicht erkannt", + message: "Der Marker konnte nicht zuverlässig erkannt werden." + ) { + Button("Erneut versuchen") { controller.tryAgainAnchor() } + .buttonStyle(.borderedProminent) + Button("Abbrechen", role: .cancel, action: onCancel) + .buttonStyle(.bordered) + } + + case .relocating: + hintCard( + icon: "arrow.triangle.2.circlepath", + text: "Position wird wiederhergestellt …", + showsProgress: true + ) + + case .relocationFailed: + actionCard( + title: "Position nicht gefunden", + message: "Die gespeicherte Position konnte nicht wiederhergestellt werden. Neu starten und den Tank neu platzieren?" + ) { + Button("Neu starten & platzieren", systemImage: "arrow.counterclockwise") { + controller.restart() + } + .buttonStyle(.borderedProminent) + Button("Weiter versuchen") { + controller.retryRelocation() + } + .buttonStyle(.bordered) + Button("Abbrechen", role: .cancel, action: onCancel) + .buttonStyle(.bordered) + } + + case .immersive: + if controller.isTankSelected { + TankManipulationPanel(controller: controller) + } else if controller.measurementPhase == .idle && controller.pipeRoutingPhase == .idle { + Text("Tank antippen zum Neu-Platzieren · Ziehen / 2 Finger / Pinch zum Anpassen") + .font(.caption) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: .capsule) + } + + default: + // initializingWorld / initializingNotPossible / userMovesAround übernimmt das + // Coaching-Overlay; immersive/worldNotInitialized brauchen keine Aktion. + EmptyView() + } + } + + // MARK: - Bausteine + + private func hintCard( + icon: String, + text: String, + showsProgress: Bool = false, + onCancel: (() -> Void)? = nil + ) -> some View { + HStack(spacing: 12) { + if showsProgress { + ProgressView() + } else { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(.tint) + } + Text(text) + .font(.subheadline) + Spacer(minLength: 0) + if let onCancel { + Button("Abbrechen", role: .cancel, action: onCancel) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding() + .background(.ultraThinMaterial, in: .rect(cornerRadius: 16)) + } + + private func actionCard( + title: String, + message: String, + @ViewBuilder buttons: () -> Buttons + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + VStack(spacing: 8) { + buttons() + } + .frame(maxWidth: .infinity) + } + .padding() + .background(.ultraThinMaterial, in: .rect(cornerRadius: 16)) + } +} diff --git a/GasTankAR/Views/TankAR/ARViewContainer.swift b/GasTankAR/Views/TankAR/ARViewContainer.swift new file mode 100644 index 0000000..2bba621 --- /dev/null +++ b/GasTankAR/Views/TankAR/ARViewContainer.swift @@ -0,0 +1,29 @@ +// +// ARViewContainer.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import SwiftUI +import RealityKit + +/// Dünner UIKit-Adapter: hostet nur die `ARView` und übergibt sie an den Controller. +/// Enthält bewusst keine Logik. +struct ARViewContainer: UIViewRepresentable { + let controller: ARSessionController + + func makeCoordinator() -> ARSessionController { controller } + + func makeUIView(context: Context) -> ARView { + let view = ARView(frame: .zero) + context.coordinator.attach(to: view) + return view + } + + func updateUIView(_ uiView: ARView, context: Context) {} + + static func dismantleUIView(_ uiView: ARView, coordinator: ARSessionController) { + MainActor.assumeIsolated { coordinator.detach() } + } +} diff --git a/GasTankAR/Views/TankAR/DirtTextureFactory.swift b/GasTankAR/Views/TankAR/DirtTextureFactory.swift new file mode 100644 index 0000000..0c7902e --- /dev/null +++ b/GasTankAR/Views/TankAR/DirtTextureFactory.swift @@ -0,0 +1,57 @@ +// +// DirtTextureFactory.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import RealityKit +import UIKit + +/// Erzeugt prozedural eine kachelbare Erd-/Dreck-Textur (ohne Bild-Asset) für die Grubenwand, +/// damit sichtbar wird, dass sich der Tank unter der Erde befindet. +@MainActor +enum DirtTextureFactory { + /// Einmalig erzeugte, wiederverwendete Textur. + static let texture: TextureResource? = makeTexture() + + private static func makeTexture() -> TextureResource? { + let size = CGSize(width: 256, height: 256) + let image = UIGraphicsImageRenderer(size: size).image { context in + let rect = CGRect(origin: .zero, size: size) + + // Erd-Grundton. + UIColor(red: 0.42, green: 0.30, blue: 0.18, alpha: 1).setFill() + context.fill(rect) + + // Größere Erdflecken / Steine für Struktur. + for _ in 0..<70 { + let diameter = CGFloat.random(in: 6...26) + let origin = CGPoint(x: .random(in: -10...size.width), y: .random(in: -10...size.height)) + let brightness = CGFloat.random(in: -0.12...0.12) + UIColor( + red: max(0, min(1, 0.42 + brightness)), + green: max(0, min(1, 0.30 + brightness)), + blue: max(0, min(1, 0.18 + brightness)), + alpha: 1 + ).setFill() + context.cgContext.fillEllipse(in: CGRect(x: origin.x, y: origin.y, width: diameter, height: diameter)) + } + + // Feine Körnung (helle + dunkle Sprenkel). + for _ in 0..<2000 { + let point = CGPoint(x: .random(in: 0.. + let end: SIMD3 + + /// Länge der Strecke in Metern. + var distance: Float { simd_distance(start, end) } + + /// Länge als lesbarer Text, z. B. „1,23 m". + var formattedDistance: String { + String(format: "%.2f m", distance) + } +} + +/// Ablaufphasen beim Anlegen einer Strecke. +enum MeasurementPhase: Equatable { + /// Kein Messvorgang aktiv. + case idle + /// Wartet auf den ersten Punkt. + case awaitingFirstPoint + /// Wartet auf den zweiten Punkt. + case awaitingSecondPoint + /// Beide Punkte gesetzt – wartet auf die Benennung. + case naming +} diff --git a/GasTankAR/Views/TankAR/MeasurementEntityFactory.swift b/GasTankAR/Views/TankAR/MeasurementEntityFactory.swift new file mode 100644 index 0000000..206444e --- /dev/null +++ b/GasTankAR/Views/TankAR/MeasurementEntityFactory.swift @@ -0,0 +1,98 @@ +// +// MeasurementEntityFactory.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import RealityKit +import UIKit + +/// Baut die AR-Visualisierung einer Strecke: zwei Endpunkt-Kugeln, die verbindende Linie +/// sowie ein Text-Label (Bezeichnung + Länge) am Mittelpunkt der Strecke. +/// +/// Enthält außerdem wiederverwendbare Bausteine für die *Live*-Vorschau während des Messens +/// (Cursor-Kugel, skalierbare Linie, aktualisierbares Label). +@MainActor +enum MeasurementEntityFactory { + /// Name des Label-Halters – wird zum Wiederfinden fürs Billboarding genutzt. + static let labelName = "MeasurementLabel" + + private static let color = UIColor.systemYellow + private static let pointRadius: Float = 0.012 + private static let lineThickness: Float = 0.006 + private static let labelHeight: Float = 0.06 + private static let labelYOffset: Float = 0.03 + + /// Kleine Kugel für einen (auch vorläufigen) Messpunkt. + static func makePoint(at position: SIMD3) -> ModelEntity { + let sphere = ModelEntity( + mesh: .generateSphere(radius: pointRadius), + materials: [UnlitMaterial(color: color)] + ) + sphere.position = position + return sphere + } + + /// Vollständige Strecken-Visualisierung (Punkte + Linie + Label). + static func makeMeasurement(_ measurement: Measurement) -> Entity { + let container = Entity() + container.name = "Measurement-\(measurement.id.uuidString)" + container.addChild(makePoint(at: measurement.start)) + container.addChild(makePoint(at: measurement.end)) + + let line = makeLineBase() + configureLine(line, from: measurement.start, to: measurement.end) + container.addChild(line) + + let label = makeLabelHolder() + label.position = labelPosition(from: measurement.start, to: measurement.end) + label.addChild(makeText("\(measurement.name) (\(measurement.formattedDistance))")) + container.addChild(label) + return container + } + + /// Basis-Linie der Länge 1 m (entlang +Z). Für die Live-Linie wird sie via ``configureLine`` skaliert/rotiert. + static func makeLineBase() -> ModelEntity { + ModelEntity( + mesh: .generateBox(size: SIMD3(lineThickness, lineThickness, 1)), + materials: [UnlitMaterial(color: color)] + ) + } + + /// Richtet eine per ``makeLineBase`` erzeugte Linie zwischen zwei Punkten aus (Länge über Z-Skalierung). + static func configureLine(_ line: Entity, from start: SIMD3, to end: SIMD3) { + let length = max(simd_distance(start, end), 0.0001) + line.transform = Transform( + scale: SIMD3(1, 1, length), + rotation: simd_quatf(from: SIMD3(0, 0, 1), to: normalize(end - start)), + translation: (start + end) / 2 + ) + } + + /// Leerer Label-Halter (Billboard-Anker). Der Text wird als zentriertes Kind eingehängt. + static func makeLabelHolder() -> Entity { + let holder = Entity() + holder.name = labelName + return holder + } + + /// Zentriertes Text-Entity für einen Label-Halter. + static func makeText(_ string: String) -> ModelEntity { + let mesh = MeshResource.generateText( + string, + extrusionDepth: 0.001, + font: .systemFont(ofSize: CGFloat(labelHeight), weight: .semibold) + ) + var material = UnlitMaterial(color: .white) + material.faceCulling = .none + let text = ModelEntity(mesh: mesh, materials: [material]) + text.position = -text.visualBounds(relativeTo: nil).center + return text + } + + /// Position des Labels (Streckenmitte, leicht angehoben). + static func labelPosition(from start: SIMD3, to end: SIMD3) -> SIMD3 { + (start + end) / 2 + SIMD3(0, labelYOffset, 0) + } +} diff --git a/GasTankAR/Views/TankAR/MeasurementListView.swift b/GasTankAR/Views/TankAR/MeasurementListView.swift new file mode 100644 index 0000000..2df343b --- /dev/null +++ b/GasTankAR/Views/TankAR/MeasurementListView.swift @@ -0,0 +1,59 @@ +// +// MeasurementListView.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import SwiftUI + +/// Von unten einschwebendes Sheet mit den angelegten Strecken. Ist keine vorhanden, +/// wird zum Hinzufügen aufgefordert. +struct MeasurementListView: View { + let controller: ARSessionController + /// Startet einen neuen Messvorgang (schließt das Sheet und lässt den Nutzer zwei Punkte wählen). + let onAddStrecke: () -> Void + + var body: some View { + NavigationStack { + Group { + if controller.measurements.isEmpty { + ContentUnavailableView { + Label("Keine Strecken", systemImage: "ruler") + } description: { + Text("Füge eine Strecke hinzu und wähle dafür zwei Punkte im Raum.") + } actions: { + Button("Strecke hinzufügen", systemImage: "plus", action: onAddStrecke) + .buttonStyle(.borderedProminent) + } + } else { + List { + ForEach(controller.measurements) { measurement in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(measurement.name) + .font(.headline) + Text(measurement.formattedDistance) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "ruler") + .foregroundStyle(.secondary) + } + } + .onDelete { controller.deleteMeasurements(at: $0) } + } + } + } + .navigationTitle("Strecken") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Hinzufügen", systemImage: "plus", action: onAddStrecke) + } + } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/GasTankAR/Views/TankAR/PhotoEncoder.swift b/GasTankAR/Views/TankAR/PhotoEncoder.swift new file mode 100644 index 0000000..2cf8053 --- /dev/null +++ b/GasTankAR/Views/TankAR/PhotoEncoder.swift @@ -0,0 +1,107 @@ +// +// PhotoEncoder.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import ImageIO +import UniformTypeIdentifiers +import CoreLocation +import UIKit + +/// Kodiert einen Snapshot als JPEG und bettet dabei EXIF-Aufnahmezeit sowie – falls vorhanden – +/// GPS-Metadaten direkt in die Bilddatei ein (ImageIO). So tragen auch exportierte Bilder ihren +/// Aufnahmeort mit sich. +enum PhotoEncoder { + /// Erzeugt JPEG-Daten mit eingebetteten Metadaten. Fällt bei Fehlern auf `UIImage.jpegData` zurück. + static func jpegData( + from image: UIImage, + coordinate: CLLocationCoordinate2D?, + date: Date, + compressionQuality: CGFloat = 0.9 + ) -> Data? { + guard let cgImage = image.cgImage else { + return image.jpegData(compressionQuality: compressionQuality) + } + + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output, UTType.jpeg.identifier as CFString, 1, nil + ) else { + return image.jpegData(compressionQuality: compressionQuality) + } + + var properties: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: compressionQuality, + kCGImagePropertyOrientation: CGImagePropertyOrientation(image.imageOrientation).rawValue, + kCGImagePropertyExifDictionary: [ + kCGImagePropertyExifDateTimeOriginal: Self.exifDateFormatter.string(from: date), + kCGImagePropertyExifDateTimeDigitized: Self.exifDateFormatter.string(from: date), + ], + ] + + if let coordinate { + properties[kCGImagePropertyGPSDictionary] = gpsProperties(for: coordinate, date: date) + } + + CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) else { + return image.jpegData(compressionQuality: compressionQuality) + } + return output as Data + } + + private static func gpsProperties(for coordinate: CLLocationCoordinate2D, date: Date) -> [CFString: Any] { + [ + kCGImagePropertyGPSLatitude: abs(coordinate.latitude), + kCGImagePropertyGPSLatitudeRef: coordinate.latitude >= 0 ? "N" : "S", + kCGImagePropertyGPSLongitude: abs(coordinate.longitude), + kCGImagePropertyGPSLongitudeRef: coordinate.longitude >= 0 ? "E" : "W", + kCGImagePropertyGPSDateStamp: Self.gpsDateFormatter.string(from: date), + kCGImagePropertyGPSTimeStamp: Self.gpsTimeFormatter.string(from: date), + ] + } + + /// EXIF-Zeitstempel in lokaler Zeit („yyyy:MM:dd HH:mm:ss"). + private static let exifDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" + return formatter + }() + + /// GPS-Datum in UTC („yyyy:MM:dd"). + private static let gpsDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyy:MM:dd" + return formatter + }() + + /// GPS-Uhrzeit in UTC („HH:mm:ss"). + private static let gpsTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "HH:mm:ss" + return formatter + }() +} + +private extension CGImagePropertyOrientation { + init(_ orientation: UIImage.Orientation) { + switch orientation { + case .up: self = .up + case .down: self = .down + case .left: self = .left + case .right: self = .right + case .upMirrored: self = .upMirrored + case .downMirrored: self = .downMirrored + case .leftMirrored: self = .leftMirrored + case .rightMirrored: self = .rightMirrored + @unknown default: self = .up + } + } +} diff --git a/GasTankAR/Views/TankAR/PipeRoute.swift b/GasTankAR/Views/TankAR/PipeRoute.swift new file mode 100644 index 0000000..d402e3b --- /dev/null +++ b/GasTankAR/Views/TankAR/PipeRoute.swift @@ -0,0 +1,49 @@ +// +// PipeRoute.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import Foundation +import simd + +/// Eine Rohrführung als geordnete Folge von Wegpunkten (Welt-Koordinaten), die zu einem +/// zusammenhängenden Pfad verbunden werden – visualisiert die geplante Rohrverlegung. +struct PipeRoute { + private(set) var waypoints: [SIMD3] = [] + + var count: Int { waypoints.count } + + /// Gesamtlänge des Pfads über alle Segmente in Metern. + var totalLength: Float { + guard waypoints.count > 1 else { return 0 } + return zip(waypoints, waypoints.dropFirst()) + .reduce(0) { $0 + simd_distance($1.0, $1.1) } + } + + var formattedLength: String { + String(format: "%.2f m", totalLength) + } + + mutating func add(_ waypoint: SIMD3) { + waypoints.append(waypoint) + } + + @discardableResult + mutating func removeLast() -> SIMD3? { + waypoints.isEmpty ? nil : waypoints.removeLast() + } + + mutating func clear() { + waypoints.removeAll() + } +} + +/// Ablaufphasen beim Erstellen einer Rohrführung. +enum PipeRoutingPhase: Equatable { + /// Kein Rohr-Routing aktiv. + case idle + /// Wegpunkte werden gesetzt. + case placing +} diff --git a/GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift b/GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift new file mode 100644 index 0000000..b1b5239 --- /dev/null +++ b/GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift @@ -0,0 +1,54 @@ +// +// PipeRouteEntityFactory.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import RealityKit +import UIKit + +/// Baut die AR-Visualisierung einer Rohrführung: Wegpunkt-Kugeln und die verbindenden +/// Segmente. Bewusst in einer markanten, naturfremden Farbe (Pink/Magenta) gehalten. +@MainActor +enum PipeRouteEntityFactory { + /// Markante, in der Natur kaum vorkommende Linien-/Punktfarbe. + private static let color = UIColor.systemPink + private static let waypointRadius: Float = 0.02 + private static let segmentThickness: Float = 0.012 + + /// Kugel für einen Wegpunkt. + static func makeWaypoint(at position: SIMD3) -> ModelEntity { + let sphere = ModelEntity( + mesh: .generateSphere(radius: waypointRadius), + materials: [UnlitMaterial(color: color)] + ) + sphere.position = position + return sphere + } + + /// Verbindungssegment zwischen zwei Wegpunkten. + static func makeSegment(from start: SIMD3, to end: SIMD3) -> ModelEntity { + let segment = makeSegmentBase() + configureSegment(segment, from: start, to: end) + return segment + } + + /// Basis-Segment der Länge 1 m (entlang +Z) – für die Live-Vorschau wiederverwendbar. + static func makeSegmentBase() -> ModelEntity { + ModelEntity( + mesh: .generateBox(size: SIMD3(segmentThickness, segmentThickness, 1)), + materials: [UnlitMaterial(color: color)] + ) + } + + /// Richtet ein Segment zwischen zwei Punkten aus (Länge über Z-Skalierung). + static func configureSegment(_ segment: Entity, from start: SIMD3, to end: SIMD3) { + let length = max(simd_distance(start, end), 0.0001) + segment.transform = Transform( + scale: SIMD3(1, 1, length), + rotation: simd_quatf(from: SIMD3(0, 0, 1), to: normalize(end - start)), + translation: (start + end) / 2 + ) + } +} diff --git a/GasTankAR/Views/TankAR/ReferenceImageProvider.swift b/GasTankAR/Views/TankAR/ReferenceImageProvider.swift new file mode 100644 index 0000000..2a07214 --- /dev/null +++ b/GasTankAR/Views/TankAR/ReferenceImageProvider.swift @@ -0,0 +1,56 @@ +// +// ReferenceImageProvider.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import ARKit +import UIKit + +/// Liefert die Referenzbilder für die Anker-Erkennung. Bevorzugt die AR-Ressourcengruppe +/// „ARMarkers"; fällt sonst auf einen programmatisch erzeugten Platzhalter-Marker zurück. +enum ReferenceImageProvider { + static func load() -> Set { + if let group = ARReferenceImage.referenceImages(inGroupNamed: "ARMarkers", bundle: .main) { + return group + } + if let placeholder = makePlaceholder() { + return [placeholder] + } + return [] + } + + private static func makePlaceholder() -> ARReferenceImage? { + let size = CGSize(width: 512, height: 512) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { context in + let cg = context.cgContext + UIColor.white.setFill() + cg.fill(CGRect(origin: .zero, size: size)) + + let palette: [UIColor] = [.black, .systemBlue, .systemRed, .systemGreen, .systemOrange, .systemPurple] + let cells = 8 + let cellSize = size.width / CGFloat(cells) + for row in 0.. Entity { + let root = Entity() + root.name = "SafetyZones" + for zone in zones { + root.addChild(makeRing(for: zone)) + } + return root + } + + private static func makeRing(for zone: SafetyZone) -> Entity { + let container = Entity() + container.name = "SafetyZone-\(zone.label)" + + let mesh = annulusMesh(radius: zone.radius, halfWidth: lineHalfWidth, segments: segments) + + var material = UnlitMaterial(color: zone.color.withAlphaComponent(CGFloat(opacity))) + material.blending = .transparent(opacity: .init(floatLiteral: opacity)) + // Beidseitig sichtbar – unabhängig von der Blickrichtung auf den Boden. + material.faceCulling = .none + + container.addChild(ModelEntity(mesh: mesh, materials: [material])) + + for angle in labelAngles { + container.addChild(makeLabel(for: zone, at: angle)) + } + return container + } + + /// Erzeugt eine flach auf dem Boden liegende Meter-Beschriftung, tangential zum Ring + /// ausgerichtet (Oberkante zeigt zur Mitte, damit sie von außen aufrecht lesbar ist). + private static func makeLabel(for zone: SafetyZone, at angle: Float) -> Entity { + let mesh = MeshResource.generateText( + zone.label, + extrusionDepth: 0.002, + font: .systemFont(ofSize: CGFloat(labelHeight), weight: .bold) + ) + + var material = UnlitMaterial(color: zone.color) + material.faceCulling = .none + + let text = ModelEntity(mesh: mesh, materials: [material]) + // Auf den eigenen Ursprung zentrieren. + let bounds = text.visualBounds(relativeTo: nil) + text.position = -bounds.center + + let holder = Entity() + holder.addChild(text) + holder.position = SIMD3(sin(angle) * zone.radius, 0.01, cos(angle) * zone.radius) + holder.orientation = simd_quatf(angle: angle, axis: SIMD3(0, 1, 0)) + * simd_quatf(angle: -.pi / 2, axis: SIMD3(1, 0, 0)) + return holder + } + + /// Erzeugt einen flachen Kreisring (Annulus) in der XZ-Ebene. + private static func annulusMesh(radius: Float, halfWidth: Float, segments: Int) -> MeshResource { + var positions: [SIMD3] = [] + var indices: [UInt32] = [] + let inner = max(0, radius - halfWidth) + let outer = radius + halfWidth + + for i in 0...segments { + let angle = Float(i) / Float(segments) * 2 * .pi + let cosA = cos(angle) + let sinA = sin(angle) + positions.append(SIMD3(inner * cosA, 0, inner * sinA)) + positions.append(SIMD3(outer * cosA, 0, outer * sinA)) + } + + for i in 0.. + + func make(withImages: Bool, initialWorldMap: ARWorldMap?) -> ARWorldTrackingConfiguration { + let config = ARWorldTrackingConfiguration() + config.planeDetection = [.horizontal] + + if useLiDAR { + if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) { + config.sceneReconstruction = .mesh + } + if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) { + config.frameSemantics.insert(.sceneDepth) + } + } else { + config.sceneReconstruction = [] + config.frameSemantics = [] + } + + // People Occlusion funktioniert auch ohne LiDAR (A12+): Personen zwischen Kamera + // und Tank verdecken das Objekt korrekt. (Beliebige Objekte wie Stühle brauchen LiDAR.) + if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) { + config.frameSemantics.insert(.personSegmentationWithDepth) + } + + if let initialWorldMap { + config.initialWorldMap = initialWorldMap + } + if withImages { + config.detectionImages = referenceImages + config.maximumNumberOfTrackedImages = 1 + } + return config + } +} diff --git a/GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift b/GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift new file mode 100644 index 0000000..ef685c3 --- /dev/null +++ b/GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift @@ -0,0 +1,63 @@ +// +// TankDepthFadeMaterial.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import RealityKit +import Metal + +/// Kapselt das Höhen-Fade-CustomMaterial für den unterirdischen Tank: +/// verblasst die Opacity anhand der Welt-Höhe (Kopf opak, vergrabener Teil transparent). +@MainActor +enum TankDepthFadeMaterial { + /// Fade-Band (Meter) unterhalb der Bodenkante, über das die Transparenz einsetzt. + static let band: Float = 0.15 + /// Minimale Opacity des vergrabenen Teils. + static let belowAlpha: Float = 0.3 + + private static let shader: CustomMaterial.SurfaceShader? = { + guard let library = MTLCreateSystemDefaultDevice()?.makeDefaultLibrary() else { return nil } + return CustomMaterial.SurfaceShader(named: "tankDepthFadeSurface", in: library) + }() + + /// Ersetzt die Materialien der Entity (rekursiv) durch das Fade-CustomMaterial. + static func apply(to entity: Entity) { + guard let shader else { return } + forEachModelEntity(entity) { modelEntity in + guard var component = modelEntity.model else { return } + component.materials = component.materials.map { material in + guard var custom = try? CustomMaterial(from: material, surfaceShader: shader) else { + return material + } + custom.blending = .transparent(opacity: 1.0) + custom.custom.value = SIMD4(0, band, belowAlpha, 0) + return custom + } + modelEntity.model = component + } + } + + /// Aktualisiert die Boden-Höhe (Welt-Y) in bereits angewandten Fade-Materialien. + static func updateGroundY(_ groundY: Float, on entity: Entity) { + forEachModelEntity(entity) { modelEntity in + guard var component = modelEntity.model else { return } + component.materials = component.materials.map { material in + guard var custom = material as? CustomMaterial else { return material } + custom.custom.value = SIMD4(groundY, band, belowAlpha, 0) + return custom + } + modelEntity.model = component + } + } + + private static func forEachModelEntity(_ entity: Entity, _ body: (ModelEntity) -> Void) { + if let modelEntity = entity as? ModelEntity { + body(modelEntity) + } + for child in entity.children { + forEachModelEntity(child, body) + } + } +} diff --git a/GasTankAR/Views/TankAR/TankEntityFactory.swift b/GasTankAR/Views/TankAR/TankEntityFactory.swift new file mode 100644 index 0000000..15db2ea --- /dev/null +++ b/GasTankAR/Views/TankAR/TankEntityFactory.swift @@ -0,0 +1,93 @@ +// +// TankEntityFactory.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import RealityKit +import UIKit + +/// Baut die darzustellende Tank-Entity (aus `GasTank.usdz`): lädt/cached das Modell, +/// richtet es je nach Aufstellungsart aus, erzeugt die Kollisionsform und – bei +/// unterirdischer Aufstellung – den Höhen-Transparenz-Effekt. +@MainActor +final class TankEntityFactory { + /// Name des USDZ-Assets im Bundle. + private static let modelName = "GasTank" + /// Anteil der Modellhöhe, der bei unterirdischer Aufstellung oben herausragt („Kopf"). + private static let belowGroundExposedFraction: Float = 0.25 + /// Zusätzlicher Abstand zwischen Tank-Umkreis und Grubenwand (in Metern) – großzügig, + /// damit die Grube nicht gequetscht wirkt. + private static let pitMargin: Float = 0.4 + /// Zusätzliche Tiefe der Grubensohle unter der Tankunterkante (in Metern). + private static let pitFloorClearance: Float = 0.1 + + /// Vorab geladenes Modell; wird pro Platzierung geklont. + private var loadedModel: Entity? + + /// Lädt das USDZ asynchron vor (blockiert den Main-Actor nicht). + func preload() { + guard loadedModel == nil else { return } + Task { @MainActor in + self.loadedModel = try? await Entity(named: Self.modelName) + } + } + + /// Erzeugt den manipulierbaren Container (mit Kollision fürs Gesten-Hit-Testing). + func makeContainer(for tank: Tank) -> ModelEntity { + let model = loadModelClone() + let bounds = model.visualBounds(relativeTo: nil) + let container = ModelEntity() + + switch tank.placement { + case .aboveGround: + model.position.y -= bounds.min.y // Unterkante auf die Fläche + + case .belowGround: + // Modell absenken, sodass nur der obere Teil („Kopf") herausragt … + let exposed = bounds.extents.y * Self.belowGroundExposedFraction + let buried = bounds.extents.y - exposed + model.position.y -= (bounds.max.y - exposed) + // … und den Boden „aufreißen": eine runde Grube ausheben, in welcher der Tank steht. + // Radius = Umkreis der Grundfläche (halbe Diagonale) + großzügiger Rand. + let footprintRadius = 0.5 * (bounds.extents.x * bounds.extents.x + bounds.extents.z * bounds.extents.z).squareRoot() + container.addChild(TankPitFactory.makePit( + radius: footprintRadius + Self.pitMargin, + depth: buried + Self.pitFloorClearance, + center: bounds.center + )) + } + + container.addChild(model) + + let colliderBounds = model.visualBounds(relativeTo: container) + container.collision = CollisionComponent(shapes: [ + ShapeResource + .generateBox(size: colliderBounds.extents) + .offsetBy(translation: colliderBounds.center) + ]) + + // Auswahl-Markierung (Eck-Winkel) – zunächst versteckt, wird bei Auswahl eingeblendet. + let highlight = TankHighlightFactory.makeHighlight(for: colliderBounds) + highlight.isEnabled = false + container.addChild(highlight) + + return container + } + + /// Liefert einen Klon des vorab geladenen Modells. Fällt auf synchrones Laden + /// bzw. – wenn das USDZ fehlt – auf einen einfachen Cube zurück. + private func loadModelClone() -> Entity { + if loadedModel == nil { + loadedModel = try? Entity.load(named: Self.modelName) + } + if let loadedModel { + return loadedModel.clone(recursive: true) + } + return ModelEntity( + mesh: .generateBox(size: 0.3), + materials: [SimpleMaterial(color: .white, isMetallic: false)] + ) + } +} diff --git a/GasTankAR/Views/TankAR/TankHighlightFactory.swift b/GasTankAR/Views/TankAR/TankHighlightFactory.swift new file mode 100644 index 0000000..36a5bb8 --- /dev/null +++ b/GasTankAR/Views/TankAR/TankHighlightFactory.swift @@ -0,0 +1,69 @@ +// +// TankHighlightFactory.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import RealityKit +import UIKit + +/// Baut die Auswahl-Markierung um den Tank: ein (ansonsten unsichtbarer) Quader, dessen +/// acht Ecken mit kräftig gefärbten Winkeln hervorgehoben werden – deutlich sichtbar, +/// ohne den Tank selbst zu verdecken. +@MainActor +enum TankHighlightFactory { + /// Name der Markierungs-Entity (zum Ein-/Ausblenden bei Auswahl). + static let name = "TankSelectionHighlight" + + private static let color = UIColor.systemTeal + + /// Erzeugt die Eck-Winkel passend zur Bounding-Box (im Koordinatensystem des Containers). + static func makeHighlight(for bounds: BoundingBox) -> Entity { + let root = Entity() + root.name = name + + let extents = bounds.extents + let smallestEdge = min(extents.x, min(extents.y, extents.z)) + let armLength = max(0.05, smallestEdge * 0.3) + let thickness = max(0.008, smallestEdge * 0.05) + + var material = UnlitMaterial(color: color) + material.faceCulling = .none + + for x in [bounds.min.x, bounds.max.x] { + for y in [bounds.min.y, bounds.max.y] { + for z in [bounds.min.z, bounds.max.z] { + let corner = SIMD3(x, y, z) + // Vorzeichen: Winkel zeigt von der Ecke nach innen (zur Boxmitte). + let signX: Float = (x == bounds.min.x) ? 1 : -1 + let signY: Float = (y == bounds.min.y) ? 1 : -1 + let signZ: Float = (z == bounds.min.z) ? 1 : -1 + + root.addChild(beam( + size: SIMD3(armLength, thickness, thickness), + center: corner + SIMD3(signX * armLength / 2, 0, 0), + material: material + )) + root.addChild(beam( + size: SIMD3(thickness, armLength, thickness), + center: corner + SIMD3(0, signY * armLength / 2, 0), + material: material + )) + root.addChild(beam( + size: SIMD3(thickness, thickness, armLength), + center: corner + SIMD3(0, 0, signZ * armLength / 2), + material: material + )) + } + } + } + return root + } + + private static func beam(size: SIMD3, center: SIMD3, material: UnlitMaterial) -> ModelEntity { + let beam = ModelEntity(mesh: .generateBox(size: size), materials: [material]) + beam.position = center + return beam + } +} diff --git a/GasTankAR/Views/TankAR/TankManipulationPanel.swift b/GasTankAR/Views/TankAR/TankManipulationPanel.swift new file mode 100644 index 0000000..5115fc1 --- /dev/null +++ b/GasTankAR/Views/TankAR/TankManipulationPanel.swift @@ -0,0 +1,144 @@ +// +// TankManipulationPanel.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import SwiftUI + +/// Steuer-Panel für den ausgewählten Tank: Höhe (↑↓), Verschieben auf dem Boden +/// (vor/zurück/links/rechts) und Drehen (⟲⟳). Erscheint von unten; Pinch bleibt möglich. +/// Buttons unterstützen Gedrückt-halten für kontinuierliche Anpassung. +struct TankManipulationPanel: View { + let controller: ARSessionController + + var body: some View { + VStack(spacing: 14) { + HStack { + Text("Tank anpassen") + .font(.headline) + Spacer() + Button { + controller.deselectTank() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Schließen") + } + + HStack(alignment: .center, spacing: 24) { + heightGroup + moveGroup + rotateGroup + } + + Button("Neu platzieren", systemImage: "arrow.up.and.down.and.arrow.left.and.right") { + controller.reposition() + } + .buttonStyle(.bordered) + } + .padding() + .background(.ultraThinMaterial, in: .rect(cornerRadius: 16)) + } + + // MARK: - Gruppen + + private var heightGroup: some View { + controlGroup(title: "Höhe") { + VStack(spacing: 8) { + holdButton("chevron.up", label: "Höher") { controller.adjustTankHeight(up: true) } + holdButton("chevron.down", label: "Tiefer") { controller.adjustTankHeight(up: false) } + } + } + } + + private var moveGroup: some View { + controlGroup(title: "Verschieben") { + VStack(spacing: 8) { + holdButton("arrow.up", label: "Vorwärts") { controller.moveTank(forward: true) } + HStack(spacing: 8) { + holdButton("arrow.left", label: "Nach links") { controller.moveTank(left: true) } + holdButton("arrow.right", label: "Nach rechts") { controller.moveTank(left: false) } + } + holdButton("arrow.down", label: "Rückwärts") { controller.moveTank(forward: false) } + } + } + } + + private var rotateGroup: some View { + controlGroup(title: "Drehen") { + HStack(spacing: 8) { + holdButton("rotate.left", label: "Gegen den Uhrzeigersinn") { controller.rotateTank(clockwise: false) } + holdButton("rotate.right", label: "Im Uhrzeigersinn") { controller.rotateTank(clockwise: true) } + } + } + } + + private func controlGroup(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(spacing: 8) { + content() + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func holdButton(_ systemImage: String, label: String, action: @escaping () -> Void) -> some View { + HoldRepeatButton( + systemImage: systemImage, + accessibilityLabel: label, + action: action, + onRelease: { controller.commitTankEdit() } + ) + } +} + +/// Runder Button, der bei Gedrückt-halten die Aktion wiederholt (Tastenwiederholung). +/// Einzel-Tap = ein Schritt; Halten = fortlaufend. Beim Loslassen wird `onRelease` aufgerufen. +private struct HoldRepeatButton: View { + let systemImage: String + let accessibilityLabel: String + let action: () -> Void + let onRelease: () -> Void + + @State private var repeatTask: Task? + + private static let initialDelay: Duration = .milliseconds(350) + private static let repeatInterval: Duration = .milliseconds(60) + + var body: some View { + Image(systemName: systemImage) + .font(.title3) + .frame(width: 48, height: 48) + .background(.ultraThinMaterial, in: .circle) + .contentShape(.circle) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in if repeatTask == nil { start() } } + .onEnded { _ in stop() } + ) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) + } + + private func start() { + action() // sofortiger erster Schritt (Einzel-Tap) + repeatTask = Task { @MainActor in + try? await Task.sleep(for: Self.initialDelay) + while !Task.isCancelled { + action() + try? await Task.sleep(for: Self.repeatInterval) + } + } + } + + private func stop() { + repeatTask?.cancel() + repeatTask = nil + onRelease() + } +} diff --git a/GasTankAR/Views/TankAR/TankPitFactory.swift b/GasTankAR/Views/TankAR/TankPitFactory.swift new file mode 100644 index 0000000..bdb3abc --- /dev/null +++ b/GasTankAR/Views/TankAR/TankPitFactory.swift @@ -0,0 +1,211 @@ +// +// TankPitFactory.swift +// GasTankAR +// +// Created by Dennis Nemec on 28.08.26. +// + +import RealityKit +import UIKit + +/// Baut die „ausgehobene Grube" für einen unterirdischen Tank als **Zylinder**: Der Boden wird +/// mit einer kreisrunden Occlusion-Maske „aufgerissen" und darunter ein runder Schacht +/// ausgehoben (sichtbarer Wandmantel + Sohle), in dem der Tank steht. +/// +/// Aufbau der zurückgegebenen Entity (lokaler Ursprung = Bodenkante, y = 0): +/// - Wandmantel + Sohle reichen von y = 0 bis y = -`depth`. +/// - Eine kreisrunde Occlusion-Maske in der Bodenebene verdeckt alles Vergrabene außerhalb +/// der Öffnung, sodass der Eindruck eines echten Lochs im Boden entsteht. +@MainActor +enum TankPitFactory { + /// Anzahl der Segmente pro Kreis (höher = runder). + private static let segments = 48 + /// Radius der kreisförmigen Boden-Occlusion-Maske (muss die Öffnung deutlich überragen). + private static let maskOuterRadius: Float = 20 + /// Die Maske liegt minimal unter der Bodenkante, damit sie nicht mit bodennahen + /// Overlays (z. B. den Sicherheitsabstands-Ringen bei y = 0) um die Tiefe konkurriert. + private static let maskYOffset: Float = -0.005 + /// Breite der hervorgehobenen Rand-Ringe. + private static let rimBand: Float = 0.03 + + /// Farbe des Wandmantels (heller, damit sich die Grube klar vom Boden abhebt). + private static let wallColor = UIColor(red: 0.62, green: 0.47, blue: 0.31, alpha: 1) + /// Farbe der Grubensohle (dunkler als die Wände – erzeugt Tiefeneindruck). + private static let floorColor = UIColor(red: 0.24, green: 0.18, blue: 0.12, alpha: 1) + /// Farbe der hervorgehobenen Rand-Ringe. + private static let rimColor = UIColor.systemYellow + + /// Erzeugt die zylindrische Grube. + /// - Parameters: + /// - radius: Radius der Grube (x/z) in Metern. + /// - depth: Tiefe der Grube (y) in Metern. + /// - center: horizontaler Mittelpunkt relativ zum Container (y wird ignoriert). + static func makePit(radius: Float, depth: Float, center: SIMD3) -> Entity { + let pit = Entity() + pit.name = "TankPit" + pit.position = SIMD3(center.x, 0, center.z) + + // Sichtbarer Wandmantel mit Erd-/Dreck-Textur … + pit.addChild(wallModel(radius: radius, depth: depth)) + // … runde Sohle (dunkler) … + pit.addChild(model(mesh: floorMesh(radius: radius, depth: depth), color: floorColor)) + // … hervorgehobene Rand-Ringe oben und unten … + pit.addChild(model(mesh: ringMesh(radius: radius, y: 0), color: rimColor)) + pit.addChild(model(mesh: ringMesh(radius: radius, y: -depth), color: rimColor)) + + // … und die kreisrunde Occlusion-Maske mit Loch über der Öffnung. + let mask = ModelEntity( + mesh: maskMesh(holeRadius: radius, outerRadius: maskOuterRadius), + materials: [OcclusionMaterial()] + ) + mask.position.y = maskYOffset + pit.addChild(mask) + + return pit + } + + // MARK: - Bausteine + + private static func model(mesh: MeshResource, color: UIColor) -> ModelEntity { + var material = UnlitMaterial(color: color) + material.faceCulling = .none // beidseitig sichtbar (Blick von außen und in die Grube) + return ModelEntity(mesh: mesh, materials: [material]) + } + + /// Wandmantel mit gekachelter Erd-Textur (Fallback: einfarbig). + private static func wallModel(radius: Float, depth: Float) -> ModelEntity { + var material = UnlitMaterial(color: wallColor) + if let texture = DirtTextureFactory.texture { + material.color = .init(tint: .white, texture: .init(texture)) + } + material.faceCulling = .none + return ModelEntity(mesh: wallMesh(radius: radius, depth: depth), materials: [material]) + } + + /// Kreispunkte in der XZ-Ebene auf gegebener Höhe. + private static func circle(radius: Float, y: Float) -> [SIMD3] { + (0.. MeshResource { + let top = circle(radius: radius, y: 0) + let bottom = circle(radius: radius, y: -depth) + + // Kachelgröße ~0,5 m: horizontal über den Umfang, vertikal über die Tiefe. + let tileSize: Float = 0.5 + let uRepeat = (2 * .pi * radius) / tileSize + let vMax = depth / tileSize + + var builder = TriangleMeshBuilder() + for i in 0.. MeshResource { + let rim = circle(radius: radius, y: -depth) + let center = SIMD3(0, -depth, 0) + + var builder = TriangleMeshBuilder() + for i in 0.. MeshResource { + let inner = circle(radius: max(0, radius - rimBand), y: y) + let outer = circle(radius: radius + rimBand, y: y) + + var builder = TriangleMeshBuilder() + for i in 0.. MeshResource { + let inner = circle(radius: holeRadius, y: 0) + let outer = circle(radius: outerRadius, y: 0) + + var builder = TriangleMeshBuilder() + for i in 0..] = [] + private var indices: [UInt32] = [] + private var uvs: [SIMD2] = [] + + /// Viereck mit UV-Koordinaten (für texturierte Flächen). + mutating func addQuad( + _ a: SIMD3, _ b: SIMD3, _ c: SIMD3, _ d: SIMD3, + uv: (SIMD2, SIMD2, SIMD2, SIMD2), + doubleSided: Bool = false + ) { + let base = UInt32(positions.count) + positions.append(contentsOf: [a, b, c, d]) + uvs.append(contentsOf: [uv.0, uv.1, uv.2, uv.3]) + indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3]) + if doubleSided { + indices.append(contentsOf: [base, base + 2, base + 1, base, base + 3, base + 2]) + } + } + + mutating func addTriangle( + _ a: SIMD3, _ b: SIMD3, _ c: SIMD3, + doubleSided: Bool = false + ) { + let base = UInt32(positions.count) + positions.append(contentsOf: [a, b, c]) + indices.append(contentsOf: [base, base + 1, base + 2]) + if doubleSided { + indices.append(contentsOf: [base, base + 2, base + 1]) + } + } + + mutating func addQuad( + _ a: SIMD3, _ b: SIMD3, _ c: SIMD3, _ d: SIMD3, + doubleSided: Bool = false + ) { + let base = UInt32(positions.count) + positions.append(contentsOf: [a, b, c, d]) + indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3]) + if doubleSided { + indices.append(contentsOf: [base, base + 2, base + 1, base, base + 3, base + 2]) + } + } + + func makeMesh(name: String) -> MeshResource { + var descriptor = MeshDescriptor(name: name) + descriptor.positions = MeshBuffers.Positions(positions) + descriptor.primitives = .triangles(indices) + if uvs.count == positions.count { + descriptor.textureCoordinates = MeshBuffers.TextureCoordinates(uvs) + } + return (try? MeshResource.generate(from: [descriptor])) ?? .generateBox(size: 0.01) + } +} diff --git a/GasTankAR/Views/TankAR/TankShaders.metal b/GasTankAR/Views/TankAR/TankShaders.metal new file mode 100644 index 0000000..78d426f --- /dev/null +++ b/GasTankAR/Views/TankAR/TankShaders.metal @@ -0,0 +1,56 @@ +// +// TankShaders.metal +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +#include +#include + +using namespace metal; + +// Sampler für die durchgereichten Texturen des Originalmaterials. +constexpr sampler textureSampler(address::clamp_to_edge, filter::bicubic); + +/// Surface-Shader für den unterirdischen Tank: reicht Basisfarbe/Roughness/Metallic +/// des Originalmaterials durch und verblasst die Opacity anhand der Welt-Höhe. +/// +/// custom_parameter(): +/// x = Boden-Höhe (Welt-Y) +/// y = Fade-Band (Meter, über die die Transparenz einsetzt) +/// z = minimale Opacity des vergrabenen Teils +[[visible]] +void tankDepthFadeSurface(realitykit::surface_parameters params) +{ + auto tex = params.textures(); + auto constants = params.material_constants(); + + float2 uv = params.geometry().uv0(); + // Modelle aus USDZ nutzen eine gespiegelte Y-UV. + uv.y = 1.0 - uv.y; + + // Basisfarbe = Tint * Textur. + half3 tint = (half3)constants.base_color_tint(); + half3 color = (half3)tex.base_color().sample(textureSampler, uv).rgb; + color *= tint; + params.surface().set_base_color(color); + + // Roughness / Metallic durchreichen (wie PhysicallyBasedMaterial). + half roughness = tex.roughness().sample(textureSampler, uv).r * constants.roughness_scale(); + params.surface().set_roughness(roughness); + + half metallic = tex.metallic().sample(textureSampler, uv).r * constants.metallic_scale(); + params.surface().set_metallic(metallic); + + // Höhenbasierter Alpha-Fade. + float3 worldPosition = params.geometry().world_position(); + float4 custom = params.uniforms().custom_parameter(); + float groundY = custom.x; + float band = max(custom.y, 1e-3); + float belowAlpha = custom.z; + + float t = clamp((groundY - worldPosition.y) / band, 0.0, 1.0); + float alpha = mix(1.0, belowAlpha, t); + params.surface().set_opacity(half(alpha)); +} diff --git a/GasTankAR/Views/TankAR/WorldMapArchiver.swift b/GasTankAR/Views/TankAR/WorldMapArchiver.swift new file mode 100644 index 0000000..1bb1721 --- /dev/null +++ b/GasTankAR/Views/TankAR/WorldMapArchiver.swift @@ -0,0 +1,20 @@ +// +// WorldMapArchiver.swift +// GasTankAR +// +// Created by Dennis Nemec on 26.08.26. +// + +import ARKit + +/// Kapselt das (De-)Serialisieren einer `ARWorldMap` via `NSKeyedArchiver`. +/// Bewusst thread-neutral gehalten – das Archivieren sollte off-main erfolgen (teuer). +enum WorldMapArchiver { + static func archive(_ worldMap: ARWorldMap) -> Data? { + try? NSKeyedArchiver.archivedData(withRootObject: worldMap, requiringSecureCoding: true) + } + + static func unarchive(_ data: Data) -> ARWorldMap? { + try? NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data) + } +} diff --git a/GasTankAR/Views/TankEditor.swift b/GasTankAR/Views/TankEditor.swift new file mode 100644 index 0000000..8d0fb28 --- /dev/null +++ b/GasTankAR/Views/TankEditor.swift @@ -0,0 +1,102 @@ +// +// TankEditor.swift +// GasTankAR +// +// Created by Dennis Nemec on 25.08.26. +// + +import SwiftUI + +/// Wiederverwendbare Auswahl für die Tank-Eigenschaften (Größe, Farbe, +/// Aufstellungsart und halbtransparente Darstellung). +struct TankEditor: View { + @Binding var tank: Tank + + var body: some View { + Picker("Größe", selection: $tank.size) { + ForEach(TankSize.allCases) { size in + Text(size.displayName).tag(size) + } + } + + Picker("Farbe", selection: $tank.color) { + ForEach(TankColor.allCases) { color in + Text(color.displayName).tag(color) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Aufstellungsart") + + HStack(spacing: 12) { + ForEach(TankPlacement.allCases) { placement in + PlacementOption( + placement: placement, + isSelected: tank.placement == placement + ) { + tank.placement = placement + } + } + } + } + .padding(.vertical, 4) + + Toggle("Halbtransparent (nur Deckel)", isOn: $tank.showLidOnly) + .disabled(tank.placement != .belowGround) + .onChange(of: tank.placement) { _, newPlacement in + // Option nur für unterirdische Tanks sinnvoll – sonst zurücksetzen. + if newPlacement != .belowGround { + tank.showLidOnly = false + } + } + } +} + +/// Auswahlkachel für eine Aufstellungsart mit Icon und umrahmter Auswahl. +private struct PlacementOption: View { + let placement: TankPlacement + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Image(placement.imageName) + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundStyle(Color.accentColor) + .frame(height: 72) + .scaleEffect(1.25) + .padding(8) + + Text(placement.displayName) + .font(.caption) + .foregroundStyle(isSelected ? .primary : .secondary) + } + .frame(maxWidth: .infinity) + .padding(8) + .background( + (isSelected ? Color.accentColor.opacity(0.12) : Color.clear), + in: .rect(cornerRadius: 12) + ) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke( + isSelected ? Color.accentColor : Color.secondary.opacity(0.4), + lineWidth: isSelected ? 2 : 1 + ) + } + } + .buttonStyle(.plain) + } +} + +#Preview { + @Previewable @State var tank = Tank() + return Form { + Section("Tankauswahl") { + TankEditor(tank: $tank) + } + } +} diff --git a/GasTankARTests/ARFlowStateMachineTests.swift b/GasTankARTests/ARFlowStateMachineTests.swift new file mode 100644 index 0000000..8859c35 --- /dev/null +++ b/GasTankARTests/ARFlowStateMachineTests.swift @@ -0,0 +1,191 @@ +// +// ARFlowStateMachineTests.swift +// GasTankARTests +// +// Created by Dennis Nemec on 26.08.26. +// + +import Testing +@testable import GasTankAR + +struct ARFlowStateMachineTests { + + // MARK: - Start & Initialisierung + + @Test func startsInWorldNotInitializedAndRunsConfiguration() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + let commands = machine.reduce(.started) + + #expect(machine.state == .worldNotInitialized) + #expect(commands.contains(.runConfiguration(useImages: false, useInitialMap: true, resetTracking: false))) + } + + @Test func initializingTracking() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingInitializing) + #expect(machine.state == .initializingWorld) + } + + // MARK: - Scene erfolgreich initialisiert → Location wählen / wiederherstellen + + @Test func normalTrackingWithoutStoredTransformGoesToSelectLocation() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingNormal) + #expect(machine.state == .selectLocation) + #expect(machine.hasPlacedTank == false) + } + + @Test func normalTrackingWithStoredTransformRestoresTank() { + var machine = ARFlowStateMachine(hasStoredTransform: true) + _ = machine.reduce(.started) + let commands = machine.reduce(.trackingNormal) + #expect(machine.state == .immersive) + #expect(machine.hasPlacedTank == true) + #expect(commands.contains(.placeTank(.storedTransform))) + } + + // MARK: - Platzieren + + @Test func captureFromSelectLocationPlacesTankAndPersists() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingNormal) // → selectLocation + let commands = machine.reduce(.captureConfirmed) + + #expect(machine.state == .immersive) + #expect(machine.hasPlacedTank == true) + #expect(commands.contains(.placeTank(.screenCenter))) + #expect(commands.contains(.persistWorldMap)) + #expect(commands.contains(.persistTankTransform)) + } + + @Test func captureIsIgnoredOutsideSelectLocation() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) // worldNotInitialized + let commands = machine.reduce(.captureConfirmed) + #expect(commands.isEmpty) + #expect(machine.state == .worldNotInitialized) + } + + // MARK: - Eskalation bei zu wenigen Merkmalen + + @Test func insufficientFeaturesEscalatesToShowOptions() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + + let first = machine.reduce(.trackingInsufficientFeatures) + #expect(machine.state == .initializingNotPossible) + #expect(first.contains(.startMoveAroundTimer)) + + _ = machine.reduce(.moveAroundTimerFired) + #expect(machine.state == .userMovesAround) + + _ = machine.reduce(.showOptionsTimerFired) + #expect(machine.state == .showOptions) + } + + @Test func timersDoNothingInWrongState() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + // Kein initializingNotPossible aktiv → moveAroundTimer ignoriert. + let commands = machine.reduce(.moveAroundTimerFired) + #expect(commands.isEmpty) + #expect(machine.state == .worldNotInitialized) + } + + // MARK: - Anker-Erkennung (Marker) + + @Test func placeAnchorMarkerStartsImageDetection() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingNormal) // selectLocation + let commands = machine.reduce(.placeAnchorMarkerTapped) + + #expect(machine.state == .anchorDetection) + #expect(commands.contains(.runConfiguration(useImages: true, useInitialMap: false, resetTracking: false))) + #expect(commands.contains(.startAnchorDetectionTimer)) + } + + @Test func anchorDetectionTimeoutAsksToCancel() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingNormal) + _ = machine.reduce(.placeAnchorMarkerTapped) + _ = machine.reduce(.anchorDetectionTimerFired) + #expect(machine.state == .askToCancel) + } + + @Test func imageAnchorDetectedPlacesTank() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingNormal) + _ = machine.reduce(.placeAnchorMarkerTapped) + let commands = machine.reduce(.imageAnchorDetected) + + #expect(machine.state == .immersive) + #expect(machine.hasPlacedTank == true) + #expect(commands.contains(.placeTank(.imageAnchor))) + } + + @Test func imageAnchorIgnoredWhenNotDetecting() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingNormal) // selectLocation + let commands = machine.reduce(.imageAnchorDetected) + #expect(commands.isEmpty) + #expect(machine.state == .selectLocation) + } + + // MARK: - Relokalisierung + + @Test func relocalizingStartsTimerAndTimeoutFails() { + var machine = ARFlowStateMachine(hasStoredTransform: true) + _ = machine.reduce(.started) + let relocating = machine.reduce(.trackingRelocalizing) + #expect(machine.state == .relocating) + #expect(relocating.contains(.startRelocationTimer)) + + _ = machine.reduce(.relocationTimerFired) + #expect(machine.state == .relocationFailed) + } + + @Test func normalTrackingWhilePlacedGoesImmersive() { + var machine = ARFlowStateMachine(hasStoredTransform: false) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingNormal) + _ = machine.reduce(.captureConfirmed) // placed → immersive + _ = machine.reduce(.trackingRelocalizing) + #expect(machine.state == .relocating) + _ = machine.reduce(.trackingNormal) + #expect(machine.state == .immersive) + } + + // MARK: - Neu platzieren / Neustart + + @Test func repositionClearsTankAndReturnsToSelectLocation() { + var machine = ARFlowStateMachine(hasStoredTransform: true) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingNormal) // restored → immersive + let commands = machine.reduce(.repositionTapped) + + #expect(machine.state == .selectLocation) + #expect(machine.hasPlacedTank == false) + #expect(machine.hasStoredTransform == false) + #expect(commands.contains(.removeTank)) + #expect(commands.contains(.clearStoredTransform)) + } + + @Test func restartResetsTracking() { + var machine = ARFlowStateMachine(hasStoredTransform: true) + _ = machine.reduce(.started) + _ = machine.reduce(.trackingRelocalizing) + _ = machine.reduce(.relocationTimerFired) // relocationFailed + let commands = machine.reduce(.restartTapped) + + #expect(machine.state == .worldNotInitialized) + #expect(machine.hasStoredTransform == false) + #expect(commands.contains(.runConfiguration(useImages: false, useInitialMap: false, resetTracking: true))) + } +} diff --git a/GasTankARTests/DocuframeTests.swift b/GasTankARTests/DocuframeTests.swift new file mode 100644 index 0000000..9dfb217 --- /dev/null +++ b/GasTankARTests/DocuframeTests.swift @@ -0,0 +1,218 @@ +// +// DocuframeTests.swift +// GasTankARTests +// +// Created by Dennis Nemec on 28.08.26. +// + +import Testing +import Foundation +import SwiftData +@testable import GasTankAR + +// MARK: - Test-Doubles + +/// Konfigurierbarer Mock des ``DocuframeService`` (Actor → threadsicher & Sendable), +/// der Aufrufe zählt und wahlweise einen Fehler wirft. +private actor MockDocuframeService: DocuframeService { + enum Behavior: Sendable { + case success + case failure(DocuframeError) + } + + let behavior: Behavior + private(set) var uploadCallCount = 0 + private(set) var sendEmailCallCount = 0 + private(set) var lastPhotoCount = 0 + private(set) var lastLeadID: String? + + init(behavior: Behavior = .success) { + self.behavior = behavior + } + + func uploadPhotos(_ photos: [DocuframePhoto], projectLeadID: String) async throws { + uploadCallCount += 1 + record(photos: photos, leadID: projectLeadID) + try applyBehavior() + } + + func sendEmail(_ photos: [DocuframePhoto], projectLeadID: String) async throws { + sendEmailCallCount += 1 + record(photos: photos, leadID: projectLeadID) + try applyBehavior() + } + + private func record(photos: [DocuframePhoto], leadID: String) { + lastPhotoCount = photos.count + lastLeadID = leadID + } + + private func applyBehavior() throws { + if case .failure(let error) = behavior { throw error } + } +} + +/// Hält Container und Fotos für die Dauer eines Tests am Leben. +@MainActor +private struct TestEnvironment { + let container: ModelContainer + let photos: [ProjectPhoto] +} + +@MainActor +private func makeEnvironment(photoCount: Int) throws -> TestEnvironment { + let container = try ModelContainer( + for: Project.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + let context = container.mainContext + let project = Project(leadID: "LEAD-1", customerName: "Test", creationDate: Date(timeIntervalSince1970: 0)) + context.insert(project) + + var photos: [ProjectPhoto] = [] + for index in 0..