Files
Gas-Tank-AR-App/GasTankAR/Views/TankAR/ARSessionController.swift
2026-09-02 20:54:36 +02:00

874 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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