Initial commit for first version of project
This commit is contained in:
BIN
GasTank.usdz
Normal file
BIN
GasTank.usdz
Normal file
Binary file not shown.
@ -200,11 +200,12 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 95BEEDC83035F1C000D17653 /* Build configuration list for PBXProject "GasTankAR" */;
|
buildConfigurationList = 95BEEDC83035F1C000D17653 /* Build configuration list for PBXProject "GasTankAR" */;
|
||||||
developmentRegion = en;
|
developmentRegion = de;
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
de,
|
||||||
);
|
);
|
||||||
mainGroup = 95BEEDC43035F1C000D17653;
|
mainGroup = 95BEEDC43035F1C000D17653;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
@ -293,13 +294,16 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = GasTankAR/Info.plist;
|
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_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
|
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
|
||||||
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -328,13 +332,16 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = GasTankAR/Info.plist;
|
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_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
|
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
|
||||||
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
INFOPLIST_KEY_UIStatusBarHidden = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@ -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>
|
||||||
@ -10,8 +10,6 @@ import UIKit
|
|||||||
@main
|
@main
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
return true
|
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.
|
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
GasTankAR/Assets.xcassets/Image.imageset/Contents.json
vendored
Normal file
20
GasTankAR/Assets.xcassets/Image.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
GasTankAR/Assets.xcassets/tank-lower.imageset/ChatGPT Image 26. Aug. 2026, 00_01_42.png
vendored
Normal file
BIN
GasTankAR/Assets.xcassets/tank-lower.imageset/ChatGPT Image 26. Aug. 2026, 00_01_42.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
21
GasTankAR/Assets.xcassets/tank-lower.imageset/Contents.json
vendored
Normal file
21
GasTankAR/Assets.xcassets/tank-lower.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
GasTankAR/Assets.xcassets/tank-upper.imageset/ChatGPT Image 26. Aug. 2026, 00_01_57.png
vendored
Normal file
BIN
GasTankAR/Assets.xcassets/tank-upper.imageset/ChatGPT Image 26. Aug. 2026, 00_01_57.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 672 KiB |
21
GasTankAR/Assets.xcassets/tank-upper.imageset/Contents.json
vendored
Normal file
21
GasTankAR/Assets.xcassets/tank-upper.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,35 +6,16 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import RealityKit
|
import SwiftData
|
||||||
|
|
||||||
struct ContentView : View {
|
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
RealityView { content in
|
ProjectListView()
|
||||||
|
.modelContainer(for: Project.self)
|
||||||
// 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
|
|
||||||
|
|
||||||
}
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ContentView()
|
ProjectListView()
|
||||||
|
.modelContainer(PreviewSupport.container)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
GasTankAR/GasTank.usdz
Normal file
BIN
GasTankAR/GasTank.usdz
Normal file
Binary file not shown.
@ -2,6 +2,11 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>de</string>
|
||||||
|
<string>en</string>
|
||||||
|
</array>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
81
GasTankAR/Model/Project.swift
Normal file
81
GasTankAR/Model/Project.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
GasTankAR/Model/ProjectPhoto.swift
Normal file
49
GasTankAR/Model/ProjectPhoto.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
GasTankAR/Model/Tank.swift
Normal file
80
GasTankAR/Model/Tank.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
GasTankAR/PreviewSupport.swift
Normal file
134
GasTankAR/PreviewSupport.swift
Normal 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
|
||||||
71
GasTankAR/Services/DocuframeService.swift
Normal file
71
GasTankAR/Services/DocuframeService.swift
Normal 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
|
||||||
|
}
|
||||||
44
GasTankAR/Services/StubDocuframeService.swift
Normal file
44
GasTankAR/Services/StubDocuframeService.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
GasTankAR/Views/Docuframe/DocuframeView.swift
Normal file
126
GasTankAR/Views/Docuframe/DocuframeView.swift
Normal 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)
|
||||||
|
}
|
||||||
118
GasTankAR/Views/Docuframe/DocuframeViewModel.swift
Normal file
118
GasTankAR/Views/Docuframe/DocuframeViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
263
GasTankAR/Views/GalleryView.swift
Normal file
263
GasTankAR/Views/GalleryView.swift
Normal 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()) {}
|
||||||
|
}
|
||||||
82
GasTankAR/Views/ProjectCreate.swift
Normal file
82
GasTankAR/Views/ProjectCreate.swift
Normal 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)
|
||||||
|
}
|
||||||
66
GasTankAR/Views/ProjectDetailView.swift
Normal file
66
GasTankAR/Views/ProjectDetailView.swift
Normal 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)
|
||||||
|
}
|
||||||
58
GasTankAR/Views/ProjectListItem.swift
Normal file
58
GasTankAR/Views/ProjectListItem.swift
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
99
GasTankAR/Views/ProjectListView.swift
Normal file
99
GasTankAR/Views/ProjectListView.swift
Normal 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())
|
||||||
|
}
|
||||||
54
GasTankAR/Views/SettingsView.swift
Normal file
54
GasTankAR/Views/SettingsView.swift
Normal 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()
|
||||||
|
}
|
||||||
83
GasTankAR/Views/TankAR/ARControlOverlay.swift
Normal file
83
GasTankAR/Views/TankAR/ARControlOverlay.swift
Normal 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 : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
284
GasTankAR/Views/TankAR/ARExperienceView.swift
Normal file
284
GasTankAR/Views/TankAR/ARExperienceView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
252
GasTankAR/Views/TankAR/ARFlowStateMachine.swift
Normal file
252
GasTankAR/Views/TankAR/ARFlowStateMachine.swift
Normal 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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
67
GasTankAR/Views/TankAR/ARProjectPersisting.swift
Normal file
67
GasTankAR/Views/TankAR/ARProjectPersisting.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
873
GasTankAR/Views/TankAR/ARSessionController.swift
Normal file
873
GasTankAR/Views/TankAR/ARSessionController.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
234
GasTankAR/Views/TankAR/ARStatusOverlay.swift
Normal file
234
GasTankAR/Views/TankAR/ARStatusOverlay.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
29
GasTankAR/Views/TankAR/ARViewContainer.swift
Normal file
29
GasTankAR/Views/TankAR/ARViewContainer.swift
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
57
GasTankAR/Views/TankAR/DirtTextureFactory.swift
Normal file
57
GasTankAR/Views/TankAR/DirtTextureFactory.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
26
GasTankAR/Views/TankAR/Matrix+Array.swift
Normal file
26
GasTankAR/Views/TankAR/Matrix+Array.swift
Normal 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])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
GasTankAR/Views/TankAR/Measurement.swift
Normal file
37
GasTankAR/Views/TankAR/Measurement.swift
Normal 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
|
||||||
|
}
|
||||||
98
GasTankAR/Views/TankAR/MeasurementEntityFactory.swift
Normal file
98
GasTankAR/Views/TankAR/MeasurementEntityFactory.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
GasTankAR/Views/TankAR/MeasurementListView.swift
Normal file
59
GasTankAR/Views/TankAR/MeasurementListView.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
107
GasTankAR/Views/TankAR/PhotoEncoder.swift
Normal file
107
GasTankAR/Views/TankAR/PhotoEncoder.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
GasTankAR/Views/TankAR/PipeRoute.swift
Normal file
49
GasTankAR/Views/TankAR/PipeRoute.swift
Normal 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
|
||||||
|
}
|
||||||
54
GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift
Normal file
54
GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
GasTankAR/Views/TankAR/ReferenceImageProvider.swift
Normal file
56
GasTankAR/Views/TankAR/ReferenceImageProvider.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
128
GasTankAR/Views/TankAR/SafetyZone.swift
Normal file
128
GasTankAR/Views/TankAR/SafetyZone.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
GasTankAR/Views/TankAR/SessionConfigurationFactory.swift
Normal file
47
GasTankAR/Views/TankAR/SessionConfigurationFactory.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
63
GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift
Normal file
63
GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
GasTankAR/Views/TankAR/TankEntityFactory.swift
Normal file
93
GasTankAR/Views/TankAR/TankEntityFactory.swift
Normal 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)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
GasTankAR/Views/TankAR/TankHighlightFactory.swift
Normal file
69
GasTankAR/Views/TankAR/TankHighlightFactory.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
144
GasTankAR/Views/TankAR/TankManipulationPanel.swift
Normal file
144
GasTankAR/Views/TankAR/TankManipulationPanel.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
211
GasTankAR/Views/TankAR/TankPitFactory.swift
Normal file
211
GasTankAR/Views/TankAR/TankPitFactory.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
GasTankAR/Views/TankAR/TankShaders.metal
Normal file
56
GasTankAR/Views/TankAR/TankShaders.metal
Normal 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));
|
||||||
|
}
|
||||||
20
GasTankAR/Views/TankAR/WorldMapArchiver.swift
Normal file
20
GasTankAR/Views/TankAR/WorldMapArchiver.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
GasTankAR/Views/TankEditor.swift
Normal file
102
GasTankAR/Views/TankEditor.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
GasTankARTests/ARFlowStateMachineTests.swift
Normal file
191
GasTankARTests/ARFlowStateMachineTests.swift
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
218
GasTankARTests/DocuframeTests.swift
Normal file
218
GasTankARTests/DocuframeTests.swift
Normal 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
BIN
Lastenheft AR App.pdf
Normal file
Binary file not shown.
BIN
Projektplan AR App.pdf
Normal file
BIN
Projektplan AR App.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user