// // 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 /// Vom Nutzer aktivierte, automatisch am Tank angelegte Maße. private(set) var visibleTankDimensions: Set = [] /// Transparenz des vergrabenen Tankkörpers: 0 = opak, 1 = vollständig transparent. private(set) var undergroundTransparency: Double = 1.0 - Double(TankDepthFadeMaterial.belowAlpha) /// Ob die Grube beim aktuell platzierten unterirdischen Tank sichtbar ist. private(set) var showsPit: Bool /// Unterirdische Tanks bieten zusätzliche Darstellungsoptionen. var isUndergroundTank: Bool { persistence.tank.placement == .belowGround } /// Ob der aktuell platzierte Tank halbtransparent dargestellt wird. var isSemiTransparentTank: Bool { isUndergroundTank && !showsPit } /// 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] = [:] @ObservationIgnored private var tankDimensionEntities: [TankDimension: Entity] = [:] @ObservationIgnored private var tankBaseExtents: SIMD3? @ObservationIgnored private var lastTankDimensionScale: SIMD3? /// Label-Halter für das Billboarding (Ausrichtung zur Kamera). @ObservationIgnored private var measurementLabels: [Entity] = [] @ObservationIgnored private var tankDimensionLabels: [Entity] = [] /// Vorläufige Punkt-Kugeln während eines laufenden Messvorgangs. @ObservationIgnored private var previewPointEntities: [Entity] = [] @ObservationIgnored private var pendingMeasurementStart: SIMD3? @ObservationIgnored private var pendingMeasurementEnd: SIMD3? /// 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] = [] @ObservationIgnored private var pipeSegmentIndices: [Int] = [] /// Index der Verbindung, die nach Doppeltipp bestätigt entfernt werden kann. private(set) var selectedPipeSegmentIndex: Int? /// 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? @ObservationIgnored private var showOptionsTask: Task? @ObservationIgnored private var anchorDetectionTask: Task? @ObservationIgnored private var relocationTask: Task? @ObservationIgnored private var mappingPollTask: Task? 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, arExposure: Double = 1.0) { self.persistence = persistence self.useLiDAR = useLiDAR self.showsPit = persistence.tank.placement == .belowGround && !persistence.tank.showLidOnly self.entityFactory = TankEntityFactory(aboveGroundLightMultiplier: Float(arExposure)) self.configFactory = SessionConfigurationFactory( useLiDAR: useLiDAR, referenceImages: ReferenceImageProvider.load() ) self.machine = ARFlowStateMachine(hasStoredTransform: persistence.tankTransform != nil) self.measurements = persistence.savedMeasurements self.pipeRoute = persistence.savedPipeRoute 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) let doubleTap = UITapGestureRecognizer( target: self, action: #selector(handlePipeSegmentDoubleTap(_:)) ) doubleTap.numberOfTapsRequired = 2 view.addGestureRecognizer(doubleTap) tap.require(toFail: doubleTap) // 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(persistence.tank) restorePersistedAnnotations() 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 } /// Passt ausschließlich die Transparenz des vergrabenen Tankkörpers an. func setUndergroundTransparency(_ value: Double) { guard isSemiTransparentTank else { return } undergroundTransparency = min(max(value, 0), 1) updateDepthFadeGroundY() } /// Schaltet die Grube eines unterirdischen Tanks direkt im AR-Modus um. func setShowsPit(_ visible: Bool) { guard isUndergroundTank, visible != showsPit, let currentTank = tankEntity, let tankAnchor else { return } showsPit = visible var tank = persistence.tank tank.showLidOnly = !visible let transform = currentTank.transform currentTank.removeFromParent() tankDimensionEntities.removeAll() tankDimensionLabels.removeAll() tankBaseExtents = nil lastTankDimensionScale = nil let replacement = entityFactory.makeContainer(for: tank) replacement.transform = transform tankAnchor.addChild(replacement) tankEntity = replacement installManipulationGestures(on: replacement) updateDepthFadeGroundY() updateSafetyZones() updateTankDimensions() } /// Setzt die Tank-Skalierung auf die Originalgröße zurück. func resetTankScale() { guard let tankEntity else { return } tankEntity.scale = SIMD3(repeating: 1) updateSafetyZones() updateTankDimensionValues() persistTankTransform() } // 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(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(-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 = locationManager.location?.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) /// Schaltet ein automatisch am Tank angelegtes Maß ein oder aus. func setTankDimension(_ dimension: TankDimension, visible: Bool) { if visible { visibleTankDimensions.insert(dimension) } else { visibleTankDimensions.remove(dimension) } updateTankDimensions() } /// 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) persistence.saveMeasurements(measurements) 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) persistence.saveMeasurements(measurements) } /// 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? { 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(column.x, column.y, column.z) } private func addMeasurementPreviewPoint(at position: SIMD3) { 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 restorePersistedAnnotations() { for measurement in measurements { renderMeasurement(measurement) } rebuildPipeRouteEntities() } private func rebuildPipeRouteEntities() { pipeWaypointEntities.forEach { $0.removeFromParent() } pipeSegmentEntities.forEach { $0.removeFromParent() } pipeWaypointEntities.removeAll() pipeSegmentEntities.removeAll() pipeSegmentIndices.removeAll() guard !pipeRoute.waypoints.isEmpty else { return } let anchor = ensurePipeAnchor() for waypoint in pipeRoute.waypoints { let entity = PipeRouteEntityFactory.makeWaypoint(at: waypoint) anchor.addChild(entity) pipeWaypointEntities.append(entity) } for (index, pair) in zip(pipeRoute.waypoints, pipeRoute.waypoints.dropFirst()).enumerated() where !pipeRoute.isSegmentRemoved(at: index) { let segment = PipeRouteEntityFactory.makeSegment(from: pair.0, to: pair.1) anchor.addChild(segment) pipeSegmentEntities.append(segment) pipeSegmentIndices.append(index) } } private func updateTankDimensions() { tankDimensionEntities.values.forEach { $0.removeFromParent() } tankDimensionEntities.removeAll() tankDimensionLabels.removeAll() guard let tankEntity, let tankModel = tankEntity.findEntity(named: TankEntityFactory.modelEntityName) else { return } // Nur das eigentliche Modell vermessen; Grube, Occlusion-Maske und Markierung // dürfen die Tankmaße nicht beeinflussen. let bounds = tankModel.visualBounds(relativeTo: tankEntity) tankBaseExtents = bounds.extents lastTankDimensionScale = tankEntity.scale guard !visibleTankDimensions.isEmpty else { return } let lower = bounds.min let upper = bounds.max let center = bounds.center let dimensionGap = Swift.max(Swift.max(bounds.extents.x, bounds.extents.y), bounds.extents.z) * 0.08 let labelGap: Float = 0.02 for dimension in visibleTankDimensions { let start: SIMD3 let end: SIMD3 let labelOffset: SIMD3 switch dimension { case .height: start = SIMD3(upper.x + dimensionGap, lower.y, center.z) end = SIMD3(upper.x + dimensionGap, upper.y, center.z) labelOffset = SIMD3(labelGap, 0, 0) case .length: // Die Längenlinie liegt direkt an der unteren Modellkante. Ein // zusätzlicher Abstand nach unten würde bei unterirdischen Tanks // unter der Grubensohle liegen und dadurch verdeckt werden. start = SIMD3(lower.x - dimensionGap, lower.y, lower.z) end = SIMD3(lower.x - dimensionGap, lower.y, upper.z) labelOffset = SIMD3(-labelGap, 0, 0) case .width: start = SIMD3(lower.x, upper.y + dimensionGap, center.z) end = SIMD3(upper.x, upper.y + dimensionGap, center.z) labelOffset = SIMD3(0, labelGap, 0) } let entity = MeasurementEntityFactory.makeDimension( dimension, from: start, to: end, labelOffset: labelOffset ) tankEntity.addChild(entity) tankDimensionEntities[dimension] = entity if let label = entity.findEntity(named: MeasurementEntityFactory.labelName) { tankDimensionLabels.append(label) } } } private func updateTankDimensionValues() { guard let tankEntity, let tankBaseExtents, !visibleTankDimensions.isEmpty else { return } let scale = tankEntity.scale guard lastTankDimensionScale != scale else { return } lastTankDimensionScale = scale let absoluteScale = SIMD3( abs(scale.x), abs(scale.y), abs(scale.z) ) let scaledExtents = tankBaseExtents * absoluteScale for dimension in visibleTankDimensions { guard let entity = tankDimensionEntities[dimension] else { continue } let distance: Float switch dimension { case .height: distance = scaledExtents.y case .length: distance = scaledExtents.z case .width: distance = scaledExtents.x } MeasurementEntityFactory.updateDimensionLabel( entity, dimension: dimension, distance: distance ) } } 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() } /// 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), fontSize: 0.09 ) ) } } } 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 + tankDimensionLabels 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) pipeSegmentIndices.append(pipeRoute.waypoints.count - 1) } let waypoint = PipeRouteEntityFactory.makeWaypoint(at: position) anchor.addChild(waypoint) pipeWaypointEntities.append(waypoint) pipeRoute.add(position) persistence.savePipeRoute(pipeRoute) } /// Entfernt den zuletzt gesetzten Wegpunkt (und sein Segment). func undoLastPipeWaypoint() { guard pipeRoute.removeLast() != nil else { return } pipeWaypointEntities.popLast()?.removeFromParent() pipeSegmentEntities.popLast()?.removeFromParent() pipeSegmentIndices.popLast() persistence.savePipeRoute(pipeRoute) } /// 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() pipeSegmentIndices.removeAll() selectedPipeSegmentIndex = nil hidePipeLivePreview() pipeRoute.clear() persistence.savePipeRoute(pipeRoute) } /// 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 { 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() updateTankDimensions() } /// 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. Ihr Mittelpunkt liegt am seitlichen Domschacht. 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 } let tankPosition = tankEntity.position(relativeTo: tankAnchor) let localDomeOffset = SIMD4(0, 0, safetyZoneDomeOffset, 0) let tankTransform = tankEntity.transformMatrix(relativeTo: tankAnchor) let domeOffset = tankTransform * localDomeOffset entity.position = tankPosition + SIMD3(domeOffset.x, domeOffset.y, domeOffset.z) } /// Abstand vom Tankmittelpunkt zum seitlichen Domschacht entlang der lokalen /// Tank-Längsachse (+Z), abgeleitet aus den Vorgabemaßen. private var safetyZoneDomeOffset: Float { switch persistence.tank.size { case .t1_2: return 2.460 / 2 case .t2_1: return 4.255 / 2 case .t2_9: return 5.500 / 2 } } 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: updateTankDimensionValues() 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 } } @objc private func handlePipeSegmentDoubleTap(_ gesture: UITapGestureRecognizer) { guard state == .immersive, measurementPhase == .idle, pipeRoutingPhase == .idle, let arView else { return } let point = gesture.location(in: arView) guard let hit = arView.entity(at: point) else { return } for (entityIndex, segment) in pipeSegmentEntities.enumerated() { if Self.entity(hit, isPartOf: segment) { if let selectedPipeSegmentIndex { setPipeSegmentHighlight(selectedPipeSegmentIndex, highlighted: false) } selectedPipeSegmentIndex = pipeSegmentIndices[entityIndex] setPipeSegmentHighlight(pipeSegmentIndices[entityIndex], highlighted: true) return } } } func cancelPipeSegmentRemoval() { if let selectedPipeSegmentIndex { setPipeSegmentHighlight(selectedPipeSegmentIndex, highlighted: false) } selectedPipeSegmentIndex = nil } func confirmPipeSegmentRemoval() { guard selectedPipeSegmentIndex != nil else { return } pipeRoute.removeSegmentAndOrphanedWaypoints(at: selectedPipeSegmentIndex!) selectedPipeSegmentIndex = nil rebuildPipeRouteEntities() persistence.savePipeRoute(pipeRoute) } private func setPipeSegmentHighlight(_ index: Int, highlighted: Bool) { guard let entityIndex = pipeSegmentIndices.firstIndex(of: index) else { return } PipeRouteEntityFactory.setSegmentColor( pipeSegmentEntities[entityIndex], color: highlighted ? .systemYellow : .systemPink ) } private func removeTank() { if let tankAnchor { arView?.scene.removeAnchor(tankAnchor) } tankAnchor = nil tankEntity = nil safetyZoneEntity = nil tankDimensionEntities.removeAll() tankDimensionLabels.removeAll() tankBaseExtents = nil lastTankDimensionScale = 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 let belowAlpha = 1.0 - Float(undergroundTransparency) TankDepthFadeMaterial.updateGroundY(groundY, belowAlpha: belowAlpha, 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 } }