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>
333 lines
12 KiB
Swift
333 lines
12 KiB
Swift
//
|
||
// ARExperienceView.swift
|
||
// GasTankAR
|
||
//
|
||
// Created by Dennis Nemec on 26.08.26.
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
/// Vollbild-AR-Erlebnis für ein Projekt: komponiert AR-View + Status-Overlay und
|
||
/// besitzt den `ARSessionController`.
|
||
struct ARExperienceView: View {
|
||
@Environment(\.modelContext) private var modelContext
|
||
@Environment(\.dismiss) private var dismiss
|
||
@AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false
|
||
@AppStorage(SettingsKeys.arExposure) private var arExposure = 1.0
|
||
|
||
let project: Project
|
||
|
||
@State private var controller: ARSessionController?
|
||
@State private var flashOpacity = 0.0
|
||
@State private var showMeasurements = false
|
||
@State private var measurementName = ""
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
if let controller {
|
||
ARViewContainer(controller: controller)
|
||
.ignoresSafeArea()
|
||
ARStatusOverlay(controller: controller, onCancel: { dismiss() })
|
||
if controller.state == .immersive {
|
||
ARControlOverlay(controller: controller) {
|
||
showMeasurements = true
|
||
}
|
||
.transition(.opacity)
|
||
}
|
||
if controller.measurementPhase == .awaitingFirstPoint
|
||
|| controller.measurementPhase == .awaitingSecondPoint {
|
||
measuringOverlay(controller: controller)
|
||
}
|
||
if controller.pipeRoutingPhase == .placing {
|
||
pipeRoutingOverlay(controller: controller)
|
||
}
|
||
if controller.selectedPipeSegmentIndex != nil {
|
||
pipeSegmentRemovalOverlay(controller: controller)
|
||
}
|
||
Color.white
|
||
.opacity(flashOpacity)
|
||
.ignoresSafeArea()
|
||
.allowsHitTesting(false)
|
||
.onChange(of: controller.snapshotFlashCounter) {
|
||
flashOpacity = 0.9
|
||
withAnimation(.easeOut(duration: 0.35)) { flashOpacity = 0 }
|
||
}
|
||
}
|
||
}
|
||
.overlay(alignment: .topLeading) {
|
||
Button {
|
||
dismiss()
|
||
} label: {
|
||
Image(systemName: "arrow.left")
|
||
.font(.title2.weight(.semibold))
|
||
.frame(width: 44, height: 44)
|
||
.contentShape(.rect)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(.white)
|
||
.shadow(radius: 3)
|
||
.accessibilityLabel("Zurück")
|
||
.padding(.leading, 12)
|
||
.safeAreaPadding(.top)
|
||
}
|
||
.sheet(isPresented: $showMeasurements) {
|
||
if let controller {
|
||
MeasurementListView(controller: controller) {
|
||
showMeasurements = false
|
||
controller.startMeasurement()
|
||
}
|
||
}
|
||
}
|
||
.alert("Strecke benennen", isPresented: namingBinding) {
|
||
TextField("Bezeichnung", text: $measurementName)
|
||
Button("Speichern") {
|
||
controller?.finishMeasurement(name: measurementName)
|
||
measurementName = ""
|
||
}
|
||
Button("Abbrechen", role: .cancel) {
|
||
controller?.cancelMeasurement()
|
||
measurementName = ""
|
||
}
|
||
} message: {
|
||
Text("Gib der Strecke eine Bezeichnung. Sie erscheint auf der Linie.")
|
||
}
|
||
.task {
|
||
guard controller == nil else { return }
|
||
let persistence = ProjectPersistenceAdapter(project: project, modelContext: modelContext)
|
||
controller = ARSessionController(
|
||
persistence: persistence,
|
||
useLiDAR: lidarEnabled,
|
||
arExposure: arExposure
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Präsentiert den Benennungs-Alert, sobald beide Punkte gesetzt sind.
|
||
private var namingBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { controller?.measurementPhase == .naming },
|
||
set: { isPresented in
|
||
// Wird der Alert ohne Button-Aktion geschlossen, Messvorgang abbrechen.
|
||
if !isPresented, controller?.measurementPhase == .naming {
|
||
controller?.cancelMeasurement()
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
/// Vollständiges Mess-Overlay: Fadenkreuz in der Mitte, Hinweis-/Distanz-Banner oben,
|
||
/// Auslöser-Button unten (wie beim Platzieren des Tanks).
|
||
private func measuringOverlay(controller: ARSessionController) -> some View {
|
||
ZStack {
|
||
// Fadenkreuz exakt in der Vollbild-Mitte – deckt sich mit dem Raycast-Punkt
|
||
// (arView.bounds-Mitte), der die Safe Area ignoriert.
|
||
measuringCrosshair
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.ignoresSafeArea()
|
||
|
||
VStack {
|
||
measuringBanner(for: controller)
|
||
Spacer()
|
||
measuringShutter(for: controller)
|
||
}
|
||
.padding()
|
||
}
|
||
.transition(.opacity)
|
||
}
|
||
|
||
private var measuringCrosshair: some View {
|
||
ZStack {
|
||
Circle()
|
||
.stroke(.yellow.opacity(0.9), lineWidth: 2)
|
||
.frame(width: 44, height: 44)
|
||
Circle()
|
||
.fill(.yellow)
|
||
.frame(width: 5, height: 5)
|
||
}
|
||
.shadow(radius: 2)
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
private func measuringBanner(for controller: ARSessionController) -> some View {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "ruler")
|
||
Text(measuringBannerText(for: controller))
|
||
.font(.subheadline.weight(.medium))
|
||
.monospacedDigit()
|
||
Spacer(minLength: 0)
|
||
Button("Abbrechen") { controller.cancelMeasurement() }
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(.ultraThinMaterial, in: .capsule)
|
||
}
|
||
|
||
private func measuringBannerText(for controller: ARSessionController) -> String {
|
||
switch controller.measurementPhase {
|
||
case .awaitingFirstPoint:
|
||
return "Ziele auf den Startpunkt."
|
||
case .awaitingSecondPoint:
|
||
if let distance = controller.liveDistance {
|
||
return String(format: "Abstand: %.2f m", distance)
|
||
}
|
||
return "Ziele auf den Endpunkt."
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
private func measuringShutter(for controller: ARSessionController) -> some View {
|
||
let title = controller.measurementPhase == .awaitingFirstPoint ? "Startpunkt setzen" : "Endpunkt setzen"
|
||
return VStack(spacing: 10) {
|
||
Text(title)
|
||
.font(.subheadline.weight(.medium))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(.ultraThinMaterial, in: .capsule)
|
||
|
||
Button {
|
||
controller.captureMeasurementPoint()
|
||
} label: {
|
||
ZStack {
|
||
Circle()
|
||
.stroke(.white, lineWidth: 4)
|
||
.frame(width: 74, height: 74)
|
||
Circle()
|
||
.fill(.yellow)
|
||
.frame(width: 60, height: 60)
|
||
}
|
||
.shadow(radius: 4)
|
||
.contentShape(.circle)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(title)
|
||
}
|
||
}
|
||
|
||
private func pipeSegmentRemovalOverlay(controller: ARSessionController) -> some View {
|
||
VStack {
|
||
Spacer()
|
||
|
||
HStack(spacing: 12) {
|
||
Text("Verbindung entfernen?")
|
||
.font(.subheadline.weight(.medium))
|
||
|
||
Button("Abbrechen") {
|
||
controller.cancelPipeSegmentRemoval()
|
||
}
|
||
.buttonStyle(.bordered)
|
||
|
||
Button("Entfernen", role: .destructive) {
|
||
controller.confirmPipeSegmentRemoval()
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(.ultraThinMaterial, in: .capsule)
|
||
}
|
||
.padding()
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
}
|
||
|
||
// MARK: - Rohrführung-Overlay
|
||
|
||
/// Overlay für die Rohrführung: pinkes Fadenkreuz (Vollbild-Mitte), Info-Banner oben,
|
||
/// Auslöser + Aktionen unten (gleiche Bedienung wie beim Messen).
|
||
private func pipeRoutingOverlay(controller: ARSessionController) -> some View {
|
||
ZStack {
|
||
pipeCrosshair
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.ignoresSafeArea()
|
||
|
||
VStack {
|
||
pipeBanner(for: controller)
|
||
Spacer()
|
||
pipeControls(for: controller)
|
||
}
|
||
.padding()
|
||
}
|
||
.transition(.opacity)
|
||
}
|
||
|
||
private var pipeCrosshair: some View {
|
||
ZStack {
|
||
Circle()
|
||
.stroke(.pink.opacity(0.9), lineWidth: 2)
|
||
.frame(width: 44, height: 44)
|
||
Circle()
|
||
.fill(.pink)
|
||
.frame(width: 5, height: 5)
|
||
}
|
||
.shadow(radius: 2)
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
private func pipeBanner(for controller: ARSessionController) -> some View {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "point.3.connected.trianglepath.dotted")
|
||
Text(pipeBannerText(for: controller))
|
||
.font(.subheadline.weight(.medium))
|
||
.monospacedDigit()
|
||
Spacer(minLength: 0)
|
||
Button("Abbrechen") { controller.cancelPipeRouting() }
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(.ultraThinMaterial, in: .capsule)
|
||
}
|
||
|
||
private func pipeBannerText(for controller: ARSessionController) -> String {
|
||
if controller.pipeWaypointCount == 0 {
|
||
return "Ziele auf den Startpunkt der Rohrführung."
|
||
}
|
||
return "Wegpunkte: \(controller.pipeWaypointCount) · Länge: \(controller.pipeRouteLengthText)"
|
||
}
|
||
|
||
private func pipeControls(for controller: ARSessionController) -> some View {
|
||
VStack(spacing: 12) {
|
||
Text("Wegpunkt setzen")
|
||
.font(.subheadline.weight(.medium))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(.ultraThinMaterial, in: .capsule)
|
||
|
||
Button {
|
||
controller.addPipeWaypoint()
|
||
} label: {
|
||
ZStack {
|
||
Circle()
|
||
.stroke(.white, lineWidth: 4)
|
||
.frame(width: 74, height: 74)
|
||
Circle()
|
||
.fill(.pink)
|
||
.frame(width: 60, height: 60)
|
||
}
|
||
.shadow(radius: 4)
|
||
.contentShape(.circle)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Wegpunkt setzen")
|
||
|
||
HStack(spacing: 12) {
|
||
Button("Rückgängig", systemImage: "arrow.uturn.backward") {
|
||
controller.undoLastPipeWaypoint()
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.disabled(controller.pipeWaypointCount == 0)
|
||
|
||
Button("Fertig", systemImage: "checkmark") {
|
||
controller.finishPipeRouting()
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(controller.pipeWaypointCount < 2)
|
||
}
|
||
}
|
||
}
|
||
}
|