Initial commit for first version of project

This commit is contained in:
Dennis Nemec
2026-09-02 20:54:36 +02:00
parent 3d9f1ab6bc
commit 992bb32025
56 changed files with 5139 additions and 31 deletions

BIN
GasTank.usdz Normal file

Binary file not shown.

View File

@ -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",

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "49F4A6D3-C1CC-44A3-AC2F-F7F6C434BF73"
type = "1"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "74309E53-14CD-4936-BF18-C77CB66EA2E1"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "GasTankAR/ContentView.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "20"
endingLineNumber = "20"
landmarkName = "unknown"
landmarkType = "0">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View File

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

View File

@ -0,0 +1,20 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

View File

@ -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
}
}

View File

@ -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<Float>(0.2, 0.2)))
anchor.addChild(model)
// Add the horizontal plane anchor to the scene
content.add(anchor)
content.camera = .spatialTracking
ProjectListView()
.modelContainer(for: Project.self)
}
.edgesIgnoringSafeArea(.all)
}
}
#Preview {
ContentView()
ProjectListView()
.modelContainer(PreviewSupport.container)
}

BIN
GasTankAR/GasTank.usdz Normal file

Binary file not shown.

View File

@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleLocalizations</key>
<array>
<string>de</string>
<string>en</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)"
}
}

View File

@ -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..<count {
let leadNumber: Int = 1001 + index
let leadID: String = "LEAD-" + String(leadNumber)
let offset: TimeInterval = TimeInterval(-index) * 86_400
let project = Project(
leadID: leadID,
customerName: names[index % names.count],
creationDate: Date.now.addingTimeInterval(offset),
tank: Tank()
)
projects.append(project)
}
return projects
}
}
#endif

View File

@ -0,0 +1,71 @@
//
// DocuframeService.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import Foundation
/// Foto-Übergabeobjekt für DOCUframe-Vorgänge – bewusst unabhängig von der
/// Persistenzschicht (SwiftData), damit die Domäne keine Speicher-Details kennt.
struct DocuframePhoto: Sendable {
let imageData: Data
let creationDate: Date
let latitude: Double?
let longitude: Double?
}
/// Fehler bei DOCUframe-Vorgängen.
enum DocuframeError: LocalizedError, Equatable {
case noPhotosSelected
/// Der Server hat mit einem 4xx-Status geantwortet (fehlerhafte Anfrage).
case clientError(statusCode: Int)
/// Der Server hat mit einem 5xx-Status geantwortet (Serverproblem).
case serverError(statusCode: Int)
/// Netzwerk-/Transportfehler (z. B. keine Verbindung).
case transport(String)
var errorDescription: String? {
switch self {
case .noPhotosSelected:
"Es wurden keine Fotos ausgewählt."
case .clientError(let statusCode):
"Die Anfrage wurde von DOCUframe abgelehnt (Fehler \(statusCode)). Bitte prüfe die Daten und versuche es erneut."
case .serverError(let statusCode):
"DOCUframe ist derzeit nicht erreichbar (Serverfehler \(statusCode)). Bitte versuche es später erneut."
case .transport(let message):
message
}
}
/// Bildet einen HTTP-Statuscode auf den passenden Fehler ab – `nil` bei Erfolg (2xx/3xx).
static func from(statusCode: Int) -> 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
}

View File

@ -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
}
}
}

View File

@ -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<Bool> {
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)
}

View File

@ -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<PersistentIdentifier> = []
/// 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
}
}

View File

@ -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()) {}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
)
)
}
}

View File

@ -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())
}

View File

@ -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()
}

View File

@ -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 : [])
}
}

View File

@ -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<Bool> {
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)
}
}
}
}

View File

@ -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 []
}
}

View File

@ -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()
}
}

View File

@ -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<Float>?
@ObservationIgnored private var pendingMeasurementEnd: SIMD3<Float>?
/// 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<Void, Never>?
@ObservationIgnored private var showOptionsTask: Task<Void, Never>?
@ObservationIgnored private var anchorDetectionTask: Task<Void, Never>?
@ObservationIgnored private var relocationTask: Task<Void, Never>?
@ObservationIgnored private var mappingPollTask: Task<Void, Never>?
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<Float>(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<Float>(-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<Float>? {
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<Float>(column.x, column.y, column.z)
}
private func addMeasurementPreviewPoint(at position: SIMD3<Float>) {
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<Void, Never> {
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
}
}

View File

@ -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<Buttons: View>(
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))
}
}

View File

@ -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() }
}
}

View File

@ -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..<size.width), y: .random(in: 0..<size.height))
let diameter = CGFloat.random(in: 0.5...2.5)
let isDark = Bool.random()
let color = isDark
? UIColor(white: 0, alpha: .random(in: 0.05...0.2))
: UIColor(white: 1, alpha: .random(in: 0.03...0.12))
color.setFill()
context.cgContext.fillEllipse(in: CGRect(x: point.x, y: point.y, width: diameter, height: diameter))
}
}
guard let cgImage = image.cgImage else { return nil }
return try? TextureResource.generate(from: cgImage, options: .init(semantic: .color))
}
}

View File

@ -0,0 +1,26 @@
//
// Matrix+Array.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import simd
extension simd_float4x4 {
/// Spaltenweise Darstellung als 16 Floats (für die Persistenz).
var floatArray: [Float] {
[columns.0, columns.1, columns.2, columns.3].flatMap { [$0.x, $0.y, $0.z, $0.w] }
}
/// Rekonstruiert eine Matrix aus 16 spaltenweisen Floats.
init?(floatArray array: [Float]) {
guard array.count == 16 else { return nil }
self.init(
SIMD4(array[0], array[1], array[2], array[3]),
SIMD4(array[4], array[5], array[6], array[7]),
SIMD4(array[8], array[9], array[10], array[11]),
SIMD4(array[12], array[13], array[14], array[15])
)
}
}

View File

@ -0,0 +1,37 @@
//
// Measurement.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import Foundation
import simd
/// Eine benannte Strecke zwischen zwei im Raum gewählten Punkten (Welt-Koordinaten).
struct Measurement: Identifiable {
let id = UUID()
var name: String
let start: SIMD3<Float>
let end: SIMD3<Float>
/// 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
}

View File

@ -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<Float>) -> 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<Float>, to end: SIMD3<Float>) {
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<Float>, to end: SIMD3<Float>) -> SIMD3<Float> {
(start + end) / 2 + SIMD3(0, labelYOffset, 0)
}
}

View File

@ -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])
}
}

View File

@ -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
}
}
}

View File

@ -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<Float>] = []
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<Float>) {
waypoints.append(waypoint)
}
@discardableResult
mutating func removeLast() -> SIMD3<Float>? {
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
}

View File

@ -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<Float>) -> 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<Float>, to end: SIMD3<Float>) -> 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<Float>, to end: SIMD3<Float>) {
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
)
}
}

View File

@ -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<ARReferenceImage> {
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..<cells {
for column in 0..<cells {
let index = (row * cells + column) % palette.count
palette[index].setFill()
let rect = CGRect(
x: CGFloat(column) * cellSize,
y: CGFloat(row) * cellSize,
width: cellSize,
height: cellSize
)
if (row + column).isMultiple(of: 2) {
cg.fillEllipse(in: rect.insetBy(dx: 4, dy: 4))
} else {
cg.fill(rect.insetBy(dx: 6, dy: 6))
}
}
}
}
guard let cgImage = image.cgImage else { return nil }
return ARReferenceImage(cgImage, orientation: .up, physicalWidth: 0.15)
}
}

View File

@ -0,0 +1,128 @@
//
// SafetyZone.swift
// GasTankAR
//
// Created by Dennis Nemec on 27.08.26.
//
import RealityKit
import UIKit
/// Ein Sicherheitsabstand rund um den Tank: ein Kreis mit gegebenem Radius und Farbe,
/// dessen Mittelpunkt der Tank ist.
///
/// Modular gehalten: Ein weiterer Sicherheitsabstand wird schlicht durch Hinzufügen
/// eines Eintrags zu ``SafetyZone/all`` ergänzt – die Darstellung folgt automatisch.
struct SafetyZone: Identifiable {
let id = UUID()
/// Radius des Kreises in Metern (Abstand vom Tank-Mittelpunkt).
let radius: Float
/// Farbe des Rings.
let color: UIColor
/// Anzeigename, z. B. für Barrierefreiheit oder eine spätere Legende.
let label: String
/// Alle darzustellenden Sicherheitsabstände. Zum Ergänzen genügt ein weiterer Eintrag.
static let all: [SafetyZone] = [
SafetyZone(radius: 3, color: .systemRed, label: "3 m"),
SafetyZone(radius: 5, color: .systemOrange, label: "5 m"),
]
}
/// Baut die flachen, auf dem Boden liegenden Ringe für die Sicherheitsabstände.
@MainActor
enum SafetyZoneFactory {
/// Halbe Linienbreite des Rings in Metern (Ring = Radius ± Halbbreite).
private static let lineHalfWidth: Float = 0.03
/// Anzahl der Segmente pro Ring (höher = runder).
private static let segments = 96
/// Deckkraft der Ringe.
private static let opacity: Float = 0.85
/// Texthöhe der Meter-Beschriftung in Metern.
private static let labelHeight: Float = 0.25
/// Winkelpositionen (im Bogenmaß), an denen die Beschriftung auf dem Ring liegt.
/// Mehrere Positionen sorgen dafür, dass die Meterangabe aus jeder Richtung lesbar ist.
private static let labelAngles: [Float] = [0, .pi / 2, .pi, 3 * .pi / 2]
/// Erzeugt eine Entity mit je einem Ring pro Sicherheitsabstand, zentriert im Ursprung
/// und flach in der XZ-Ebene (y = 0) liegend.
static func makeEntity(for zones: [SafetyZone] = SafetyZone.all) -> 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<Float>] = []
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..<segments {
let base = UInt32(i * 2)
indices.append(contentsOf: [base, base + 1, base + 2])
indices.append(contentsOf: [base + 2, base + 1, base + 3])
}
var descriptor = MeshDescriptor(name: "safetyRing")
descriptor.positions = MeshBuffers.Positions(positions)
descriptor.primitives = .triangles(indices)
return (try? MeshResource.generate(from: [descriptor])) ?? .generatePlane(width: 0, depth: 0)
}
}

View File

@ -0,0 +1,47 @@
//
// SessionConfigurationFactory.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import ARKit
/// Baut die `ARWorldTrackingConfiguration` für den AR-Modus.
/// Kapselt die LiDAR-/People-Occlusion-/Image-Detection-Logik an einem Ort.
struct SessionConfigurationFactory {
let useLiDAR: Bool
let referenceImages: Set<ARReferenceImage>
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
}
}

View File

@ -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<Float>(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<Float>(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)
}
}
}

View File

@ -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)]
)
}
}

View File

@ -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<Float>, center: SIMD3<Float>, material: UnlitMaterial) -> ModelEntity {
let beam = ModelEntity(mesh: .generateBox(size: size), materials: [material])
beam.position = center
return beam
}
}

View File

@ -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<Content: View>(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<Void, Never>?
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()
}
}

View File

@ -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<Float>) -> 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<Float>] {
(0..<segments).map { index in
let angle = Float(index) / Float(segments) * 2 * .pi
return SIMD3(radius * cos(angle), y, radius * sin(angle))
}
}
/// Wandmantel (senkrecht, y = 0 … -depth) inkl. UV-Koordinaten für die gekachelte Textur.
private static func wallMesh(radius: Float, depth: Float) -> 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..<segments {
let next = (i + 1) % segments
let u0 = Float(i) / Float(segments) * uRepeat
let u1 = Float(i + 1) / Float(segments) * uRepeat
builder.addQuad(
top[i], top[next], bottom[next], bottom[i],
uv: (SIMD2(u0, 0), SIMD2(u1, 0), SIMD2(u1, vMax), SIMD2(u0, vMax)),
doubleSided: true
)
}
return builder.makeMesh(name: "tankPitWall")
}
/// Runde Sohle bei y = -depth (Dreiecksfächer um den Mittelpunkt).
private static func floorMesh(radius: Float, depth: Float) -> MeshResource {
let rim = circle(radius: radius, y: -depth)
let center = SIMD3<Float>(0, -depth, 0)
var builder = TriangleMeshBuilder()
for i in 0..<segments {
let next = (i + 1) % segments
builder.addTriangle(center, rim[i], rim[next], doubleSided: true)
}
return builder.makeMesh(name: "tankPitFloor")
}
/// Flacher, hervorgehobener Ring (Rand) auf gegebener Höhe.
private static func ringMesh(radius: Float, y: Float) -> 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..<segments {
let next = (i + 1) % segments
builder.addQuad(inner[i], inner[next], outer[next], outer[i], doubleSided: true)
}
return builder.makeMesh(name: "tankPitRim")
}
/// Waagerechte Fläche in der Bodenebene mit kreisrundem Loch (Ring zwischen Loch und Außenrand).
private static func maskMesh(holeRadius: Float, outerRadius: Float) -> MeshResource {
let inner = circle(radius: holeRadius, y: 0)
let outer = circle(radius: outerRadius, y: 0)
var builder = TriangleMeshBuilder()
for i in 0..<segments {
let next = (i + 1) % segments
builder.addQuad(inner[i], inner[next], outer[next], outer[i], doubleSided: true)
}
return builder.makeMesh(name: "tankPitMask")
}
}
/// Kleiner Helfer, der Dreiecke/Vierecke zu einem Mesh sammelt.
private struct TriangleMeshBuilder {
private var positions: [SIMD3<Float>] = []
private var indices: [UInt32] = []
private var uvs: [SIMD2<Float>] = []
/// Viereck mit UV-Koordinaten (für texturierte Flächen).
mutating func addQuad(
_ a: SIMD3<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>, _ d: SIMD3<Float>,
uv: (SIMD2<Float>, SIMD2<Float>, SIMD2<Float>, SIMD2<Float>),
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<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>,
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<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>, _ d: SIMD3<Float>,
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)
}
}

View File

@ -0,0 +1,56 @@
//
// TankShaders.metal
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
#include <metal_stdlib>
#include <RealityKit/RealityKit.h>
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));
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)))
}
}

View File

@ -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..<photoCount {
let photo = ProjectPhoto(
creationDate: Date(timeIntervalSince1970: TimeInterval(index)),
imageData: Data([UInt8(index)]),
project: project
)
context.insert(photo)
photos.append(photo)
}
return TestEnvironment(container: container, photos: photos)
}
// MARK: - Fehler-Mapping
struct DocuframeErrorTests {
@Test func mapsHTTPStatusCodesToErrors() {
#expect(DocuframeError.from(statusCode: 200) == nil)
#expect(DocuframeError.from(statusCode: 302) == nil)
#expect(DocuframeError.from(statusCode: 400) == .clientError(statusCode: 400))
#expect(DocuframeError.from(statusCode: 404) == .clientError(statusCode: 404))
#expect(DocuframeError.from(statusCode: 500) == .serverError(statusCode: 500))
#expect(DocuframeError.from(statusCode: 503) == .serverError(statusCode: 503))
}
@Test func httpResponseValidationThrowsForClientAndServerErrors() throws {
let url = URL(string: "https://docuframe.example.com")!
let success = try #require(HTTPURLResponse(url: url, statusCode: 204, httpVersion: nil, headerFields: nil))
#expect(throws: Never.self) { try success.validateDocuframeStatus() }
let clientError = try #require(HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil))
#expect(throws: DocuframeError.clientError(statusCode: 400)) { try clientError.validateDocuframeStatus() }
let serverError = try #require(HTTPURLResponse(url: url, statusCode: 500, httpVersion: nil, headerFields: nil))
#expect(throws: DocuframeError.serverError(statusCode: 500)) { try serverError.validateDocuframeStatus() }
}
}
// MARK: - ViewModel
@MainActor
struct DocuframeViewModelTests {
// MARK: Auswahl
@Test func togglingSelectionTracksPhotos() throws {
let env = try makeEnvironment(photoCount: 2)
let viewModel = DocuframeViewModel(service: MockDocuframeService())
#expect(!viewModel.hasSelection)
viewModel.toggleSelection(env.photos[0])
#expect(viewModel.isSelected(env.photos[0]))
#expect(viewModel.selectionCount == 1)
viewModel.toggleSelection(env.photos[0])
#expect(!viewModel.isSelected(env.photos[0]))
#expect(!viewModel.hasSelection)
}
// MARK: Erfolg
@Test func uploadSuccessSetsSuccessResult() async throws {
let env = try makeEnvironment(photoCount: 2)
let mock = MockDocuframeService(behavior: .success)
let viewModel = DocuframeViewModel(service: mock)
env.photos.forEach(viewModel.toggleSelection)
await viewModel.upload(from: env.photos, leadID: "LEAD-1")
#expect(viewModel.isProcessing == false)
let result = try #require(viewModel.result)
#expect(!result.isError)
#expect(result.title == "Erledigt")
#expect(result.message == "2 Foto(s) in DOCUframe hochgeladen.")
let uploadCount = await mock.uploadCallCount
let photoCount = await mock.lastPhotoCount
let leadID = await mock.lastLeadID
#expect(uploadCount == 1)
#expect(photoCount == 2)
#expect(leadID == "LEAD-1")
}
@Test func sendEmailSuccessSetsSuccessResult() async throws {
let env = try makeEnvironment(photoCount: 1)
let mock = MockDocuframeService(behavior: .success)
let viewModel = DocuframeViewModel(service: mock)
viewModel.toggleSelection(env.photos[0])
await viewModel.sendEmail(from: env.photos, leadID: "LEAD-1")
let result = try #require(viewModel.result)
#expect(!result.isError)
#expect(result.message == "1 Foto(s) per E-Mail versendet.")
let sendCount = await mock.sendEmailCallCount
#expect(sendCount == 1)
}
// MARK: HTTP-Fehler
@Test func uploadServerErrorSetsFailureResult() async throws {
let env = try makeEnvironment(photoCount: 1)
let mock = MockDocuframeService(behavior: .failure(.serverError(statusCode: 500)))
let viewModel = DocuframeViewModel(service: mock)
viewModel.toggleSelection(env.photos[0])
await viewModel.upload(from: env.photos, leadID: "LEAD-1")
#expect(viewModel.isProcessing == false)
let result = try #require(viewModel.result)
#expect(result.isError)
#expect(result.title == "Fehler")
#expect(result.message == DocuframeError.serverError(statusCode: 500).localizedDescription)
}
@Test func sendEmailClientErrorSetsFailureResult() async throws {
let env = try makeEnvironment(photoCount: 1)
let mock = MockDocuframeService(behavior: .failure(.clientError(statusCode: 400)))
let viewModel = DocuframeViewModel(service: mock)
viewModel.toggleSelection(env.photos[0])
await viewModel.sendEmail(from: env.photos, leadID: "LEAD-1")
let result = try #require(viewModel.result)
#expect(result.isError)
#expect(result.message == DocuframeError.clientError(statusCode: 400).localizedDescription)
}
// MARK: Keine Auswahl
@Test func withoutSelectionReportsErrorAndSkipsService() async throws {
let env = try makeEnvironment(photoCount: 2)
let mock = MockDocuframeService()
let viewModel = DocuframeViewModel(service: mock)
await viewModel.upload(from: env.photos, leadID: "LEAD-1")
let result = try #require(viewModel.result)
#expect(result.isError)
#expect(result.message == DocuframeError.noPhotosSelected.localizedDescription)
let uploadCount = await mock.uploadCallCount
#expect(uploadCount == 0)
}
}

BIN
Lastenheft AR App.pdf Normal file

Binary file not shown.

BIN
Projektplan AR App.pdf Normal file

Binary file not shown.