Introduce TankDefinition/TankCatalog loaded from Tanks.json, bundle the Drachengas USDZ tank models, add tank selection and preview views, and update the AR experience, editor and project views to use the catalog. Includes tests for catalog loading and model selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1171 lines
46 KiB
Swift
1171 lines
46 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
|
||
/// Vom Nutzer aktivierte, automatisch am Tank angelegte Maße.
|
||
private(set) var visibleTankDimensions: Set<TankDimension> = []
|
||
/// 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<Float>?
|
||
@ObservationIgnored private var lastTankDimensionScale: SIMD3<Float>?
|
||
/// 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<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] = []
|
||
@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<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, 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<Float>(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<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 = 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<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 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<Float>
|
||
let end: SIMD3<Float>
|
||
let labelOffset: SIMD3<Float>
|
||
|
||
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<Float>(
|
||
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<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()
|
||
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<Float>(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
|
||
}
|
||
}
|