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