Initial commit for first version of project
This commit is contained in:
873
GasTankAR/Views/TankAR/ARSessionController.swift
Normal file
873
GasTankAR/Views/TankAR/ARSessionController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user