Initial commit for first version of project

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

View File

@ -0,0 +1,83 @@
//
// ARControlOverlay.swift
// GasTankAR
//
// Created by Dennis Nemec on 27.08.26.
//
import SwiftUI
/// Runde Werkzeug-Buttons oben rechts über der AR-View. Untereinander angeordnet;
/// weitere Funktionen lassen sich als zusätzliche `roundButton` ergänzen.
struct ARControlOverlay: View {
let controller: ARSessionController
/// Öffnet die Strecken-Liste (Ausmessen).
let onMeasure: () -> Void
var body: some View {
VStack {
HStack {
Spacer()
VStack(spacing: 12) {
roundButton(
systemImage: "shield.lefthalf.filled",
isActive: controller.showSafetyZones,
label: "Sicherheitsabstand"
) {
controller.toggleSafetyZones()
}
roundButton(
systemImage: "ruler",
isActive: controller.measurementPhase != .idle,
label: "Ausmessen"
) {
onMeasure()
}
roundButton(
systemImage: "point.3.connected.trianglepath.dotted",
isActive: controller.pipeRoutingPhase != .idle,
label: "Rohrführung"
) {
if controller.pipeRoutingPhase == .idle {
controller.startPipeRouting()
}
}
roundButton(
systemImage: "camera.fill",
isActive: false,
label: "Foto aufnehmen"
) {
controller.captureSnapshot()
}
}
}
Spacer()
}
.padding()
}
private func roundButton(
systemImage: String,
isActive: Bool,
label: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.title3)
.frame(width: 52, height: 52)
.foregroundStyle(isActive ? Color.white : Color.primary)
.background(
isActive ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.ultraThinMaterial),
in: .circle
)
.shadow(radius: 3)
}
.buttonStyle(.plain)
.accessibilityLabel(label)
.accessibilityAddTraits(isActive ? .isSelected : [])
}
}

View File

@ -0,0 +1,284 @@
//
// ARExperienceView.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import SwiftUI
import SwiftData
/// Vollbild-AR-Erlebnis für ein Projekt: komponiert AR-View + Status-Overlay und
/// besitzt den `ARSessionController`.
struct ARExperienceView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false
let project: Project
@State private var controller: ARSessionController?
@State private var flashOpacity = 0.0
@State private var showMeasurements = false
@State private var measurementName = ""
var body: some View {
ZStack {
if let controller {
ARViewContainer(controller: controller)
.ignoresSafeArea()
ARStatusOverlay(controller: controller, onCancel: { dismiss() })
if controller.state == .immersive {
ARControlOverlay(controller: controller) {
showMeasurements = true
}
.transition(.opacity)
}
if controller.measurementPhase == .awaitingFirstPoint
|| controller.measurementPhase == .awaitingSecondPoint {
measuringOverlay(controller: controller)
}
if controller.pipeRoutingPhase == .placing {
pipeRoutingOverlay(controller: controller)
}
Color.white
.opacity(flashOpacity)
.ignoresSafeArea()
.allowsHitTesting(false)
.onChange(of: controller.snapshotFlashCounter) {
flashOpacity = 0.9
withAnimation(.easeOut(duration: 0.35)) { flashOpacity = 0 }
}
}
}
.navigationTitle(project.customerName)
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showMeasurements) {
if let controller {
MeasurementListView(controller: controller) {
showMeasurements = false
controller.startMeasurement()
}
}
}
.alert("Strecke benennen", isPresented: namingBinding) {
TextField("Bezeichnung", text: $measurementName)
Button("Speichern") {
controller?.finishMeasurement(name: measurementName)
measurementName = ""
}
Button("Abbrechen", role: .cancel) {
controller?.cancelMeasurement()
measurementName = ""
}
} message: {
Text("Gib der Strecke eine Bezeichnung. Sie erscheint auf der Linie.")
}
.task {
guard controller == nil else { return }
let persistence = ProjectPersistenceAdapter(project: project, modelContext: modelContext)
controller = ARSessionController(persistence: persistence, useLiDAR: lidarEnabled)
}
}
/// Präsentiert den Benennungs-Alert, sobald beide Punkte gesetzt sind.
private var namingBinding: Binding<Bool> {
Binding(
get: { controller?.measurementPhase == .naming },
set: { isPresented in
// Wird der Alert ohne Button-Aktion geschlossen, Messvorgang abbrechen.
if !isPresented, controller?.measurementPhase == .naming {
controller?.cancelMeasurement()
}
}
)
}
/// Vollständiges Mess-Overlay: Fadenkreuz in der Mitte, Hinweis-/Distanz-Banner oben,
/// Auslöser-Button unten (wie beim Platzieren des Tanks).
private func measuringOverlay(controller: ARSessionController) -> some View {
ZStack {
// Fadenkreuz exakt in der Vollbild-Mitte – deckt sich mit dem Raycast-Punkt
// (arView.bounds-Mitte), der die Safe Area ignoriert.
measuringCrosshair
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
VStack {
measuringBanner(for: controller)
Spacer()
measuringShutter(for: controller)
}
.padding()
}
.transition(.opacity)
}
private var measuringCrosshair: some View {
ZStack {
Circle()
.stroke(.yellow.opacity(0.9), lineWidth: 2)
.frame(width: 44, height: 44)
Circle()
.fill(.yellow)
.frame(width: 5, height: 5)
}
.shadow(radius: 2)
.allowsHitTesting(false)
}
private func measuringBanner(for controller: ARSessionController) -> some View {
HStack(spacing: 8) {
Image(systemName: "ruler")
Text(measuringBannerText(for: controller))
.font(.subheadline.weight(.medium))
.monospacedDigit()
Spacer(minLength: 0)
Button("Abbrechen") { controller.cancelMeasurement() }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: .capsule)
}
private func measuringBannerText(for controller: ARSessionController) -> String {
switch controller.measurementPhase {
case .awaitingFirstPoint:
return "Ziele auf den Startpunkt."
case .awaitingSecondPoint:
if let distance = controller.liveDistance {
return String(format: "Abstand: %.2f m", distance)
}
return "Ziele auf den Endpunkt."
default:
return ""
}
}
private func measuringShutter(for controller: ARSessionController) -> some View {
let title = controller.measurementPhase == .awaitingFirstPoint ? "Startpunkt setzen" : "Endpunkt setzen"
return VStack(spacing: 10) {
Text(title)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: .capsule)
Button {
controller.captureMeasurementPoint()
} label: {
ZStack {
Circle()
.stroke(.white, lineWidth: 4)
.frame(width: 74, height: 74)
Circle()
.fill(.yellow)
.frame(width: 60, height: 60)
}
.shadow(radius: 4)
.contentShape(.circle)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
}
}
// MARK: - Rohrführung-Overlay
/// Overlay für die Rohrführung: pinkes Fadenkreuz (Vollbild-Mitte), Info-Banner oben,
/// Auslöser + Aktionen unten (gleiche Bedienung wie beim Messen).
private func pipeRoutingOverlay(controller: ARSessionController) -> some View {
ZStack {
pipeCrosshair
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
VStack {
pipeBanner(for: controller)
Spacer()
pipeControls(for: controller)
}
.padding()
}
.transition(.opacity)
}
private var pipeCrosshair: some View {
ZStack {
Circle()
.stroke(.pink.opacity(0.9), lineWidth: 2)
.frame(width: 44, height: 44)
Circle()
.fill(.pink)
.frame(width: 5, height: 5)
}
.shadow(radius: 2)
.allowsHitTesting(false)
}
private func pipeBanner(for controller: ARSessionController) -> some View {
HStack(spacing: 8) {
Image(systemName: "point.3.connected.trianglepath.dotted")
Text(pipeBannerText(for: controller))
.font(.subheadline.weight(.medium))
.monospacedDigit()
Spacer(minLength: 0)
Button("Abbrechen") { controller.cancelPipeRouting() }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: .capsule)
}
private func pipeBannerText(for controller: ARSessionController) -> String {
if controller.pipeWaypointCount == 0 {
return "Ziele auf den Startpunkt der Rohrführung."
}
return "Wegpunkte: \(controller.pipeWaypointCount) · Länge: \(controller.pipeRouteLengthText)"
}
private func pipeControls(for controller: ARSessionController) -> some View {
VStack(spacing: 12) {
Text("Wegpunkt setzen")
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: .capsule)
Button {
controller.addPipeWaypoint()
} label: {
ZStack {
Circle()
.stroke(.white, lineWidth: 4)
.frame(width: 74, height: 74)
Circle()
.fill(.pink)
.frame(width: 60, height: 60)
}
.shadow(radius: 4)
.contentShape(.circle)
}
.buttonStyle(.plain)
.accessibilityLabel("Wegpunkt setzen")
HStack(spacing: 12) {
Button("Rückgängig", systemImage: "arrow.uturn.backward") {
controller.undoLastPipeWaypoint()
}
.buttonStyle(.bordered)
.disabled(controller.pipeWaypointCount == 0)
Button("Fertig", systemImage: "checkmark") {
controller.finishPipeRouting()
}
.buttonStyle(.borderedProminent)
.disabled(controller.pipeWaypointCount < 2)
}
}
}
}

View File

@ -0,0 +1,252 @@
//
// ARFlowStateMachine.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import Foundation
/// Zustände des AR-Modus gemäß State-Machine (ohne den entfernten „Choose Tank"-State –
/// stattdessen führt eine gefundene Location/ein erkannter Anker direkt in `immersive`).
enum ARFlowState: Equatable {
case worldNotInitialized
case initializingWorld
case initializingNotPossible
case userMovesAround
case showOptions
case anchorDetection
case askToCancel
case selectLocation
case immersive
case relocating
case relocationFailed
}
/// Transparente ARKit-Statuslage (wird von der View in lokalisierten Text übersetzt).
enum ARStatus: Equatable {
case relocalizing
case excessiveMotion
case insufficientFeatures
case unavailable
}
/// Quelle der Transform beim Platzieren des Tanks.
enum PlacementSource: Equatable {
case storedTransform
case screenCenter
case imageAnchor
}
/// Eingaben in die State-Machine: ARKit-Events, Timer und Nutzeraktionen.
enum ARFlowEvent: Equatable {
case started
// ARKit-Tracking
case trackingNormal
case trackingInitializing
case trackingRelocalizing
case trackingExcessiveMotion
case trackingInsufficientFeatures
case trackingUnavailable
// Coaching / Session
case coachingWillActivate
case coachingDidDeactivate
case sessionInterruptionEnded
case imageAnchorDetected
// Timer
case moveAroundTimerFired
case showOptionsTimerFired
case anchorDetectionTimerFired
case relocationTimerFired
// Nutzeraktionen
case captureConfirmed // Raycast war erfolgreich (von der View vorgeprüft)
case keepMovingTapped
case placeAnchorMarkerTapped
case tryAgainAnchorTapped
case repositionTapped
case restartTapped
case retryRelocationTapped
}
/// Seiteneffekte, die der Controller (unreine Schale) ausführt.
enum ARFlowCommand: Equatable {
case setStatus(ARStatus?)
case runConfiguration(useImages: Bool, useInitialMap: Bool, resetTracking: Bool)
case setCoachingAutomatic(Bool)
case startMoveAroundTimer
case startShowOptionsTimer
case cancelEscalationTimers
case startRelocationTimer
case cancelRelocationTimer
case startAnchorDetectionTimer
case cancelAnchorDetectionTimer
case placeTank(PlacementSource)
case removeTank
case persistWorldMap
case persistTankTransform
case clearStoredTransform
}
/// Reine, testbare State-Machine des AR-Flows. Enthält keinerlei ARKit-/UI-Abhängigkeiten:
/// `reduce` verändert den Zustand und liefert die auszuführenden Kommandos zurück.
struct ARFlowStateMachine {
private(set) var state: ARFlowState = .worldNotInitialized
private(set) var hasPlacedTank = false
private(set) var hasStoredTransform: Bool
init(hasStoredTransform: Bool) {
self.hasStoredTransform = hasStoredTransform
}
mutating func reduce(_ event: ARFlowEvent) -> [ARFlowCommand] {
switch event {
case .started:
state = .worldNotInitialized
return [.runConfiguration(useImages: false, useInitialMap: true, resetTracking: false)]
case .trackingNormal:
var commands: [ARFlowCommand] = [
.setStatus(nil), .cancelEscalationTimers, .cancelRelocationTimer, .setCoachingAutomatic(true),
]
commands += resolvePlacedOrRestore()
return commands
case .trackingUnavailable:
return [.setStatus(.unavailable)]
case .trackingInitializing:
if !hasPlacedTank, state == .worldNotInitialized || state == .initializingWorld {
state = .initializingWorld
}
return [.setStatus(nil)]
case .trackingRelocalizing:
state = .relocating
return [.setStatus(.relocalizing), .cancelRelocationTimer, .startRelocationTimer]
case .trackingExcessiveMotion:
return [.setStatus(.excessiveMotion)]
case .trackingInsufficientFeatures:
var commands: [ARFlowCommand] = [.setStatus(.insufficientFeatures)]
let blockedStates: [ARFlowState] = [.anchorDetection, .showOptions, .askToCancel]
if !hasPlacedTank, !blockedStates.contains(state), state != .userMovesAround {
state = .initializingNotPossible
commands += [.cancelEscalationTimers, .startMoveAroundTimer]
}
return commands
case .moveAroundTimerFired:
guard state == .initializingNotPossible else { return [] }
state = .userMovesAround
return [.startShowOptionsTimer]
case .showOptionsTimerFired:
guard state == .userMovesAround else { return [] }
state = .showOptions
return [.setCoachingAutomatic(false)]
case .keepMovingTapped:
state = .userMovesAround
return [.setCoachingAutomatic(true), .cancelEscalationTimers, .startShowOptionsTimer]
case .placeAnchorMarkerTapped:
state = .anchorDetection
return [
.cancelEscalationTimers,
.setCoachingAutomatic(false),
.runConfiguration(useImages: true, useInitialMap: false, resetTracking: false),
.cancelAnchorDetectionTimer,
.startAnchorDetectionTimer,
]
case .anchorDetectionTimerFired:
guard state == .anchorDetection else { return [] }
state = .askToCancel
return []
case .tryAgainAnchorTapped:
state = .showOptions
return [
.cancelAnchorDetectionTimer,
.runConfiguration(useImages: false, useInitialMap: false, resetTracking: false),
.setCoachingAutomatic(false),
]
case .imageAnchorDetected:
guard state == .anchorDetection else { return [] }
state = .immersive
hasPlacedTank = true
return [.cancelAnchorDetectionTimer, .placeTank(.imageAnchor), .persistWorldMap, .persistTankTransform]
case .captureConfirmed:
guard state == .selectLocation else { return [] }
state = .immersive
hasPlacedTank = true
return [.placeTank(.screenCenter), .persistWorldMap, .persistTankTransform]
case .repositionTapped:
state = .selectLocation
hasPlacedTank = false
hasStoredTransform = false
return [.removeTank, .clearStoredTransform]
case .restartTapped:
state = .worldNotInitialized
hasPlacedTank = false
hasStoredTransform = false
return [
.cancelRelocationTimer,
.removeTank,
.clearStoredTransform,
.setStatus(nil),
.runConfiguration(useImages: false, useInitialMap: false, resetTracking: true),
]
case .retryRelocationTapped:
state = .relocating
return [.setStatus(.relocalizing), .cancelRelocationTimer, .startRelocationTimer]
case .relocationTimerFired:
guard state == .relocating else { return [] }
state = .relocationFailed
return [.setStatus(nil)]
case .coachingWillActivate:
if !hasPlacedTank { state = .initializingWorld }
return []
case .coachingDidDeactivate:
return [.cancelEscalationTimers] + resolvePlacedOrRestore()
case .sessionInterruptionEnded:
var commands: [ARFlowCommand] = [.setStatus(nil)]
if hasPlacedTank {
state = .relocating
commands += [.cancelRelocationTimer, .startRelocationTimer]
}
return commands
}
}
/// Gemeinsame Logik für „Tracking normal / Coaching fertig": Tank wiederherstellen,
/// sonst in die Platzieren-UI wechseln (sofern nicht gerade Anker-Erkennung läuft).
private mutating func resolvePlacedOrRestore() -> [ARFlowCommand] {
if hasPlacedTank {
state = .immersive
return []
}
guard state != .anchorDetection else { return [] }
if hasStoredTransform {
state = .immersive
hasPlacedTank = true
return [.placeTank(.storedTransform)]
}
state = .selectLocation
return []
}
}

View File

@ -0,0 +1,67 @@
//
// ARProjectPersisting.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import ARKit
import CoreLocation
import SwiftData
/// Abstraktion der Projekt-Persistenz für den AR-Modus (Dependency Inversion):
/// Der Controller kennt so kein SwiftData und ist unit-testbar.
@MainActor
protocol ARProjectPersisting: AnyObject {
var tank: Tank { get }
var worldMap: ARWorldMap? { get }
var tankTransform: [Float]? { get }
func saveWorldMap(_ data: Data)
func saveTankTransform(_ transform: [Float])
func clearTankTransform()
func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?)
}
/// SwiftData-Adapter: verbindet das `Project`-Model mit dem `ARProjectPersisting`-Protokoll.
@MainActor
final class ProjectPersistenceAdapter: ARProjectPersisting {
private let project: Project
private let modelContext: ModelContext
init(project: Project, modelContext: ModelContext) {
self.project = project
self.modelContext = modelContext
}
var tank: Tank { project.tank }
var worldMap: ARWorldMap? { project.worldMap }
var tankTransform: [Float]? { project.tankTransform }
func saveWorldMap(_ data: Data) {
project.worldMapData = data
try? modelContext.save()
}
func saveTankTransform(_ transform: [Float]) {
project.tankTransform = transform
try? modelContext.save()
}
func clearTankTransform() {
project.tankTransform = nil
try? modelContext.save()
}
func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?) {
let photo = ProjectPhoto(
creationDate: date,
imageData: imageData,
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
project: project
)
modelContext.insert(photo)
try? modelContext.save()
}
}

View File

@ -0,0 +1,873 @@
//
// ARSessionController.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import SwiftUI
import RealityKit
import ARKit
import CoreLocation
import Combine
/// Orchestriert den AR-Modus: hält die `ARView`/Session, empfängt ARKit-Events,
/// treibt die reine `ARFlowStateMachine` und führt deren Kommandos aus.
/// Delegiert Konfiguration, Entity-Bau, Referenzbilder und Persistenz an Kollaborateure.
@MainActor
@Observable
final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayViewDelegate, CLLocationManagerDelegate {
// Von der UI beobachtete Zustände.
private(set) var state: ARFlowState = .worldNotInitialized
private(set) var statusMessage: String?
private(set) var worldMappingStatus: ARFrame.WorldMappingStatus = .notAvailable
private(set) var isTankSelected = false {
didSet { updateSelectionHighlight() }
}
/// Ob die Sicherheitsabstände (Ringe um den Tank) eingeblendet sind.
private(set) var showSafetyZones = false
/// Wird bei jedem erfolgreichen Foto erhöht – die UI nutzt das für ein kurzes Aufblitzen.
private(set) var snapshotFlashCounter = 0
/// Angelegte Strecken (nur für die laufende Sitzung gehalten).
private(set) var measurements: [Measurement] = []
/// Aktuelle Phase des Messvorgangs.
private(set) var measurementPhase: MeasurementPhase = .idle
/// Live-Distanz (Meter) zwischen erstem Punkt und aktueller Bildschirmmitte – für die UI.
private(set) var liveDistance: Float?
/// Aktuelle Rohrführung (Wegpunkt-Pfad) der laufenden Sitzung.
private(set) var pipeRoute = PipeRoute()
/// Aktuelle Phase des Rohr-Routings.
private(set) var pipeRoutingPhase: PipeRoutingPhase = .idle
/// Anzahl gesetzter Wegpunkte (für die UI).
var pipeWaypointCount: Int { pipeRoute.count }
/// Gesamtlänge der Rohrführung als Text (für die UI).
var pipeRouteLengthText: String { pipeRoute.formattedLength }
@ObservationIgnored private let persistence: ARProjectPersisting
@ObservationIgnored private let useLiDAR: Bool
@ObservationIgnored private let configFactory: SessionConfigurationFactory
@ObservationIgnored private let entityFactory = TankEntityFactory()
@ObservationIgnored private var machine: ARFlowStateMachine
@ObservationIgnored private weak var arView: ARView?
@ObservationIgnored private weak var coaching: ARCoachingOverlayView?
@ObservationIgnored private var tankAnchor: AnchorEntity?
@ObservationIgnored private var tankEntity: ModelEntity?
@ObservationIgnored private var safetyZoneEntity: Entity?
/// Anker für alle Mess-Visualisierungen (unabhängig vom Tank).
@ObservationIgnored private var measurementAnchor: AnchorEntity?
/// Container der fertigen Strecken, nach `Measurement.id`.
@ObservationIgnored private var measurementEntities: [UUID: Entity] = [:]
/// Label-Halter für das Billboarding (Ausrichtung zur Kamera).
@ObservationIgnored private var measurementLabels: [Entity] = []
/// Vorläufige Punkt-Kugeln während eines laufenden Messvorgangs.
@ObservationIgnored private var previewPointEntities: [Entity] = []
@ObservationIgnored private var pendingMeasurementStart: SIMD3<Float>?
@ObservationIgnored private var pendingMeasurementEnd: SIMD3<Float>?
/// Live-Vorschau (folgt der Bildschirmmitte): gezogene Linie und Distanz-Label.
/// Das Fadenkreuz stellt selbst das aktuelle Linienende dar (kein zusätzlicher Cursor).
@ObservationIgnored private var liveLineEntity: ModelEntity?
@ObservationIgnored private var liveLabelEntity: Entity?
/// Zuletzt gezeigte Live-Distanz in Zentimetern (drosselt die Label-/UI-Aktualisierung).
@ObservationIgnored private var lastLiveDistanceCm: Int?
/// Anker + Entities der Rohrführung.
@ObservationIgnored private var pipeAnchor: AnchorEntity?
@ObservationIgnored private var pipeWaypointEntities: [Entity] = []
@ObservationIgnored private var pipeSegmentEntities: [Entity] = []
/// Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte.
@ObservationIgnored private var pipeLiveSegment: ModelEntity?
/// Per-Frame-Callback zum Ausrichten der Labels zur Kamera.
@ObservationIgnored private var sceneUpdateSubscription: (any Cancellable)?
/// Liefert den Standort für die Verortung aufgenommener Fotos.
@ObservationIgnored private let locationManager = CLLocationManager()
/// Zuletzt bekannter Standort (für die GPS-Verortung von Fotos).
@ObservationIgnored private var currentLocation: CLLocation?
@ObservationIgnored private lazy var initialWorldMap: ARWorldMap? = persistence.worldMap
@ObservationIgnored private lazy var storedTransform: simd_float4x4? =
persistence.tankTransform.flatMap { simd_float4x4(floatArray: $0) }
/// Transform für die nächste Platzierung aus Raycast/Bild-Anker.
@ObservationIgnored private var pendingTransform: simd_float4x4?
@ObservationIgnored private var moveAroundTask: Task<Void, Never>?
@ObservationIgnored private var showOptionsTask: Task<Void, Never>?
@ObservationIgnored private var anchorDetectionTask: Task<Void, Never>?
@ObservationIgnored private var relocationTask: Task<Void, Never>?
@ObservationIgnored private var mappingPollTask: Task<Void, Never>?
private static let moveAroundDelay: Duration = .seconds(3)
private static let showOptionsDelay: Duration = .seconds(6)
private static let anchorDetectionTimeout: Duration = .seconds(20)
private static let relocationTimeout: Duration = .seconds(15)
init(persistence: ARProjectPersisting, useLiDAR: Bool) {
self.persistence = persistence
self.useLiDAR = useLiDAR
self.configFactory = SessionConfigurationFactory(
useLiDAR: useLiDAR,
referenceImages: ReferenceImageProvider.load()
)
self.machine = ARFlowStateMachine(hasStoredTransform: persistence.tankTransform != nil)
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
}
// MARK: - Lifecycle (vom ARViewContainer)
func attach(to view: ARView) {
arView = view
view.session.delegate = self
view.automaticallyConfigureSession = false
view.environment.sceneUnderstanding.options = useLiDAR ? [.occlusion] : []
let coachingView = ARCoachingOverlayView()
coachingView.session = view.session
coachingView.goal = .horizontalPlane
coachingView.activatesAutomatically = true
coachingView.delegate = self
coachingView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(coachingView)
NSLayoutConstraint.activate([
coachingView.topAnchor.constraint(equalTo: view.topAnchor),
coachingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
coachingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
coachingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
coaching = coachingView
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
view.addGestureRecognizer(tap)
// Pro Frame die Mess-Labels zur Kamera ausrichten (Billboarding).
sceneUpdateSubscription = view.scene.subscribe(to: SceneEvents.Update.self) { [weak self] _ in
guard let self else { return }
MainActor.assumeIsolated { self.onSceneUpdate() }
}
entityFactory.preload()
startMappingPoll()
startLocationUpdates()
send(.started)
}
func detach() {
[moveAroundTask, showOptionsTask, anchorDetectionTask, relocationTask, mappingPollTask]
.forEach { $0?.cancel() }
locationManager.stopUpdatingLocation()
sceneUpdateSubscription?.cancel()
arView?.session.pause()
}
// MARK: - Nutzeraktionen (von der Overlay-UI)
func capture() {
guard state == .selectLocation, let arView else { return }
let center = CGPoint(x: arView.bounds.midX, y: arView.bounds.midY)
guard let result = arView.raycast(from: center, allowing: .estimatedPlane, alignment: .horizontal).first
else { return }
pendingTransform = result.worldTransform
send(.captureConfirmed)
}
func keepMoving() { send(.keepMovingTapped) }
func placeAnchorMarker() { send(.placeAnchorMarkerTapped) }
func tryAgainAnchor() { send(.tryAgainAnchorTapped) }
func reposition() { send(.repositionTapped) }
func restart() { send(.restartTapped) }
func retryRelocation() { send(.retryRelocationTapped) }
func deselectTank() { isTankSelected = false }
// MARK: - Tank-Feinjustierung (über das Steuer-Panel)
private static let heightStep: Float = 0.05
private static let moveStep: Float = 0.05
private static let rotationStep: Float = .pi / 36 // 5°
/// Verändert die Höhe des Tanks (y-Achse). Ein Schritt pro Aufruf (auch beim Halten wiederholt).
func adjustTankHeight(up: Bool) {
guard let tankEntity else { return }
tankEntity.position.y += up ? Self.heightStep : -Self.heightStep
refreshAfterEdit()
}
/// Verschiebt den Tank auf dem Boden – links/rechts relativ zur Blickrichtung.
func moveTank(left: Bool) {
moveOnGround(right: left ? -1 : 1, forward: 0)
}
/// Verschiebt den Tank auf dem Boden – vor/zurück relativ zur Blickrichtung.
func moveTank(forward: Bool) {
moveOnGround(right: 0, forward: forward ? 1 : -1)
}
/// Dreht den Tank um die Hochachse.
func rotateTank(clockwise: Bool) {
guard let tankEntity else { return }
let angle = (clockwise ? -1 : 1) * Self.rotationStep
let rotation = simd_quatf(angle: angle, axis: SIMD3(0, 1, 0))
tankEntity.transform.rotation = rotation * tankEntity.transform.rotation
refreshAfterEdit()
}
/// Sichert die Transform nach Abschluss einer Anpassung (z. B. beim Loslassen des Buttons).
func commitTankEdit() {
persistTankTransform()
}
/// Verschiebt den Tank in der Bodenebene entlang der kamerarelativen Rechts-/Vorwärts-Achse.
private func moveOnGround(right rightAmount: Float, forward forwardAmount: Float) {
guard let tankEntity, let arView else { return }
let cameraMatrix = arView.cameraTransform.matrix
var right = SIMD3<Float>(cameraMatrix.columns.0.x, 0, cameraMatrix.columns.0.z)
right = length(right) > 1e-4 ? normalize(right) : SIMD3(1, 0, 0)
// Kamera-Vorwärts = -Z der Kamera, auf den Boden projiziert.
var forward = SIMD3<Float>(-cameraMatrix.columns.2.x, 0, -cameraMatrix.columns.2.z)
forward = length(forward) > 1e-4 ? normalize(forward) : SIMD3(0, 0, -1)
tankEntity.position += (right * rightAmount + forward * forwardAmount) * Self.moveStep
refreshAfterEdit()
}
/// Aktualisiert abhängige Visuals nach einer Anpassung – ohne zu persistieren
/// (das Speichern erfolgt gebündelt via ``commitTankEdit()``).
private func refreshAfterEdit() {
updateSafetyZones()
updateDepthFadeGroundY()
}
/// Blendet die Eck-Markierung passend zur Auswahl ein/aus.
private func updateSelectionHighlight() {
guard let tankEntity,
let highlight = tankEntity.findEntity(named: TankHighlightFactory.name) else { return }
highlight.isEnabled = isTankSelected
}
/// Blendet die Sicherheitsabstände um den Tank ein bzw. aus.
func toggleSafetyZones() {
showSafetyZones.toggle()
updateSafetyZones()
}
/// Nimmt einen Snapshot der aktuellen AR-Sicht auf und speichert ihn – samt aktuellem
/// Standort für die spätere Karten-Verortung – im Projekt.
func captureSnapshot() {
guard let arView else { return }
let coordinate = currentLocation?.coordinate
arView.snapshot(saveToHDR: false) { [weak self] image in
guard let image else { return }
let date = Date()
guard let data = PhotoEncoder.jpegData(from: image, coordinate: coordinate, date: date) else { return }
Task { @MainActor in
guard let self else { return }
self.persistence.savePhoto(data, date: date, coordinate: coordinate)
self.snapshotFlashCounter += 1
}
}
}
// MARK: - Ausmessen (Strecken)
/// Startet einen neuen Messvorgang; der Nutzer tippt danach zwei Punkte an.
func startMeasurement() {
clearMeasurementPreview()
pendingMeasurementStart = nil
pendingMeasurementEnd = nil
measurementPhase = .awaitingFirstPoint
}
/// Bricht den laufenden Messvorgang ab und entfernt vorläufige Punkte.
func cancelMeasurement() {
clearMeasurementPreview()
hideLivePreview()
pendingMeasurementStart = nil
pendingMeasurementEnd = nil
measurementPhase = .idle
}
/// Schließt den Messvorgang mit der eingegebenen Bezeichnung ab und rendert die Strecke.
func finishMeasurement(name: String) {
guard let start = pendingMeasurementStart, let end = pendingMeasurementEnd else {
cancelMeasurement()
return
}
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let measurement = Measurement(
name: trimmed.isEmpty ? "Strecke \(measurements.count + 1)" : trimmed,
start: start,
end: end
)
measurements.append(measurement)
renderMeasurement(measurement)
clearMeasurementPreview()
hideLivePreview()
pendingMeasurementStart = nil
pendingMeasurementEnd = nil
measurementPhase = .idle
}
/// Entfernt Strecken (aus der Liste per Swipe).
func deleteMeasurements(at offsets: IndexSet) {
for index in offsets {
let measurement = measurements[index]
if let entity = measurementEntities.removeValue(forKey: measurement.id) {
if let label = entity.findEntity(named: MeasurementEntityFactory.labelName) {
measurementLabels.removeAll { $0 === label }
}
entity.removeFromParent()
}
}
measurements.remove(atOffsets: offsets)
}
/// Setzt den aktuellen Punkt aus der Bildschirmmitte (wie beim Platzieren des Tanks).
func captureMeasurementPoint() {
guard measurementPhase == .awaitingFirstPoint || measurementPhase == .awaitingSecondPoint else { return }
guard let position = raycastScreenCenter() else {
statusMessage = "Kein Punkt erkannt – ziele auf eine erkannte Fläche."
return
}
addMeasurementPreviewPoint(at: position)
if measurementPhase == .awaitingFirstPoint {
pendingMeasurementStart = position
measurementPhase = .awaitingSecondPoint
} else {
pendingMeasurementEnd = position
measurementPhase = .naming
hideLivePreview()
}
}
/// Raycast aus der Bildschirmmitte auf eine (geschätzte) Fläche.
private func raycastScreenCenter() -> SIMD3<Float>? {
guard let arView else { return nil }
let center = CGPoint(x: arView.bounds.midX, y: arView.bounds.midY)
guard let result = arView.raycast(from: center, allowing: .estimatedPlane, alignment: .any).first
else { return nil }
let column = result.worldTransform.columns.3
return SIMD3<Float>(column.x, column.y, column.z)
}
private func addMeasurementPreviewPoint(at position: SIMD3<Float>) {
let anchor = ensureMeasurementAnchor()
let point = MeasurementEntityFactory.makePoint(at: position)
anchor.addChild(point)
previewPointEntities.append(point)
}
private func renderMeasurement(_ measurement: Measurement) {
let anchor = ensureMeasurementAnchor()
let entity = MeasurementEntityFactory.makeMeasurement(measurement)
anchor.addChild(entity)
measurementEntities[measurement.id] = entity
if let label = entity.findEntity(named: MeasurementEntityFactory.labelName) {
measurementLabels.append(label)
}
}
private func ensureMeasurementAnchor() -> AnchorEntity {
if let measurementAnchor { return measurementAnchor }
let anchor = AnchorEntity(world: matrix_identity_float4x4)
arView?.scene.addAnchor(anchor)
measurementAnchor = anchor
return anchor
}
private func clearMeasurementPreview() {
previewPointEntities.forEach { $0.removeFromParent() }
previewPointEntities.removeAll()
}
/// Pro-Frame-Aufgaben: Live-Vorschau aktualisieren und alle Labels zur Kamera ausrichten.
private func onSceneUpdate() {
updateLivePreview()
updatePipeLivePreview()
billboardLabels()
}
/// Aktualisiert Cursor, Live-Linie und Distanz-Label anhand der aktuellen Bildschirmmitte.
private func updateLivePreview() {
guard measurementPhase == .awaitingFirstPoint || measurementPhase == .awaitingSecondPoint,
let position = raycastScreenCenter() else {
return
}
ensureLivePreviewEntities()
guard measurementPhase == .awaitingSecondPoint, let start = pendingMeasurementStart else {
liveLineEntity?.isEnabled = false
liveLabelEntity?.isEnabled = false
liveDistance = nil
lastLiveDistanceCm = nil
return
}
if let liveLineEntity {
liveLineEntity.isEnabled = true
MeasurementEntityFactory.configureLine(liveLineEntity, from: start, to: position)
}
let distance = simd_distance(start, position)
liveLabelEntity?.isEnabled = true
liveLabelEntity?.position = MeasurementEntityFactory.labelPosition(from: start, to: position)
// Text/Distanz nur bei Änderung (auf cm gerundet) aktualisieren – spart Text-Neuaufbau.
let cm = Int((distance * 100).rounded())
if cm != lastLiveDistanceCm {
lastLiveDistanceCm = cm
liveDistance = distance
if let holder = liveLabelEntity {
holder.children.forEach { $0.removeFromParent() }
holder.addChild(MeasurementEntityFactory.makeText(String(format: "%.2f m", distance)))
}
}
}
private func ensureLivePreviewEntities() {
let anchor = ensureMeasurementAnchor()
if liveLineEntity == nil {
let line = MeasurementEntityFactory.makeLineBase()
line.isEnabled = false
anchor.addChild(line)
liveLineEntity = line
}
if liveLabelEntity == nil {
let label = MeasurementEntityFactory.makeLabelHolder()
label.isEnabled = false
anchor.addChild(label)
liveLabelEntity = label
}
}
private func hideLivePreview() {
liveLineEntity?.isEnabled = false
liveLabelEntity?.isEnabled = false
liveDistance = nil
lastLiveDistanceCm = nil
}
/// Richtet alle sichtbaren Labels (inkl. Live-Label) nur um die Gier-Achse zur Kamera aus.
private func billboardLabels() {
guard let arView else { return }
let camera = arView.cameraTransform.translation
var labels = measurementLabels
if let liveLabelEntity, liveLabelEntity.isEnabled { labels.append(liveLabelEntity) }
guard !labels.isEmpty else { return }
for label in labels {
let position = label.position(relativeTo: nil)
let direction = camera - position
let yaw = atan2(direction.x, direction.z)
label.setOrientation(simd_quatf(angle: yaw, axis: SIMD3(0, 1, 0)), relativeTo: nil)
}
}
// MARK: - Rohrführung (Wegpunkt-Pfad)
/// Startet eine neue Rohrführung. Beendet andere Interaktionsmodi und setzt den Pfad zurück.
func startPipeRouting() {
cancelMeasurement()
isTankSelected = false
clearPipeRoute()
pipeRoutingPhase = .placing
}
/// Setzt einen Wegpunkt aus der Bildschirmmitte und verbindet ihn mit dem vorherigen.
func addPipeWaypoint() {
guard pipeRoutingPhase == .placing else { return }
guard let position = raycastScreenCenter() else {
statusMessage = "Kein Punkt erkannt – ziele auf eine erkannte Fläche."
return
}
let anchor = ensurePipeAnchor()
if let previous = pipeRoute.waypoints.last {
let segment = PipeRouteEntityFactory.makeSegment(from: previous, to: position)
anchor.addChild(segment)
pipeSegmentEntities.append(segment)
}
let waypoint = PipeRouteEntityFactory.makeWaypoint(at: position)
anchor.addChild(waypoint)
pipeWaypointEntities.append(waypoint)
pipeRoute.add(position)
}
/// Entfernt den zuletzt gesetzten Wegpunkt (und sein Segment).
func undoLastPipeWaypoint() {
guard pipeRoute.removeLast() != nil else { return }
pipeWaypointEntities.popLast()?.removeFromParent()
pipeSegmentEntities.popLast()?.removeFromParent()
}
/// Beendet das Setzen; der fertige Pfad bleibt sichtbar.
func finishPipeRouting() {
pipeRoutingPhase = .idle
hidePipeLivePreview()
}
/// Verwirft die komplette Rohrführung.
func cancelPipeRouting() {
clearPipeRoute()
pipeRoutingPhase = .idle
}
private func ensurePipeAnchor() -> AnchorEntity {
if let pipeAnchor { return pipeAnchor }
let anchor = AnchorEntity(world: matrix_identity_float4x4)
arView?.scene.addAnchor(anchor)
pipeAnchor = anchor
return anchor
}
private func clearPipeRoute() {
pipeWaypointEntities.forEach { $0.removeFromParent() }
pipeSegmentEntities.forEach { $0.removeFromParent() }
pipeWaypointEntities.removeAll()
pipeSegmentEntities.removeAll()
hidePipeLivePreview()
pipeRoute.clear()
}
/// Zeichnet das Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte.
private func updatePipeLivePreview() {
guard pipeRoutingPhase == .placing,
let last = pipeRoute.waypoints.last,
let position = raycastScreenCenter() else {
hidePipeLivePreview()
return
}
let segment = ensurePipeLiveSegment()
segment.isEnabled = true
PipeRouteEntityFactory.configureSegment(segment, from: last, to: position)
}
private func ensurePipeLiveSegment() -> ModelEntity {
if let pipeLiveSegment { return pipeLiveSegment }
let segment = PipeRouteEntityFactory.makeSegmentBase()
segment.isEnabled = false
ensurePipeAnchor().addChild(segment)
pipeLiveSegment = segment
return segment
}
private func hidePipeLivePreview() {
pipeLiveSegment?.isEnabled = false
}
// MARK: - Standort (für die GPS-Verortung von Fotos)
private func startLocationUpdates() {
switch locationManager.authorizationStatus {
case .notDetermined:
locationManager.requestWhenInUseAuthorization()
case .authorizedWhenInUse, .authorizedAlways:
locationManager.startUpdatingLocation()
default:
break
}
}
// MARK: - State-Machine
private func send(_ event: ARFlowEvent) {
let commands = machine.reduce(event)
state = machine.state
if state != .immersive { isTankSelected = false }
commands.forEach(execute)
}
private func execute(_ command: ARFlowCommand) {
switch command {
case .setStatus(let status):
statusMessage = status.map(Self.text(for:))
case .runConfiguration(let useImages, let useInitialMap, let resetTracking):
runConfiguration(useImages: useImages, useInitialMap: useInitialMap, resetTracking: resetTracking)
case .setCoachingAutomatic(let enabled):
setCoachingAutomatic(enabled)
case .startMoveAroundTimer:
moveAroundTask = schedule(Self.moveAroundDelay) { $0.send(.moveAroundTimerFired) }
case .startShowOptionsTimer:
showOptionsTask = schedule(Self.showOptionsDelay) { $0.send(.showOptionsTimerFired) }
case .cancelEscalationTimers:
moveAroundTask?.cancel()
showOptionsTask?.cancel()
case .startRelocationTimer:
relocationTask = schedule(Self.relocationTimeout) { $0.send(.relocationTimerFired) }
case .cancelRelocationTimer:
relocationTask?.cancel()
case .startAnchorDetectionTimer:
anchorDetectionTask = schedule(Self.anchorDetectionTimeout) { $0.send(.anchorDetectionTimerFired) }
case .cancelAnchorDetectionTimer:
anchorDetectionTask?.cancel()
case .placeTank(let source):
placeTank(from: source)
case .removeTank:
removeTank()
case .persistWorldMap:
persistWorldMap()
case .persistTankTransform:
persistTankTransform()
case .clearStoredTransform:
storedTransform = nil
persistence.clearTankTransform()
}
}
private func schedule(_ duration: Duration, _ action: @escaping (ARSessionController) -> Void) -> Task<Void, Never> {
Task { @MainActor [weak self] in
try? await Task.sleep(for: duration)
guard !Task.isCancelled, let self else { return }
action(self)
}
}
// MARK: - ARSessionDelegate
nonisolated func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
let trackingState = camera.trackingState
Task { @MainActor in self.handleTrackingState(trackingState) }
}
nonisolated func session(_ session: ARSession, didFailWithError error: Error) {
let message = error.localizedDescription
Task { @MainActor in self.statusMessage = "AR-Fehler: \(message)" }
}
nonisolated func sessionWasInterrupted(_ session: ARSession) {
Task { @MainActor in self.statusMessage = "AR unterbrochen …" }
}
nonisolated func sessionInterruptionEnded(_ session: ARSession) {
Task { @MainActor in self.send(.sessionInterruptionEnded) }
}
nonisolated func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
let transform = anchors.compactMap { $0 as? ARImageAnchor }.first?.transform
guard let transform else { return }
Task { @MainActor in
self.pendingTransform = transform
self.send(.imageAnchorDetected)
}
}
private func handleTrackingState(_ trackingState: ARCamera.TrackingState) {
switch trackingState {
case .normal: send(.trackingNormal)
case .notAvailable: send(.trackingUnavailable)
case .limited(.initializing): send(.trackingInitializing)
case .limited(.relocalizing): send(.trackingRelocalizing)
case .limited(.excessiveMotion): send(.trackingExcessiveMotion)
case .limited(.insufficientFeatures): send(.trackingInsufficientFeatures)
case .limited: break
@unknown default: break
}
}
// MARK: - ARCoachingOverlayViewDelegate
func coachingOverlayViewWillActivate(_ coachingOverlayView: ARCoachingOverlayView) {
send(.coachingWillActivate)
}
func coachingOverlayViewDidDeactivate(_ coachingOverlayView: ARCoachingOverlayView) {
send(.coachingDidDeactivate)
}
// MARK: - CLLocationManagerDelegate
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
Task { @MainActor in self.currentLocation = location }
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in self.startLocationUpdates() }
}
// MARK: - Session-Konfiguration
private func runConfiguration(useImages: Bool, useInitialMap: Bool, resetTracking: Bool) {
let config = configFactory.make(
withImages: useImages,
initialWorldMap: useInitialMap ? initialWorldMap : nil
)
let options: ARSession.RunOptions = resetTracking ? [.resetTracking, .removeExistingAnchors] : []
arView?.session.run(config, options: options)
}
private func setCoachingAutomatic(_ enabled: Bool) {
coaching?.activatesAutomatically = enabled
if !enabled { coaching?.setActive(false, animated: true) }
}
// MARK: - Platzierung & Manipulation
private func placeTank(from source: PlacementSource) {
let transform: simd_float4x4?
switch source {
case .storedTransform: transform = storedTransform
case .screenCenter, .imageAnchor: transform = pendingTransform
}
guard let transform else { return }
placeTank(at: transform)
}
private func placeTank(at worldTransform: simd_float4x4) {
let anchor: AnchorEntity
if let tankAnchor {
anchor = tankAnchor
} else {
anchor = AnchorEntity(world: matrix_identity_float4x4)
arView?.scene.addAnchor(anchor)
tankAnchor = anchor
}
let container: ModelEntity
if let tankEntity {
container = tankEntity
} else {
container = entityFactory.makeContainer(for: persistence.tank)
anchor.addChild(container)
tankEntity = container
installManipulationGestures(on: container)
}
container.transform = Transform(matrix: worldTransform)
updateDepthFadeGroundY()
updateSceneOcclusion()
updateSafetyZones()
}
/// Baut die Sicherheitsabstände auf, aktualisiert ihre Bodenposition oder entfernt sie.
/// Die Ringe hängen am Tank-Anker (nicht am Tank selbst), damit Skalierung/Rotation
/// des Tanks sie nicht verzerrt; sie werden nur auf die Bodenmitte des Tanks gesetzt.
private func updateSafetyZones() {
guard showSafetyZones, let tankAnchor, let tankEntity else {
safetyZoneEntity?.removeFromParent()
safetyZoneEntity = nil
return
}
let entity: Entity
if let safetyZoneEntity {
entity = safetyZoneEntity
} else {
entity = SafetyZoneFactory.makeEntity()
tankAnchor.addChild(entity)
safetyZoneEntity = entity
}
entity.position = tankEntity.position(relativeTo: tankAnchor)
}
private func installManipulationGestures(on entity: ModelEntity) {
guard let arView else { return }
let recognizers = arView.installGestures([.translation, .rotation, .scale], for: entity)
for recognizer in recognizers {
recognizer.addTarget(self, action: #selector(handleManipulationChanged(_:)))
}
}
@objc private func handleManipulationChanged(_ recognizer: UIGestureRecognizer) {
updateSafetyZones() // Ringe live mit dem Tank mitführen.
switch recognizer.state {
case .ended, .cancelled, .failed: persistTankTransform()
default: break
}
}
/// Einzel-Tap: den Tank an-/abwählen (blendet die „Neu platzieren"-Option ein).
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
guard state == .immersive, let arView else { return }
// Während Messen/Rohrführung erfolgt die Auswahl über Fadenkreuz + Button – Taps ignorieren.
guard measurementPhase == .idle, pipeRoutingPhase == .idle else { return }
let point = gesture.location(in: arView)
guard let tankEntity else { return }
if let hit = arView.entity(at: point), Self.entity(hit, isPartOf: tankEntity) {
isTankSelected.toggle()
} else {
isTankSelected = false
}
}
private func removeTank() {
if let tankAnchor { arView?.scene.removeAnchor(tankAnchor) }
tankAnchor = nil
tankEntity = nil
safetyZoneEntity = nil
isTankSelected = false
}
/// Scene-Mesh-Occlusion (LiDAR) nur für oberirdische Tanks – sonst würde das
/// rekonstruierte Boden-Mesh den halbtransparenten, vergrabenen Teil verdecken.
private func updateSceneOcclusion() {
guard let arView else { return }
let wantsOcclusion = useLiDAR && persistence.tank.placement != .belowGround
arView.environment.sceneUnderstanding.options = wantsOcclusion ? [.occlusion] : []
}
private func updateDepthFadeGroundY() {
guard persistence.tank.placement == .belowGround, let tankEntity else { return }
let groundY = tankEntity.position(relativeTo: nil).y
TankDepthFadeMaterial.updateGroundY(groundY, on: tankEntity)
}
// MARK: - Persistenz & Status
private func persistWorldMap() {
arView?.session.getCurrentWorldMap { map, _ in
// Completion läuft nicht auf dem Main-Thread – hier off-main archivieren.
guard let map, let data = WorldMapArchiver.archive(map) else { return }
Task { @MainActor [weak self] in self?.persistence.saveWorldMap(data) }
}
}
private func persistTankTransform() {
guard let tankEntity else { return }
updateDepthFadeGroundY()
persistence.saveTankTransform(tankEntity.transformMatrix(relativeTo: nil).floatArray)
}
private func startMappingPoll() {
mappingPollTask?.cancel()
mappingPollTask = Task { @MainActor in
while !Task.isCancelled {
if let status = self.arView?.session.currentFrame?.worldMappingStatus {
self.worldMappingStatus = status
}
try? await Task.sleep(for: .seconds(1))
}
}
}
// MARK: - Helfer
private static func text(for status: ARStatus) -> String {
switch status {
case .relocalizing: "Position wird wiederhergestellt …"
case .excessiveMotion: "Zu schnelle Bewegung – bitte langsamer."
case .insufficientFeatures: "Zu wenige Merkmale – geh bitte umher und sorge für mehr Licht."
case .unavailable: "AR ist auf diesem Gerät nicht verfügbar."
}
}
/// Prüft, ob eine getroffene Entity der Tank-Container oder eines seiner Kinder ist.
private static func entity(_ entity: Entity, isPartOf root: Entity) -> Bool {
var current: Entity? = entity
while let node = current {
if node === root { return true }
current = node.parent
}
return false
}
}

View File

@ -0,0 +1,234 @@
//
// ARStatusOverlay.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import SwiftUI
import ARKit
/// Dezente, nicht-distraktive Overlays über der AR-View: ein schmaler Status-Banner
/// oben sowie Aktions-Karten unten für die Entscheidungs-States der State-Machine.
struct ARStatusOverlay: View {
let controller: ARSessionController
let onCancel: () -> Void
var body: some View {
ZStack {
// Fadenkreuz exakt im Bildschirmzentrum – dort trifft der Raycast.
if controller.state == .selectLocation {
crosshair
}
VStack {
if let message = controller.statusMessage {
statusBanner(message)
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer()
bottomContent
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.padding()
}
.animation(.easeInOut(duration: 0.25), value: controller.statusMessage)
.animation(.easeInOut(duration: 0.25), value: controller.state)
.animation(.easeInOut(duration: 0.25), value: controller.isTankSelected)
}
// MARK: - Fadenkreuz & Auslöser (Kamera-Style)
private var crosshair: some View {
ZStack {
Circle()
.stroke(.white.opacity(0.9), lineWidth: 2)
.frame(width: 44, height: 44)
Circle()
.fill(.white)
.frame(width: 5, height: 5)
}
.shadow(radius: 2)
.allowsHitTesting(false)
}
private var shutterButton: some View {
VStack(spacing: 10) {
Text("Tank platzieren")
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: .capsule)
Button {
controller.capture()
} label: {
ZStack {
Circle()
.stroke(.white, lineWidth: 4)
.frame(width: 74, height: 74)
Circle()
.fill(.white)
.frame(width: 60, height: 60)
}
.shadow(radius: 4)
.contentShape(.circle)
}
.buttonStyle(.plain)
.accessibilityLabel("Tank platzieren")
}
}
// MARK: - Status-Banner
private func statusBanner(_ message: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(message)
.font(.footnote)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: .capsule)
.frame(maxWidth: .infinity)
}
// MARK: - Untere Aktions-Inhalte je State
@ViewBuilder
private var bottomContent: some View {
switch controller.state {
case .selectLocation:
shutterButton
case .showOptions:
actionCard(
title: "Zu wenige Merkmale erkannt",
message: "Die Umgebung bietet ARKit zu wenig Struktur."
) {
Button("Weiter umhergehen") { controller.keepMoving() }
.buttonStyle(.borderedProminent)
Button("Marker platzieren") { controller.placeAnchorMarker() }
.buttonStyle(.bordered)
Button("Abbrechen", role: .cancel, action: onCancel)
.buttonStyle(.bordered)
}
case .anchorDetection:
hintCard(
icon: "viewfinder",
text: "Richte die Kamera auf den Marker, um ihn zu erkennen.",
showsProgress: true,
onCancel: onCancel
)
case .askToCancel:
actionCard(
title: "Marker nicht erkannt",
message: "Der Marker konnte nicht zuverlässig erkannt werden."
) {
Button("Erneut versuchen") { controller.tryAgainAnchor() }
.buttonStyle(.borderedProminent)
Button("Abbrechen", role: .cancel, action: onCancel)
.buttonStyle(.bordered)
}
case .relocating:
hintCard(
icon: "arrow.triangle.2.circlepath",
text: "Position wird wiederhergestellt …",
showsProgress: true
)
case .relocationFailed:
actionCard(
title: "Position nicht gefunden",
message: "Die gespeicherte Position konnte nicht wiederhergestellt werden. Neu starten und den Tank neu platzieren?"
) {
Button("Neu starten & platzieren", systemImage: "arrow.counterclockwise") {
controller.restart()
}
.buttonStyle(.borderedProminent)
Button("Weiter versuchen") {
controller.retryRelocation()
}
.buttonStyle(.bordered)
Button("Abbrechen", role: .cancel, action: onCancel)
.buttonStyle(.bordered)
}
case .immersive:
if controller.isTankSelected {
TankManipulationPanel(controller: controller)
} else if controller.measurementPhase == .idle && controller.pipeRoutingPhase == .idle {
Text("Tank antippen zum Neu-Platzieren · Ziehen / 2 Finger / Pinch zum Anpassen")
.font(.caption)
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: .capsule)
}
default:
// initializingWorld / initializingNotPossible / userMovesAround übernimmt das
// Coaching-Overlay; immersive/worldNotInitialized brauchen keine Aktion.
EmptyView()
}
}
// MARK: - Bausteine
private func hintCard(
icon: String,
text: String,
showsProgress: Bool = false,
onCancel: (() -> Void)? = nil
) -> some View {
HStack(spacing: 12) {
if showsProgress {
ProgressView()
} else {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(.tint)
}
Text(text)
.font(.subheadline)
Spacer(minLength: 0)
if let onCancel {
Button("Abbrechen", role: .cancel, action: onCancel)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding()
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
}
private func actionCard<Buttons: View>(
title: String,
message: String,
@ViewBuilder buttons: () -> Buttons
) -> some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(spacing: 8) {
buttons()
}
.frame(maxWidth: .infinity)
}
.padding()
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
}
}

View File

@ -0,0 +1,29 @@
//
// ARViewContainer.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import SwiftUI
import RealityKit
/// Dünner UIKit-Adapter: hostet nur die `ARView` und übergibt sie an den Controller.
/// Enthält bewusst keine Logik.
struct ARViewContainer: UIViewRepresentable {
let controller: ARSessionController
func makeCoordinator() -> ARSessionController { controller }
func makeUIView(context: Context) -> ARView {
let view = ARView(frame: .zero)
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: ARView, context: Context) {}
static func dismantleUIView(_ uiView: ARView, coordinator: ARSessionController) {
MainActor.assumeIsolated { coordinator.detach() }
}
}

View File

@ -0,0 +1,57 @@
//
// DirtTextureFactory.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import RealityKit
import UIKit
/// Erzeugt prozedural eine kachelbare Erd-/Dreck-Textur (ohne Bild-Asset) für die Grubenwand,
/// damit sichtbar wird, dass sich der Tank unter der Erde befindet.
@MainActor
enum DirtTextureFactory {
/// Einmalig erzeugte, wiederverwendete Textur.
static let texture: TextureResource? = makeTexture()
private static func makeTexture() -> TextureResource? {
let size = CGSize(width: 256, height: 256)
let image = UIGraphicsImageRenderer(size: size).image { context in
let rect = CGRect(origin: .zero, size: size)
// Erd-Grundton.
UIColor(red: 0.42, green: 0.30, blue: 0.18, alpha: 1).setFill()
context.fill(rect)
// Größere Erdflecken / Steine für Struktur.
for _ in 0..<70 {
let diameter = CGFloat.random(in: 6...26)
let origin = CGPoint(x: .random(in: -10...size.width), y: .random(in: -10...size.height))
let brightness = CGFloat.random(in: -0.12...0.12)
UIColor(
red: max(0, min(1, 0.42 + brightness)),
green: max(0, min(1, 0.30 + brightness)),
blue: max(0, min(1, 0.18 + brightness)),
alpha: 1
).setFill()
context.cgContext.fillEllipse(in: CGRect(x: origin.x, y: origin.y, width: diameter, height: diameter))
}
// Feine Körnung (helle + dunkle Sprenkel).
for _ in 0..<2000 {
let point = CGPoint(x: .random(in: 0..<size.width), y: .random(in: 0..<size.height))
let diameter = CGFloat.random(in: 0.5...2.5)
let isDark = Bool.random()
let color = isDark
? UIColor(white: 0, alpha: .random(in: 0.05...0.2))
: UIColor(white: 1, alpha: .random(in: 0.03...0.12))
color.setFill()
context.cgContext.fillEllipse(in: CGRect(x: point.x, y: point.y, width: diameter, height: diameter))
}
}
guard let cgImage = image.cgImage else { return nil }
return try? TextureResource.generate(from: cgImage, options: .init(semantic: .color))
}
}

View File

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

View File

@ -0,0 +1,37 @@
//
// Measurement.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import Foundation
import simd
/// Eine benannte Strecke zwischen zwei im Raum gewählten Punkten (Welt-Koordinaten).
struct Measurement: Identifiable {
let id = UUID()
var name: String
let start: SIMD3<Float>
let end: SIMD3<Float>
/// Länge der Strecke in Metern.
var distance: Float { simd_distance(start, end) }
/// Länge als lesbarer Text, z. B. „1,23 m".
var formattedDistance: String {
String(format: "%.2f m", distance)
}
}
/// Ablaufphasen beim Anlegen einer Strecke.
enum MeasurementPhase: Equatable {
/// Kein Messvorgang aktiv.
case idle
/// Wartet auf den ersten Punkt.
case awaitingFirstPoint
/// Wartet auf den zweiten Punkt.
case awaitingSecondPoint
/// Beide Punkte gesetzt – wartet auf die Benennung.
case naming
}

View File

@ -0,0 +1,98 @@
//
// MeasurementEntityFactory.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import RealityKit
import UIKit
/// Baut die AR-Visualisierung einer Strecke: zwei Endpunkt-Kugeln, die verbindende Linie
/// sowie ein Text-Label (Bezeichnung + Länge) am Mittelpunkt der Strecke.
///
/// Enthält außerdem wiederverwendbare Bausteine für die *Live*-Vorschau während des Messens
/// (Cursor-Kugel, skalierbare Linie, aktualisierbares Label).
@MainActor
enum MeasurementEntityFactory {
/// Name des Label-Halters – wird zum Wiederfinden fürs Billboarding genutzt.
static let labelName = "MeasurementLabel"
private static let color = UIColor.systemYellow
private static let pointRadius: Float = 0.012
private static let lineThickness: Float = 0.006
private static let labelHeight: Float = 0.06
private static let labelYOffset: Float = 0.03
/// Kleine Kugel für einen (auch vorläufigen) Messpunkt.
static func makePoint(at position: SIMD3<Float>) -> ModelEntity {
let sphere = ModelEntity(
mesh: .generateSphere(radius: pointRadius),
materials: [UnlitMaterial(color: color)]
)
sphere.position = position
return sphere
}
/// Vollständige Strecken-Visualisierung (Punkte + Linie + Label).
static func makeMeasurement(_ measurement: Measurement) -> Entity {
let container = Entity()
container.name = "Measurement-\(measurement.id.uuidString)"
container.addChild(makePoint(at: measurement.start))
container.addChild(makePoint(at: measurement.end))
let line = makeLineBase()
configureLine(line, from: measurement.start, to: measurement.end)
container.addChild(line)
let label = makeLabelHolder()
label.position = labelPosition(from: measurement.start, to: measurement.end)
label.addChild(makeText("\(measurement.name) (\(measurement.formattedDistance))"))
container.addChild(label)
return container
}
/// Basis-Linie der Länge 1 m (entlang +Z). Für die Live-Linie wird sie via ``configureLine`` skaliert/rotiert.
static func makeLineBase() -> ModelEntity {
ModelEntity(
mesh: .generateBox(size: SIMD3(lineThickness, lineThickness, 1)),
materials: [UnlitMaterial(color: color)]
)
}
/// Richtet eine per ``makeLineBase`` erzeugte Linie zwischen zwei Punkten aus (Länge über Z-Skalierung).
static func configureLine(_ line: Entity, from start: SIMD3<Float>, to end: SIMD3<Float>) {
let length = max(simd_distance(start, end), 0.0001)
line.transform = Transform(
scale: SIMD3(1, 1, length),
rotation: simd_quatf(from: SIMD3(0, 0, 1), to: normalize(end - start)),
translation: (start + end) / 2
)
}
/// Leerer Label-Halter (Billboard-Anker). Der Text wird als zentriertes Kind eingehängt.
static func makeLabelHolder() -> Entity {
let holder = Entity()
holder.name = labelName
return holder
}
/// Zentriertes Text-Entity für einen Label-Halter.
static func makeText(_ string: String) -> ModelEntity {
let mesh = MeshResource.generateText(
string,
extrusionDepth: 0.001,
font: .systemFont(ofSize: CGFloat(labelHeight), weight: .semibold)
)
var material = UnlitMaterial(color: .white)
material.faceCulling = .none
let text = ModelEntity(mesh: mesh, materials: [material])
text.position = -text.visualBounds(relativeTo: nil).center
return text
}
/// Position des Labels (Streckenmitte, leicht angehoben).
static func labelPosition(from start: SIMD3<Float>, to end: SIMD3<Float>) -> SIMD3<Float> {
(start + end) / 2 + SIMD3(0, labelYOffset, 0)
}
}

View File

@ -0,0 +1,59 @@
//
// MeasurementListView.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import SwiftUI
/// Von unten einschwebendes Sheet mit den angelegten Strecken. Ist keine vorhanden,
/// wird zum Hinzufügen aufgefordert.
struct MeasurementListView: View {
let controller: ARSessionController
/// Startet einen neuen Messvorgang (schließt das Sheet und lässt den Nutzer zwei Punkte wählen).
let onAddStrecke: () -> Void
var body: some View {
NavigationStack {
Group {
if controller.measurements.isEmpty {
ContentUnavailableView {
Label("Keine Strecken", systemImage: "ruler")
} description: {
Text("Füge eine Strecke hinzu und wähle dafür zwei Punkte im Raum.")
} actions: {
Button("Strecke hinzufügen", systemImage: "plus", action: onAddStrecke)
.buttonStyle(.borderedProminent)
}
} else {
List {
ForEach(controller.measurements) { measurement in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(measurement.name)
.font(.headline)
Text(measurement.formattedDistance)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "ruler")
.foregroundStyle(.secondary)
}
}
.onDelete { controller.deleteMeasurements(at: $0) }
}
}
}
.navigationTitle("Strecken")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Hinzufügen", systemImage: "plus", action: onAddStrecke)
}
}
}
.presentationDetents([.medium, .large])
}
}

View File

@ -0,0 +1,107 @@
//
// PhotoEncoder.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import ImageIO
import UniformTypeIdentifiers
import CoreLocation
import UIKit
/// Kodiert einen Snapshot als JPEG und bettet dabei EXIF-Aufnahmezeit sowie – falls vorhanden –
/// GPS-Metadaten direkt in die Bilddatei ein (ImageIO). So tragen auch exportierte Bilder ihren
/// Aufnahmeort mit sich.
enum PhotoEncoder {
/// Erzeugt JPEG-Daten mit eingebetteten Metadaten. Fällt bei Fehlern auf `UIImage.jpegData` zurück.
static func jpegData(
from image: UIImage,
coordinate: CLLocationCoordinate2D?,
date: Date,
compressionQuality: CGFloat = 0.9
) -> Data? {
guard let cgImage = image.cgImage else {
return image.jpegData(compressionQuality: compressionQuality)
}
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output, UTType.jpeg.identifier as CFString, 1, nil
) else {
return image.jpegData(compressionQuality: compressionQuality)
}
var properties: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: compressionQuality,
kCGImagePropertyOrientation: CGImagePropertyOrientation(image.imageOrientation).rawValue,
kCGImagePropertyExifDictionary: [
kCGImagePropertyExifDateTimeOriginal: Self.exifDateFormatter.string(from: date),
kCGImagePropertyExifDateTimeDigitized: Self.exifDateFormatter.string(from: date),
],
]
if let coordinate {
properties[kCGImagePropertyGPSDictionary] = gpsProperties(for: coordinate, date: date)
}
CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return image.jpegData(compressionQuality: compressionQuality)
}
return output as Data
}
private static func gpsProperties(for coordinate: CLLocationCoordinate2D, date: Date) -> [CFString: Any] {
[
kCGImagePropertyGPSLatitude: abs(coordinate.latitude),
kCGImagePropertyGPSLatitudeRef: coordinate.latitude >= 0 ? "N" : "S",
kCGImagePropertyGPSLongitude: abs(coordinate.longitude),
kCGImagePropertyGPSLongitudeRef: coordinate.longitude >= 0 ? "E" : "W",
kCGImagePropertyGPSDateStamp: Self.gpsDateFormatter.string(from: date),
kCGImagePropertyGPSTimeStamp: Self.gpsTimeFormatter.string(from: date),
]
}
/// EXIF-Zeitstempel in lokaler Zeit („yyyy:MM:dd HH:mm:ss").
private static let exifDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
return formatter
}()
/// GPS-Datum in UTC („yyyy:MM:dd").
private static let gpsDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = "yyyy:MM:dd"
return formatter
}()
/// GPS-Uhrzeit in UTC („HH:mm:ss").
private static let gpsTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
}
private extension CGImagePropertyOrientation {
init(_ orientation: UIImage.Orientation) {
switch orientation {
case .up: self = .up
case .down: self = .down
case .left: self = .left
case .right: self = .right
case .upMirrored: self = .upMirrored
case .downMirrored: self = .downMirrored
case .leftMirrored: self = .leftMirrored
case .rightMirrored: self = .rightMirrored
@unknown default: self = .up
}
}
}

View File

@ -0,0 +1,49 @@
//
// PipeRoute.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import Foundation
import simd
/// Eine Rohrführung als geordnete Folge von Wegpunkten (Welt-Koordinaten), die zu einem
/// zusammenhängenden Pfad verbunden werden – visualisiert die geplante Rohrverlegung.
struct PipeRoute {
private(set) var waypoints: [SIMD3<Float>] = []
var count: Int { waypoints.count }
/// Gesamtlänge des Pfads über alle Segmente in Metern.
var totalLength: Float {
guard waypoints.count > 1 else { return 0 }
return zip(waypoints, waypoints.dropFirst())
.reduce(0) { $0 + simd_distance($1.0, $1.1) }
}
var formattedLength: String {
String(format: "%.2f m", totalLength)
}
mutating func add(_ waypoint: SIMD3<Float>) {
waypoints.append(waypoint)
}
@discardableResult
mutating func removeLast() -> SIMD3<Float>? {
waypoints.isEmpty ? nil : waypoints.removeLast()
}
mutating func clear() {
waypoints.removeAll()
}
}
/// Ablaufphasen beim Erstellen einer Rohrführung.
enum PipeRoutingPhase: Equatable {
/// Kein Rohr-Routing aktiv.
case idle
/// Wegpunkte werden gesetzt.
case placing
}

View File

@ -0,0 +1,54 @@
//
// PipeRouteEntityFactory.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import RealityKit
import UIKit
/// Baut die AR-Visualisierung einer Rohrführung: Wegpunkt-Kugeln und die verbindenden
/// Segmente. Bewusst in einer markanten, naturfremden Farbe (Pink/Magenta) gehalten.
@MainActor
enum PipeRouteEntityFactory {
/// Markante, in der Natur kaum vorkommende Linien-/Punktfarbe.
private static let color = UIColor.systemPink
private static let waypointRadius: Float = 0.02
private static let segmentThickness: Float = 0.012
/// Kugel für einen Wegpunkt.
static func makeWaypoint(at position: SIMD3<Float>) -> ModelEntity {
let sphere = ModelEntity(
mesh: .generateSphere(radius: waypointRadius),
materials: [UnlitMaterial(color: color)]
)
sphere.position = position
return sphere
}
/// Verbindungssegment zwischen zwei Wegpunkten.
static func makeSegment(from start: SIMD3<Float>, to end: SIMD3<Float>) -> ModelEntity {
let segment = makeSegmentBase()
configureSegment(segment, from: start, to: end)
return segment
}
/// Basis-Segment der Länge 1 m (entlang +Z) – für die Live-Vorschau wiederverwendbar.
static func makeSegmentBase() -> ModelEntity {
ModelEntity(
mesh: .generateBox(size: SIMD3(segmentThickness, segmentThickness, 1)),
materials: [UnlitMaterial(color: color)]
)
}
/// Richtet ein Segment zwischen zwei Punkten aus (Länge über Z-Skalierung).
static func configureSegment(_ segment: Entity, from start: SIMD3<Float>, to end: SIMD3<Float>) {
let length = max(simd_distance(start, end), 0.0001)
segment.transform = Transform(
scale: SIMD3(1, 1, length),
rotation: simd_quatf(from: SIMD3(0, 0, 1), to: normalize(end - start)),
translation: (start + end) / 2
)
}
}

View File

@ -0,0 +1,56 @@
//
// ReferenceImageProvider.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import ARKit
import UIKit
/// Liefert die Referenzbilder für die Anker-Erkennung. Bevorzugt die AR-Ressourcengruppe
/// „ARMarkers"; fällt sonst auf einen programmatisch erzeugten Platzhalter-Marker zurück.
enum ReferenceImageProvider {
static func load() -> Set<ARReferenceImage> {
if let group = ARReferenceImage.referenceImages(inGroupNamed: "ARMarkers", bundle: .main) {
return group
}
if let placeholder = makePlaceholder() {
return [placeholder]
}
return []
}
private static func makePlaceholder() -> ARReferenceImage? {
let size = CGSize(width: 512, height: 512)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { context in
let cg = context.cgContext
UIColor.white.setFill()
cg.fill(CGRect(origin: .zero, size: size))
let palette: [UIColor] = [.black, .systemBlue, .systemRed, .systemGreen, .systemOrange, .systemPurple]
let cells = 8
let cellSize = size.width / CGFloat(cells)
for row in 0..<cells {
for column in 0..<cells {
let index = (row * cells + column) % palette.count
palette[index].setFill()
let rect = CGRect(
x: CGFloat(column) * cellSize,
y: CGFloat(row) * cellSize,
width: cellSize,
height: cellSize
)
if (row + column).isMultiple(of: 2) {
cg.fillEllipse(in: rect.insetBy(dx: 4, dy: 4))
} else {
cg.fill(rect.insetBy(dx: 6, dy: 6))
}
}
}
}
guard let cgImage = image.cgImage else { return nil }
return ARReferenceImage(cgImage, orientation: .up, physicalWidth: 0.15)
}
}

View File

@ -0,0 +1,128 @@
//
// SafetyZone.swift
// GasTankAR
//
// Created by Dennis Nemec on 27.08.26.
//
import RealityKit
import UIKit
/// Ein Sicherheitsabstand rund um den Tank: ein Kreis mit gegebenem Radius und Farbe,
/// dessen Mittelpunkt der Tank ist.
///
/// Modular gehalten: Ein weiterer Sicherheitsabstand wird schlicht durch Hinzufügen
/// eines Eintrags zu ``SafetyZone/all`` ergänzt – die Darstellung folgt automatisch.
struct SafetyZone: Identifiable {
let id = UUID()
/// Radius des Kreises in Metern (Abstand vom Tank-Mittelpunkt).
let radius: Float
/// Farbe des Rings.
let color: UIColor
/// Anzeigename, z. B. für Barrierefreiheit oder eine spätere Legende.
let label: String
/// Alle darzustellenden Sicherheitsabstände. Zum Ergänzen genügt ein weiterer Eintrag.
static let all: [SafetyZone] = [
SafetyZone(radius: 3, color: .systemRed, label: "3 m"),
SafetyZone(radius: 5, color: .systemOrange, label: "5 m"),
]
}
/// Baut die flachen, auf dem Boden liegenden Ringe für die Sicherheitsabstände.
@MainActor
enum SafetyZoneFactory {
/// Halbe Linienbreite des Rings in Metern (Ring = Radius ± Halbbreite).
private static let lineHalfWidth: Float = 0.03
/// Anzahl der Segmente pro Ring (höher = runder).
private static let segments = 96
/// Deckkraft der Ringe.
private static let opacity: Float = 0.85
/// Texthöhe der Meter-Beschriftung in Metern.
private static let labelHeight: Float = 0.25
/// Winkelpositionen (im Bogenmaß), an denen die Beschriftung auf dem Ring liegt.
/// Mehrere Positionen sorgen dafür, dass die Meterangabe aus jeder Richtung lesbar ist.
private static let labelAngles: [Float] = [0, .pi / 2, .pi, 3 * .pi / 2]
/// Erzeugt eine Entity mit je einem Ring pro Sicherheitsabstand, zentriert im Ursprung
/// und flach in der XZ-Ebene (y = 0) liegend.
static func makeEntity(for zones: [SafetyZone] = SafetyZone.all) -> Entity {
let root = Entity()
root.name = "SafetyZones"
for zone in zones {
root.addChild(makeRing(for: zone))
}
return root
}
private static func makeRing(for zone: SafetyZone) -> Entity {
let container = Entity()
container.name = "SafetyZone-\(zone.label)"
let mesh = annulusMesh(radius: zone.radius, halfWidth: lineHalfWidth, segments: segments)
var material = UnlitMaterial(color: zone.color.withAlphaComponent(CGFloat(opacity)))
material.blending = .transparent(opacity: .init(floatLiteral: opacity))
// Beidseitig sichtbar – unabhängig von der Blickrichtung auf den Boden.
material.faceCulling = .none
container.addChild(ModelEntity(mesh: mesh, materials: [material]))
for angle in labelAngles {
container.addChild(makeLabel(for: zone, at: angle))
}
return container
}
/// Erzeugt eine flach auf dem Boden liegende Meter-Beschriftung, tangential zum Ring
/// ausgerichtet (Oberkante zeigt zur Mitte, damit sie von außen aufrecht lesbar ist).
private static func makeLabel(for zone: SafetyZone, at angle: Float) -> Entity {
let mesh = MeshResource.generateText(
zone.label,
extrusionDepth: 0.002,
font: .systemFont(ofSize: CGFloat(labelHeight), weight: .bold)
)
var material = UnlitMaterial(color: zone.color)
material.faceCulling = .none
let text = ModelEntity(mesh: mesh, materials: [material])
// Auf den eigenen Ursprung zentrieren.
let bounds = text.visualBounds(relativeTo: nil)
text.position = -bounds.center
let holder = Entity()
holder.addChild(text)
holder.position = SIMD3(sin(angle) * zone.radius, 0.01, cos(angle) * zone.radius)
holder.orientation = simd_quatf(angle: angle, axis: SIMD3(0, 1, 0))
* simd_quatf(angle: -.pi / 2, axis: SIMD3(1, 0, 0))
return holder
}
/// Erzeugt einen flachen Kreisring (Annulus) in der XZ-Ebene.
private static func annulusMesh(radius: Float, halfWidth: Float, segments: Int) -> MeshResource {
var positions: [SIMD3<Float>] = []
var indices: [UInt32] = []
let inner = max(0, radius - halfWidth)
let outer = radius + halfWidth
for i in 0...segments {
let angle = Float(i) / Float(segments) * 2 * .pi
let cosA = cos(angle)
let sinA = sin(angle)
positions.append(SIMD3(inner * cosA, 0, inner * sinA))
positions.append(SIMD3(outer * cosA, 0, outer * sinA))
}
for i in 0..<segments {
let base = UInt32(i * 2)
indices.append(contentsOf: [base, base + 1, base + 2])
indices.append(contentsOf: [base + 2, base + 1, base + 3])
}
var descriptor = MeshDescriptor(name: "safetyRing")
descriptor.positions = MeshBuffers.Positions(positions)
descriptor.primitives = .triangles(indices)
return (try? MeshResource.generate(from: [descriptor])) ?? .generatePlane(width: 0, depth: 0)
}
}

View File

@ -0,0 +1,47 @@
//
// SessionConfigurationFactory.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import ARKit
/// Baut die `ARWorldTrackingConfiguration` für den AR-Modus.
/// Kapselt die LiDAR-/People-Occlusion-/Image-Detection-Logik an einem Ort.
struct SessionConfigurationFactory {
let useLiDAR: Bool
let referenceImages: Set<ARReferenceImage>
func make(withImages: Bool, initialWorldMap: ARWorldMap?) -> ARWorldTrackingConfiguration {
let config = ARWorldTrackingConfiguration()
config.planeDetection = [.horizontal]
if useLiDAR {
if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) {
config.sceneReconstruction = .mesh
}
if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
config.frameSemantics.insert(.sceneDepth)
}
} else {
config.sceneReconstruction = []
config.frameSemantics = []
}
// People Occlusion funktioniert auch ohne LiDAR (A12+): Personen zwischen Kamera
// und Tank verdecken das Objekt korrekt. (Beliebige Objekte wie Stühle brauchen LiDAR.)
if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) {
config.frameSemantics.insert(.personSegmentationWithDepth)
}
if let initialWorldMap {
config.initialWorldMap = initialWorldMap
}
if withImages {
config.detectionImages = referenceImages
config.maximumNumberOfTrackedImages = 1
}
return config
}
}

View File

@ -0,0 +1,63 @@
//
// TankDepthFadeMaterial.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import RealityKit
import Metal
/// Kapselt das Höhen-Fade-CustomMaterial für den unterirdischen Tank:
/// verblasst die Opacity anhand der Welt-Höhe (Kopf opak, vergrabener Teil transparent).
@MainActor
enum TankDepthFadeMaterial {
/// Fade-Band (Meter) unterhalb der Bodenkante, über das die Transparenz einsetzt.
static let band: Float = 0.15
/// Minimale Opacity des vergrabenen Teils.
static let belowAlpha: Float = 0.3
private static let shader: CustomMaterial.SurfaceShader? = {
guard let library = MTLCreateSystemDefaultDevice()?.makeDefaultLibrary() else { return nil }
return CustomMaterial.SurfaceShader(named: "tankDepthFadeSurface", in: library)
}()
/// Ersetzt die Materialien der Entity (rekursiv) durch das Fade-CustomMaterial.
static func apply(to entity: Entity) {
guard let shader else { return }
forEachModelEntity(entity) { modelEntity in
guard var component = modelEntity.model else { return }
component.materials = component.materials.map { material in
guard var custom = try? CustomMaterial(from: material, surfaceShader: shader) else {
return material
}
custom.blending = .transparent(opacity: 1.0)
custom.custom.value = SIMD4<Float>(0, band, belowAlpha, 0)
return custom
}
modelEntity.model = component
}
}
/// Aktualisiert die Boden-Höhe (Welt-Y) in bereits angewandten Fade-Materialien.
static func updateGroundY(_ groundY: Float, on entity: Entity) {
forEachModelEntity(entity) { modelEntity in
guard var component = modelEntity.model else { return }
component.materials = component.materials.map { material in
guard var custom = material as? CustomMaterial else { return material }
custom.custom.value = SIMD4<Float>(groundY, band, belowAlpha, 0)
return custom
}
modelEntity.model = component
}
}
private static func forEachModelEntity(_ entity: Entity, _ body: (ModelEntity) -> Void) {
if let modelEntity = entity as? ModelEntity {
body(modelEntity)
}
for child in entity.children {
forEachModelEntity(child, body)
}
}
}

View File

@ -0,0 +1,93 @@
//
// TankEntityFactory.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import RealityKit
import UIKit
/// Baut die darzustellende Tank-Entity (aus `GasTank.usdz`): lädt/cached das Modell,
/// richtet es je nach Aufstellungsart aus, erzeugt die Kollisionsform und – bei
/// unterirdischer Aufstellung – den Höhen-Transparenz-Effekt.
@MainActor
final class TankEntityFactory {
/// Name des USDZ-Assets im Bundle.
private static let modelName = "GasTank"
/// Anteil der Modellhöhe, der bei unterirdischer Aufstellung oben herausragt („Kopf").
private static let belowGroundExposedFraction: Float = 0.25
/// Zusätzlicher Abstand zwischen Tank-Umkreis und Grubenwand (in Metern) – großzügig,
/// damit die Grube nicht gequetscht wirkt.
private static let pitMargin: Float = 0.4
/// Zusätzliche Tiefe der Grubensohle unter der Tankunterkante (in Metern).
private static let pitFloorClearance: Float = 0.1
/// Vorab geladenes Modell; wird pro Platzierung geklont.
private var loadedModel: Entity?
/// Lädt das USDZ asynchron vor (blockiert den Main-Actor nicht).
func preload() {
guard loadedModel == nil else { return }
Task { @MainActor in
self.loadedModel = try? await Entity(named: Self.modelName)
}
}
/// Erzeugt den manipulierbaren Container (mit Kollision fürs Gesten-Hit-Testing).
func makeContainer(for tank: Tank) -> ModelEntity {
let model = loadModelClone()
let bounds = model.visualBounds(relativeTo: nil)
let container = ModelEntity()
switch tank.placement {
case .aboveGround:
model.position.y -= bounds.min.y // Unterkante auf die Fläche
case .belowGround:
// Modell absenken, sodass nur der obere Teil („Kopf") herausragt …
let exposed = bounds.extents.y * Self.belowGroundExposedFraction
let buried = bounds.extents.y - exposed
model.position.y -= (bounds.max.y - exposed)
// … und den Boden „aufreißen": eine runde Grube ausheben, in welcher der Tank steht.
// Radius = Umkreis der Grundfläche (halbe Diagonale) + großzügiger Rand.
let footprintRadius = 0.5 * (bounds.extents.x * bounds.extents.x + bounds.extents.z * bounds.extents.z).squareRoot()
container.addChild(TankPitFactory.makePit(
radius: footprintRadius + Self.pitMargin,
depth: buried + Self.pitFloorClearance,
center: bounds.center
))
}
container.addChild(model)
let colliderBounds = model.visualBounds(relativeTo: container)
container.collision = CollisionComponent(shapes: [
ShapeResource
.generateBox(size: colliderBounds.extents)
.offsetBy(translation: colliderBounds.center)
])
// Auswahl-Markierung (Eck-Winkel) – zunächst versteckt, wird bei Auswahl eingeblendet.
let highlight = TankHighlightFactory.makeHighlight(for: colliderBounds)
highlight.isEnabled = false
container.addChild(highlight)
return container
}
/// Liefert einen Klon des vorab geladenen Modells. Fällt auf synchrones Laden
/// bzw. – wenn das USDZ fehlt – auf einen einfachen Cube zurück.
private func loadModelClone() -> Entity {
if loadedModel == nil {
loadedModel = try? Entity.load(named: Self.modelName)
}
if let loadedModel {
return loadedModel.clone(recursive: true)
}
return ModelEntity(
mesh: .generateBox(size: 0.3),
materials: [SimpleMaterial(color: .white, isMetallic: false)]
)
}
}

View File

@ -0,0 +1,69 @@
//
// TankHighlightFactory.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import RealityKit
import UIKit
/// Baut die Auswahl-Markierung um den Tank: ein (ansonsten unsichtbarer) Quader, dessen
/// acht Ecken mit kräftig gefärbten Winkeln hervorgehoben werden – deutlich sichtbar,
/// ohne den Tank selbst zu verdecken.
@MainActor
enum TankHighlightFactory {
/// Name der Markierungs-Entity (zum Ein-/Ausblenden bei Auswahl).
static let name = "TankSelectionHighlight"
private static let color = UIColor.systemTeal
/// Erzeugt die Eck-Winkel passend zur Bounding-Box (im Koordinatensystem des Containers).
static func makeHighlight(for bounds: BoundingBox) -> Entity {
let root = Entity()
root.name = name
let extents = bounds.extents
let smallestEdge = min(extents.x, min(extents.y, extents.z))
let armLength = max(0.05, smallestEdge * 0.3)
let thickness = max(0.008, smallestEdge * 0.05)
var material = UnlitMaterial(color: color)
material.faceCulling = .none
for x in [bounds.min.x, bounds.max.x] {
for y in [bounds.min.y, bounds.max.y] {
for z in [bounds.min.z, bounds.max.z] {
let corner = SIMD3(x, y, z)
// Vorzeichen: Winkel zeigt von der Ecke nach innen (zur Boxmitte).
let signX: Float = (x == bounds.min.x) ? 1 : -1
let signY: Float = (y == bounds.min.y) ? 1 : -1
let signZ: Float = (z == bounds.min.z) ? 1 : -1
root.addChild(beam(
size: SIMD3(armLength, thickness, thickness),
center: corner + SIMD3(signX * armLength / 2, 0, 0),
material: material
))
root.addChild(beam(
size: SIMD3(thickness, armLength, thickness),
center: corner + SIMD3(0, signY * armLength / 2, 0),
material: material
))
root.addChild(beam(
size: SIMD3(thickness, thickness, armLength),
center: corner + SIMD3(0, 0, signZ * armLength / 2),
material: material
))
}
}
}
return root
}
private static func beam(size: SIMD3<Float>, center: SIMD3<Float>, material: UnlitMaterial) -> ModelEntity {
let beam = ModelEntity(mesh: .generateBox(size: size), materials: [material])
beam.position = center
return beam
}
}

View File

@ -0,0 +1,144 @@
//
// TankManipulationPanel.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import SwiftUI
/// Steuer-Panel für den ausgewählten Tank: Höhe (↑↓), Verschieben auf dem Boden
/// (vor/zurück/links/rechts) und Drehen (⟲⟳). Erscheint von unten; Pinch bleibt möglich.
/// Buttons unterstützen Gedrückt-halten für kontinuierliche Anpassung.
struct TankManipulationPanel: View {
let controller: ARSessionController
var body: some View {
VStack(spacing: 14) {
HStack {
Text("Tank anpassen")
.font(.headline)
Spacer()
Button {
controller.deselectTank()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Schließen")
}
HStack(alignment: .center, spacing: 24) {
heightGroup
moveGroup
rotateGroup
}
Button("Neu platzieren", systemImage: "arrow.up.and.down.and.arrow.left.and.right") {
controller.reposition()
}
.buttonStyle(.bordered)
}
.padding()
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
}
// MARK: - Gruppen
private var heightGroup: some View {
controlGroup(title: "Höhe") {
VStack(spacing: 8) {
holdButton("chevron.up", label: "Höher") { controller.adjustTankHeight(up: true) }
holdButton("chevron.down", label: "Tiefer") { controller.adjustTankHeight(up: false) }
}
}
}
private var moveGroup: some View {
controlGroup(title: "Verschieben") {
VStack(spacing: 8) {
holdButton("arrow.up", label: "Vorwärts") { controller.moveTank(forward: true) }
HStack(spacing: 8) {
holdButton("arrow.left", label: "Nach links") { controller.moveTank(left: true) }
holdButton("arrow.right", label: "Nach rechts") { controller.moveTank(left: false) }
}
holdButton("arrow.down", label: "Rückwärts") { controller.moveTank(forward: false) }
}
}
}
private var rotateGroup: some View {
controlGroup(title: "Drehen") {
HStack(spacing: 8) {
holdButton("rotate.left", label: "Gegen den Uhrzeigersinn") { controller.rotateTank(clockwise: false) }
holdButton("rotate.right", label: "Im Uhrzeigersinn") { controller.rotateTank(clockwise: true) }
}
}
}
private func controlGroup<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(spacing: 8) {
content()
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func holdButton(_ systemImage: String, label: String, action: @escaping () -> Void) -> some View {
HoldRepeatButton(
systemImage: systemImage,
accessibilityLabel: label,
action: action,
onRelease: { controller.commitTankEdit() }
)
}
}
/// Runder Button, der bei Gedrückt-halten die Aktion wiederholt (Tastenwiederholung).
/// Einzel-Tap = ein Schritt; Halten = fortlaufend. Beim Loslassen wird `onRelease` aufgerufen.
private struct HoldRepeatButton: View {
let systemImage: String
let accessibilityLabel: String
let action: () -> Void
let onRelease: () -> Void
@State private var repeatTask: Task<Void, Never>?
private static let initialDelay: Duration = .milliseconds(350)
private static let repeatInterval: Duration = .milliseconds(60)
var body: some View {
Image(systemName: systemImage)
.font(.title3)
.frame(width: 48, height: 48)
.background(.ultraThinMaterial, in: .circle)
.contentShape(.circle)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in if repeatTask == nil { start() } }
.onEnded { _ in stop() }
)
.accessibilityLabel(accessibilityLabel)
.accessibilityAddTraits(.isButton)
}
private func start() {
action() // sofortiger erster Schritt (Einzel-Tap)
repeatTask = Task { @MainActor in
try? await Task.sleep(for: Self.initialDelay)
while !Task.isCancelled {
action()
try? await Task.sleep(for: Self.repeatInterval)
}
}
}
private func stop() {
repeatTask?.cancel()
repeatTask = nil
onRelease()
}
}

View File

@ -0,0 +1,211 @@
//
// TankPitFactory.swift
// GasTankAR
//
// Created by Dennis Nemec on 28.08.26.
//
import RealityKit
import UIKit
/// Baut die „ausgehobene Grube" für einen unterirdischen Tank als **Zylinder**: Der Boden wird
/// mit einer kreisrunden Occlusion-Maske „aufgerissen" und darunter ein runder Schacht
/// ausgehoben (sichtbarer Wandmantel + Sohle), in dem der Tank steht.
///
/// Aufbau der zurückgegebenen Entity (lokaler Ursprung = Bodenkante, y = 0):
/// - Wandmantel + Sohle reichen von y = 0 bis y = -`depth`.
/// - Eine kreisrunde Occlusion-Maske in der Bodenebene verdeckt alles Vergrabene außerhalb
/// der Öffnung, sodass der Eindruck eines echten Lochs im Boden entsteht.
@MainActor
enum TankPitFactory {
/// Anzahl der Segmente pro Kreis (höher = runder).
private static let segments = 48
/// Radius der kreisförmigen Boden-Occlusion-Maske (muss die Öffnung deutlich überragen).
private static let maskOuterRadius: Float = 20
/// Die Maske liegt minimal unter der Bodenkante, damit sie nicht mit bodennahen
/// Overlays (z. B. den Sicherheitsabstands-Ringen bei y = 0) um die Tiefe konkurriert.
private static let maskYOffset: Float = -0.005
/// Breite der hervorgehobenen Rand-Ringe.
private static let rimBand: Float = 0.03
/// Farbe des Wandmantels (heller, damit sich die Grube klar vom Boden abhebt).
private static let wallColor = UIColor(red: 0.62, green: 0.47, blue: 0.31, alpha: 1)
/// Farbe der Grubensohle (dunkler als die Wände – erzeugt Tiefeneindruck).
private static let floorColor = UIColor(red: 0.24, green: 0.18, blue: 0.12, alpha: 1)
/// Farbe der hervorgehobenen Rand-Ringe.
private static let rimColor = UIColor.systemYellow
/// Erzeugt die zylindrische Grube.
/// - Parameters:
/// - radius: Radius der Grube (x/z) in Metern.
/// - depth: Tiefe der Grube (y) in Metern.
/// - center: horizontaler Mittelpunkt relativ zum Container (y wird ignoriert).
static func makePit(radius: Float, depth: Float, center: SIMD3<Float>) -> Entity {
let pit = Entity()
pit.name = "TankPit"
pit.position = SIMD3(center.x, 0, center.z)
// Sichtbarer Wandmantel mit Erd-/Dreck-Textur …
pit.addChild(wallModel(radius: radius, depth: depth))
// … runde Sohle (dunkler) …
pit.addChild(model(mesh: floorMesh(radius: radius, depth: depth), color: floorColor))
// … hervorgehobene Rand-Ringe oben und unten …
pit.addChild(model(mesh: ringMesh(radius: radius, y: 0), color: rimColor))
pit.addChild(model(mesh: ringMesh(radius: radius, y: -depth), color: rimColor))
// … und die kreisrunde Occlusion-Maske mit Loch über der Öffnung.
let mask = ModelEntity(
mesh: maskMesh(holeRadius: radius, outerRadius: maskOuterRadius),
materials: [OcclusionMaterial()]
)
mask.position.y = maskYOffset
pit.addChild(mask)
return pit
}
// MARK: - Bausteine
private static func model(mesh: MeshResource, color: UIColor) -> ModelEntity {
var material = UnlitMaterial(color: color)
material.faceCulling = .none // beidseitig sichtbar (Blick von außen und in die Grube)
return ModelEntity(mesh: mesh, materials: [material])
}
/// Wandmantel mit gekachelter Erd-Textur (Fallback: einfarbig).
private static func wallModel(radius: Float, depth: Float) -> ModelEntity {
var material = UnlitMaterial(color: wallColor)
if let texture = DirtTextureFactory.texture {
material.color = .init(tint: .white, texture: .init(texture))
}
material.faceCulling = .none
return ModelEntity(mesh: wallMesh(radius: radius, depth: depth), materials: [material])
}
/// Kreispunkte in der XZ-Ebene auf gegebener Höhe.
private static func circle(radius: Float, y: Float) -> [SIMD3<Float>] {
(0..<segments).map { index in
let angle = Float(index) / Float(segments) * 2 * .pi
return SIMD3(radius * cos(angle), y, radius * sin(angle))
}
}
/// Wandmantel (senkrecht, y = 0 … -depth) inkl. UV-Koordinaten für die gekachelte Textur.
private static func wallMesh(radius: Float, depth: Float) -> MeshResource {
let top = circle(radius: radius, y: 0)
let bottom = circle(radius: radius, y: -depth)
// Kachelgröße ~0,5 m: horizontal über den Umfang, vertikal über die Tiefe.
let tileSize: Float = 0.5
let uRepeat = (2 * .pi * radius) / tileSize
let vMax = depth / tileSize
var builder = TriangleMeshBuilder()
for i in 0..<segments {
let next = (i + 1) % segments
let u0 = Float(i) / Float(segments) * uRepeat
let u1 = Float(i + 1) / Float(segments) * uRepeat
builder.addQuad(
top[i], top[next], bottom[next], bottom[i],
uv: (SIMD2(u0, 0), SIMD2(u1, 0), SIMD2(u1, vMax), SIMD2(u0, vMax)),
doubleSided: true
)
}
return builder.makeMesh(name: "tankPitWall")
}
/// Runde Sohle bei y = -depth (Dreiecksfächer um den Mittelpunkt).
private static func floorMesh(radius: Float, depth: Float) -> MeshResource {
let rim = circle(radius: radius, y: -depth)
let center = SIMD3<Float>(0, -depth, 0)
var builder = TriangleMeshBuilder()
for i in 0..<segments {
let next = (i + 1) % segments
builder.addTriangle(center, rim[i], rim[next], doubleSided: true)
}
return builder.makeMesh(name: "tankPitFloor")
}
/// Flacher, hervorgehobener Ring (Rand) auf gegebener Höhe.
private static func ringMesh(radius: Float, y: Float) -> MeshResource {
let inner = circle(radius: max(0, radius - rimBand), y: y)
let outer = circle(radius: radius + rimBand, y: y)
var builder = TriangleMeshBuilder()
for i in 0..<segments {
let next = (i + 1) % segments
builder.addQuad(inner[i], inner[next], outer[next], outer[i], doubleSided: true)
}
return builder.makeMesh(name: "tankPitRim")
}
/// Waagerechte Fläche in der Bodenebene mit kreisrundem Loch (Ring zwischen Loch und Außenrand).
private static func maskMesh(holeRadius: Float, outerRadius: Float) -> MeshResource {
let inner = circle(radius: holeRadius, y: 0)
let outer = circle(radius: outerRadius, y: 0)
var builder = TriangleMeshBuilder()
for i in 0..<segments {
let next = (i + 1) % segments
builder.addQuad(inner[i], inner[next], outer[next], outer[i], doubleSided: true)
}
return builder.makeMesh(name: "tankPitMask")
}
}
/// Kleiner Helfer, der Dreiecke/Vierecke zu einem Mesh sammelt.
private struct TriangleMeshBuilder {
private var positions: [SIMD3<Float>] = []
private var indices: [UInt32] = []
private var uvs: [SIMD2<Float>] = []
/// Viereck mit UV-Koordinaten (für texturierte Flächen).
mutating func addQuad(
_ a: SIMD3<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>, _ d: SIMD3<Float>,
uv: (SIMD2<Float>, SIMD2<Float>, SIMD2<Float>, SIMD2<Float>),
doubleSided: Bool = false
) {
let base = UInt32(positions.count)
positions.append(contentsOf: [a, b, c, d])
uvs.append(contentsOf: [uv.0, uv.1, uv.2, uv.3])
indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
if doubleSided {
indices.append(contentsOf: [base, base + 2, base + 1, base, base + 3, base + 2])
}
}
mutating func addTriangle(
_ a: SIMD3<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>,
doubleSided: Bool = false
) {
let base = UInt32(positions.count)
positions.append(contentsOf: [a, b, c])
indices.append(contentsOf: [base, base + 1, base + 2])
if doubleSided {
indices.append(contentsOf: [base, base + 2, base + 1])
}
}
mutating func addQuad(
_ a: SIMD3<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>, _ d: SIMD3<Float>,
doubleSided: Bool = false
) {
let base = UInt32(positions.count)
positions.append(contentsOf: [a, b, c, d])
indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
if doubleSided {
indices.append(contentsOf: [base, base + 2, base + 1, base, base + 3, base + 2])
}
}
func makeMesh(name: String) -> MeshResource {
var descriptor = MeshDescriptor(name: name)
descriptor.positions = MeshBuffers.Positions(positions)
descriptor.primitives = .triangles(indices)
if uvs.count == positions.count {
descriptor.textureCoordinates = MeshBuffers.TextureCoordinates(uvs)
}
return (try? MeshResource.generate(from: [descriptor])) ?? .generateBox(size: 0.01)
}
}

View File

@ -0,0 +1,56 @@
//
// TankShaders.metal
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
#include <metal_stdlib>
#include <RealityKit/RealityKit.h>
using namespace metal;
// Sampler für die durchgereichten Texturen des Originalmaterials.
constexpr sampler textureSampler(address::clamp_to_edge, filter::bicubic);
/// Surface-Shader für den unterirdischen Tank: reicht Basisfarbe/Roughness/Metallic
/// des Originalmaterials durch und verblasst die Opacity anhand der Welt-Höhe.
///
/// custom_parameter():
/// x = Boden-Höhe (Welt-Y)
/// y = Fade-Band (Meter, über die die Transparenz einsetzt)
/// z = minimale Opacity des vergrabenen Teils
[[visible]]
void tankDepthFadeSurface(realitykit::surface_parameters params)
{
auto tex = params.textures();
auto constants = params.material_constants();
float2 uv = params.geometry().uv0();
// Modelle aus USDZ nutzen eine gespiegelte Y-UV.
uv.y = 1.0 - uv.y;
// Basisfarbe = Tint * Textur.
half3 tint = (half3)constants.base_color_tint();
half3 color = (half3)tex.base_color().sample(textureSampler, uv).rgb;
color *= tint;
params.surface().set_base_color(color);
// Roughness / Metallic durchreichen (wie PhysicallyBasedMaterial).
half roughness = tex.roughness().sample(textureSampler, uv).r * constants.roughness_scale();
params.surface().set_roughness(roughness);
half metallic = tex.metallic().sample(textureSampler, uv).r * constants.metallic_scale();
params.surface().set_metallic(metallic);
// Höhenbasierter Alpha-Fade.
float3 worldPosition = params.geometry().world_position();
float4 custom = params.uniforms().custom_parameter();
float groundY = custom.x;
float band = max(custom.y, 1e-3);
float belowAlpha = custom.z;
float t = clamp((groundY - worldPosition.y) / band, 0.0, 1.0);
float alpha = mix(1.0, belowAlpha, t);
params.surface().set_opacity(half(alpha));
}

View File

@ -0,0 +1,20 @@
//
// WorldMapArchiver.swift
// GasTankAR
//
// Created by Dennis Nemec on 26.08.26.
//
import ARKit
/// Kapselt das (De-)Serialisieren einer `ARWorldMap` via `NSKeyedArchiver`.
/// Bewusst thread-neutral gehalten – das Archivieren sollte off-main erfolgen (teuer).
enum WorldMapArchiver {
static func archive(_ worldMap: ARWorldMap) -> Data? {
try? NSKeyedArchiver.archivedData(withRootObject: worldMap, requiringSecureCoding: true)
}
static func unarchive(_ data: Data) -> ARWorldMap? {
try? NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data)
}
}