Add tank catalog with USDZ models and tank selection

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>
This commit is contained in:
Dennis Nemec
2026-09-26 19:12:39 +02:00
parent 992bb32025
commit b8536d4182
63 changed files with 2317 additions and 188 deletions

View File

@ -32,6 +32,20 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
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?
@ -47,7 +61,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
@ObservationIgnored private let persistence: ARProjectPersisting
@ObservationIgnored private let useLiDAR: Bool
@ObservationIgnored private let configFactory: SessionConfigurationFactory
@ObservationIgnored private let entityFactory = TankEntityFactory()
@ObservationIgnored private let entityFactory: TankEntityFactory
@ObservationIgnored private var machine: ARFlowStateMachine
@ObservationIgnored private weak var arView: ARView?
@ -61,8 +75,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
@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>?
@ -79,6 +97,9 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
@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.
@ -106,14 +127,18 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
private static let anchorDetectionTimeout: Duration = .seconds(20)
private static let relocationTimeout: Duration = .seconds(15)
init(persistence: ARProjectPersisting, useLiDAR: Bool) {
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
@ -145,13 +170,22 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
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()
entityFactory.preload(persistence.tank)
restorePersistedAnnotations()
startMappingPoll()
startLocationUpdates()
send(.started)
@ -184,6 +218,50 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
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
@ -261,7 +339,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
/// Standort für die spätere Karten-Verortung – im Projekt.
func captureSnapshot() {
guard let arView else { return }
let coordinate = currentLocation?.coordinate
let coordinate = locationManager.location?.coordinate ?? currentLocation?.coordinate
arView.snapshot(saveToHDR: false) { [weak self] image in
guard let image else { return }
let date = Date()
@ -276,6 +354,16 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
// 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()
@ -306,6 +394,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
end: end
)
measurements.append(measurement)
persistence.saveMeasurements(measurements)
renderMeasurement(measurement)
clearMeasurementPreview()
@ -327,6 +416,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
}
}
measurements.remove(atOffsets: offsets)
persistence.saveMeasurements(measurements)
}
/// Setzt den aktuellen Punkt aus der Bildschirmmitte (wie beim Platzieren des Tanks).
@ -375,6 +465,130 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
}
}
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)
@ -392,7 +606,6 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
private func onSceneUpdate() {
updateLivePreview()
updatePipeLivePreview()
billboardLabels()
}
/// Aktualisiert Cursor, Live-Linie und Distanz-Label anhand der aktuellen Bildschirmmitte.
@ -428,7 +641,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
liveDistance = distance
if let holder = liveLabelEntity {
holder.children.forEach { $0.removeFromParent() }
holder.addChild(MeasurementEntityFactory.makeText(String(format: "%.2f m", distance)))
holder.addChild(
MeasurementEntityFactory.makeText(
String(format: "%.2f m", distance),
fontSize: 0.09
)
)
}
}
}
@ -460,7 +678,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
private func billboardLabels() {
guard let arView else { return }
let camera = arView.cameraTransform.translation
var labels = measurementLabels
var labels = measurementLabels + tankDimensionLabels
if let liveLabelEntity, liveLabelEntity.isEnabled { labels.append(liveLabelEntity) }
guard !labels.isEmpty else { return }
@ -494,11 +712,13 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
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).
@ -506,6 +726,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
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.
@ -533,8 +755,11 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
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.
@ -745,11 +970,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
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; sie werden nur auf die Bodenmitte des Tanks gesetzt.
/// des Tanks sie nicht verzerrt. Ihr Mittelpunkt liegt am seitlichen Domschacht.
private func updateSafetyZones() {
guard showSafetyZones, let tankAnchor, let tankEntity else {
safetyZoneEntity?.removeFromParent()
@ -765,7 +991,26 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
tankAnchor.addChild(entity)
safetyZoneEntity = entity
}
entity.position = tankEntity.position(relativeTo: tankAnchor)
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) {
@ -779,8 +1024,11 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
@objc private func handleManipulationChanged(_ recognizer: UIGestureRecognizer) {
updateSafetyZones() // Ringe live mit dem Tank mitführen.
switch recognizer.state {
case .ended, .cancelled, .failed: persistTankTransform()
default: break
case .ended, .cancelled, .failed:
updateTankDimensionValues()
persistTankTransform()
default:
break
}
}
@ -800,11 +1048,59 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
}
}
@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
}
@ -819,7 +1115,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
private func updateDepthFadeGroundY() {
guard persistence.tank.placement == .belowGround, let tankEntity else { return }
let groundY = tankEntity.position(relativeTo: nil).y
TankDepthFadeMaterial.updateGroundY(groundY, on: tankEntity)
let belowAlpha = 1.0 - Float(undergroundTransparency)
TankDepthFadeMaterial.updateGroundY(groundY, belowAlpha: belowAlpha, on: tankEntity)
}
// MARK: - Persistenz & Status