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..