Add help view and refine AR tank placement

This commit is contained in:
Dennis Nemec
2026-11-07 18:38:35 +01:00
parent 7fa162c910
commit a343bde741
25 changed files with 250 additions and 49 deletions

View File

@ -15,6 +15,9 @@ struct ARExperienceView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false
@AppStorage(SettingsKeys.arExposure) private var arExposure = 1.0
#if DEBUG
@AppStorage(SettingsKeys.devTankResizingEnabled) private var devTankResizingEnabled = false
#endif
let project: Project
@ -95,10 +98,16 @@ struct ARExperienceView: View {
.task {
guard controller == nil else { return }
let persistence = ProjectPersistenceAdapter(project: project, modelContext: modelContext)
#if DEBUG
let devResizing = devTankResizingEnabled
#else
let devResizing = false
#endif
controller = ARSessionController(
persistence: persistence,
useLiDAR: lidarEnabled,
arExposure: arExposure
arExposure: arExposure,
devTankResizingEnabled: devResizing
)
}
}

View File

@ -26,6 +26,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
}
/// Ob die Sicherheitsabstände (Ringe um den Tank) eingeblendet sind.
private(set) var showSafetyZones = false
/// Nur für Debug-Builds mit eingeschalteter Entwickler-Option.
let allowsTankResizing: Bool
/// 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).
@ -127,9 +129,15 @@ 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, arExposure: Double = 1.0) {
init(
persistence: ARProjectPersisting,
useLiDAR: Bool,
arExposure: Double = 1.0,
devTankResizingEnabled: Bool = false
) {
self.persistence = persistence
self.useLiDAR = useLiDAR
self.allowsTankResizing = TankResizePolicy.isEnabled(devFlag: devTankResizingEnabled)
self.showsPit = persistence.tank.placement == .belowGround && !persistence.tank.showLidOnly
self.entityFactory = TankEntityFactory(aboveGroundLightMultiplier: Float(arExposure))
self.configFactory = SessionConfigurationFactory(
@ -255,7 +263,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
/// Setzt die Tank-Skalierung auf die Originalgröße zurück.
func resetTankScale() {
guard let tankEntity else { return }
guard allowsTankResizing, let tankEntity else { return }
tankEntity.scale = SIMD3<Float>(repeating: 1)
updateSafetyZones()
updateTankDimensionValues()
@ -967,6 +975,10 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
}
container.transform = Transform(matrix: worldTransform)
// Ältere Projekte können eine gespeicherte Skalierung enthalten.
if !allowsTankResizing {
container.scale = SIMD3<Float>(repeating: 1)
}
updateDepthFadeGroundY()
updateSceneOcclusion()
updateSafetyZones()
@ -992,30 +1004,21 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
safetyZoneEntity = entity
}
// Der Katalogwert ist im Modellraum gemessen. w = 0 vermeidet eine
// zweite Translation; Rotation und Skalierung des Tanks bleiben erhalten.
let domeCenterX = entityFactory.domeCenterX(for: persistence.tank)
let localOffset = SIMD4<Float>(domeCenterX, 0, 0, 0)
let transformedOffset = tankEntity.transformMatrix(relativeTo: tankAnchor) * localOffset
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
}
entity.position = tankPosition + SIMD3(transformedOffset.x, 0, transformedOffset.z)
}
private func installManipulationGestures(on entity: ModelEntity) {
guard let arView else { return }
let recognizers = arView.installGestures([.translation, .rotation, .scale], for: entity)
let gestures: ARView.EntityGestures = allowsTankResizing
? [.translation, .rotation, .scale]
: [.translation, .rotation]
let recognizers = arView.installGestures(gestures, for: entity)
for recognizer in recognizers {
recognizer.addTarget(self, action: #selector(handleManipulationChanged(_:)))
}

View File

@ -9,13 +9,13 @@ import RealityKit
import UIKit
/// Ein Sicherheitsabstand rund um den Tank: ein Kreis mit gegebenem Radius und Farbe,
/// dessen Mittelpunkt der Tank ist.
/// dessen Mittelpunkt über dem Domschacht liegt.
///
/// Modular gehalten: Ein weiterer Sicherheitsabstand wird schlicht durch Hinzufügen
/// eines Eintrags zu ``SafetyZone/all`` ergänzt – die Darstellung folgt automatisch.
struct SafetyZone: Identifiable {
let id = UUID()
/// Radius des Kreises in Metern (Abstand vom Tank-Mittelpunkt).
/// Radius des Kreises in Metern (Abstand von der Domschachtmitte).
let radius: Float
/// Farbe des Rings.
let color: UIColor

View File

@ -42,6 +42,12 @@ final class TankEntityFactory {
self.aboveGroundLightMultiplier = aboveGroundLightMultiplier
}
/// Domschachtposition der tatsächlich geladenen Variante, gemessen ab dem
/// Ursprung des USDZ-Modells. Bei fehlendem Modell bleibt der Ersatzkörper zentriert.
func domeCenterX(for tank: Tank) -> Float {
catalog.resolvedDefinition(for: tank)?.domeCenterX ?? 0
}
/// Lädt das zum Tank passende Modell asynchron vor (blockiert den Main-Actor nicht).
func preload(_ tank: Tank) {
guard let name = catalog.resolvedDefinition(for: tank)?.modelResourceName else { return }

View File

@ -8,7 +8,7 @@
import SwiftUI
/// Steuer-Panel für den ausgewählten Tank: Höhe (↑↓), Verschieben auf dem Boden
/// (vor/zurück/links/rechts) und Drehen (⟲⟳). Erscheint von unten; Pinch bleibt möglich.
/// (vor/zurück/links/rechts) und Drehen (⟲⟳). Erscheint von unten.
/// Buttons unterstützen Gedrückt-halten für kontinuierliche Anpassung.
struct TankManipulationPanel: View {
let controller: ARSessionController
@ -109,10 +109,12 @@ struct TankManipulationPanel: View {
private var actionButtons: some View {
HStack(spacing: 10) {
Button("Skalierung zurücksetzen", systemImage: "arrow.counterclockwise") {
controller.resetTankScale()
if controller.allowsTankResizing {
Button("Skalierung zurücksetzen", systemImage: "arrow.counterclockwise") {
controller.resetTankScale()
}
.buttonStyle(.bordered)
}
.buttonStyle(.bordered)
Button("Neu platzieren", systemImage: "arrow.up.and.down.and.arrow.left.and.right") {
controller.reposition()