874 lines
34 KiB
Swift
874 lines
34 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|