Initial commit for first version of project
This commit is contained in:
126
GasTankAR/Views/Docuframe/DocuframeView.swift
Normal file
126
GasTankAR/Views/Docuframe/DocuframeView.swift
Normal file
@ -0,0 +1,126 @@
|
||||
//
|
||||
// DocuframeView.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// DOCUframe-Tab: Auswahl aufgenommener Fotos und Übergabe an DOCUframe (Upload) bzw. Mailversand.
|
||||
struct DocuframeView: View {
|
||||
let project: Project
|
||||
|
||||
@State private var viewModel = DocuframeViewModel()
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
|
||||
|
||||
private var photos: [ProjectPhoto] {
|
||||
project.photos.sorted { $0.creationDate > $1.creationDate }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if photos.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Fotos",
|
||||
systemImage: "photo.on.rectangle.angled",
|
||||
description: Text("Nimm im AR-Modus Fotos auf, um sie an DOCUframe zu übergeben.")
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 2) {
|
||||
ForEach(photos) { photo in
|
||||
selectableThumbnail(photo)
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if !photos.isEmpty { actionBar }
|
||||
}
|
||||
.alert(
|
||||
viewModel.result?.title ?? "",
|
||||
isPresented: resultBinding,
|
||||
presenting: viewModel.result
|
||||
) { _ in
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { result in
|
||||
Text(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectableThumbnail(_ photo: ProjectPhoto) -> some View {
|
||||
let selected = viewModel.isSelected(photo)
|
||||
return PhotoThumbnail(imageData: photo.imageData)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipped()
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, selected ? Color.accentColor : Color.black.opacity(0.35))
|
||||
.padding(6)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
.overlay {
|
||||
if selected {
|
||||
Rectangle().strokeBorder(Color.accentColor, lineWidth: 3)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { viewModel.toggleSelection(photo) }
|
||||
}
|
||||
|
||||
private var actionBar: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(viewModel.hasSelection ? "\(viewModel.selectionCount) ausgewählt" : "Fotos zum Übertragen auswählen")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await viewModel.upload(from: photos, leadID: project.leadID) }
|
||||
} label: {
|
||||
Label("In DOCUframe hochladen", systemImage: "arrow.up.doc")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
Task { await viewModel.sendEmail(from: photos, leadID: project.leadID) }
|
||||
} label: {
|
||||
Label("E-Mail senden", systemImage: "envelope")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(!viewModel.hasSelection || viewModel.isProcessing)
|
||||
|
||||
if viewModel.isProcessing {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var resultBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.result != nil },
|
||||
set: { if !$0 { viewModel.result = nil } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sample = PreviewSupport.galleryProjectContainer()
|
||||
return NavigationStack {
|
||||
DocuframeView(project: sample.project)
|
||||
}
|
||||
.modelContainer(sample.container)
|
||||
}
|
||||
118
GasTankAR/Views/Docuframe/DocuframeViewModel.swift
Normal file
118
GasTankAR/Views/Docuframe/DocuframeViewModel.swift
Normal file
@ -0,0 +1,118 @@
|
||||
//
|
||||
// DocuframeViewModel.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Präsentationslogik für den DOCUframe-Tab: verwaltet die Fotoauswahl und stößt Upload bzw.
|
||||
/// Mailversand über den injizierten ``DocuframeService`` an. Kennt keine UI-Details.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DocuframeViewModel {
|
||||
/// Auswahl anhand der stabilen SwiftData-Identität der Fotos.
|
||||
var selectedIDs: Set<PersistentIdentifier> = []
|
||||
/// Läuft gerade ein Upload/Mailversand?
|
||||
private(set) var isProcessing = false
|
||||
/// Ergebnis des letzten Vorgangs für die Anzeige (nil = kein Hinweis).
|
||||
var result: DocuframeActionResult?
|
||||
|
||||
@ObservationIgnored private let service: DocuframeService
|
||||
|
||||
/// Der Service wird injiziert (Default: Stub) – austauschbar für echte Anbindung oder Tests.
|
||||
init(service: DocuframeService = StubDocuframeService()) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
var hasSelection: Bool { !selectedIDs.isEmpty }
|
||||
var selectionCount: Int { selectedIDs.count }
|
||||
|
||||
func isSelected(_ photo: ProjectPhoto) -> Bool {
|
||||
selectedIDs.contains(photo.persistentModelID)
|
||||
}
|
||||
|
||||
func toggleSelection(_ photo: ProjectPhoto) {
|
||||
let id = photo.persistentModelID
|
||||
if selectedIDs.contains(id) {
|
||||
selectedIDs.remove(id)
|
||||
} else {
|
||||
selectedIDs.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
func upload(from photos: [ProjectPhoto], leadID: String) async {
|
||||
await perform(from: photos, leadID: leadID, successText: "in DOCUframe hochgeladen") {
|
||||
try await service.uploadPhotos($0, projectLeadID: $1)
|
||||
}
|
||||
}
|
||||
|
||||
func sendEmail(from photos: [ProjectPhoto], leadID: String) async {
|
||||
await perform(from: photos, leadID: leadID, successText: "per E-Mail versendet") {
|
||||
try await service.sendEmail($0, projectLeadID: $1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gemeinsame Ausführung
|
||||
|
||||
private func perform(
|
||||
from photos: [ProjectPhoto],
|
||||
leadID: String,
|
||||
successText: String,
|
||||
action: ([DocuframePhoto], String) async throws -> Void
|
||||
) async {
|
||||
let selected = photos.filter { selectedIDs.contains($0.persistentModelID) }
|
||||
guard !selected.isEmpty else {
|
||||
result = .failure(message: DocuframeError.noPhotosSelected.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
|
||||
let payload = selected.map(Self.makeDTO)
|
||||
do {
|
||||
try await action(payload, leadID)
|
||||
result = .success(message: "\(selected.count) Foto(s) \(successText).")
|
||||
} catch {
|
||||
// Bei HTTP 4xx/5xx (und anderen Fehlern) den Nutzer informieren.
|
||||
result = .failure(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bildet das Persistenz-Model auf ein persistenzunabhängiges Domänen-DTO ab.
|
||||
private static func makeDTO(_ photo: ProjectPhoto) -> DocuframePhoto {
|
||||
DocuframePhoto(
|
||||
imageData: photo.imageData,
|
||||
creationDate: photo.creationDate,
|
||||
latitude: photo.latitude,
|
||||
longitude: photo.longitude
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnis eines DOCUframe-Vorgangs für die Anzeige (Erfolg oder Fehler).
|
||||
enum DocuframeActionResult {
|
||||
case success(message: String)
|
||||
case failure(message: String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .success: "Erledigt"
|
||||
case .failure: "Fehler"
|
||||
}
|
||||
}
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .success(let message), .failure(let message): message
|
||||
}
|
||||
}
|
||||
|
||||
var isError: Bool {
|
||||
if case .failure = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
263
GasTankAR/Views/GalleryView.swift
Normal file
263
GasTankAR/Views/GalleryView.swift
Normal file
@ -0,0 +1,263 @@
|
||||
//
|
||||
// GalleryView.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 27.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MapKit
|
||||
import CoreLocation
|
||||
|
||||
/// Galerie der im AR-Modus aufgenommenen Fotos eines Projekts.
|
||||
/// Anzeige wie in der Fotos-App (Raster), gruppiert nach Aufnahmedatum mit
|
||||
/// angehefteten Abschnitts-Überschriften – analog zur Buchstaben-Gruppierung der Kontakte-App.
|
||||
struct GalleryView: View {
|
||||
@Bindable var project: Project
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var selectedPhoto: ProjectPhoto?
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
|
||||
|
||||
/// Fotos nach Tag gruppiert und absteigend sortiert (neueste zuerst).
|
||||
private var sections: [(date: Date, photos: [ProjectPhoto])] {
|
||||
let groups = Dictionary(grouping: project.photos) { photo in
|
||||
Calendar.current.startOfDay(for: photo.creationDate)
|
||||
}
|
||||
return groups
|
||||
.map { (date: $0.key, photos: $0.value.sorted { $0.creationDate > $1.creationDate }) }
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if project.photos.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Fotos",
|
||||
systemImage: "photo.on.rectangle.angled",
|
||||
description: Text("Nimm im AR-Modus über den Kamera-Button ein Foto auf.")
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 2, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(sections, id: \.date) { section in
|
||||
Section {
|
||||
ForEach(section.photos) { photo in
|
||||
thumbnail(for: photo)
|
||||
}
|
||||
} header: {
|
||||
sectionHeader(for: section.date)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(item: $selectedPhoto) { photo in
|
||||
PhotoViewer(photo: photo) {
|
||||
delete(photo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func thumbnail(for photo: ProjectPhoto) -> some View {
|
||||
PhotoThumbnail(imageData: photo.imageData)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipped()
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { selectedPhoto = photo }
|
||||
.contextMenu {
|
||||
Button("Löschen", systemImage: "trash", role: .destructive) {
|
||||
delete(photo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(for date: Date) -> some View {
|
||||
Text(Self.headerTitle(for: date))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private func delete(_ photo: ProjectPhoto) {
|
||||
if selectedPhoto == photo { selectedPhoto = nil }
|
||||
modelContext.delete(photo)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
/// Überschrift wie in der Fotos-App: „Heute"/„Gestern", sonst das ausgeschriebene Datum.
|
||||
private static func headerTitle(for date: Date) -> String {
|
||||
let calendar = Calendar.current
|
||||
if calendar.isDateInToday(date) { return "Heute" }
|
||||
if calendar.isDateInYesterday(date) { return "Gestern" }
|
||||
return date.formatted(.dateTime.day().month(.wide).year())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt und dekodiert die Bilddaten außerhalb des Main-Threads und zeigt das Ergebnis quadratisch.
|
||||
struct PhotoThumbnail: View {
|
||||
let imageData: Data
|
||||
|
||||
@State private var image: UIImage?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color(.secondarySystemBackground)
|
||||
if let image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: imageData) {
|
||||
image = await decode(imageData)
|
||||
}
|
||||
}
|
||||
|
||||
private func decode(_ data: Data) async -> UIImage? {
|
||||
await Task.detached(priority: .userInitiated) {
|
||||
UIImage(data: data)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Vollbild-Betrachter für ein einzelnes Foto mit Schließen- und Löschen-Aktion.
|
||||
private struct PhotoViewer: View {
|
||||
let photo: ProjectPhoto
|
||||
let onDelete: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
if let image = UIImage(data: photo.imageData) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
if let coordinate = photo.coordinate {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Karte")
|
||||
.font(.headline)
|
||||
PhotoLocationCard(coordinate: coordinate)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Löschen", systemImage: "trash", role: .destructive) {
|
||||
onDelete()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.navigationTitle(photo.creationDate.formatted(.dateTime.day().month().year().hour().minute()))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schwebende Karte, die den Aufnahmeort eines Fotos zeigt: Karten-Vorschau plus
|
||||
/// (rückwärts-geokodierte) Adresse. Tippen öffnet den Ort in Apple Karten.
|
||||
private struct PhotoLocationCard: View {
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
|
||||
@State private var address: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Map(
|
||||
initialPosition: .region(
|
||||
MKCoordinateRegion(center: coordinate, latitudinalMeters: 350, longitudinalMeters: 350)
|
||||
)
|
||||
) {
|
||||
Marker("Aufnahmeort", coordinate: coordinate)
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: 140)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.red)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Aufnahmeort")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(address ?? Self.coordinateText(coordinate))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "arrow.up.forward.app.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(.rect(cornerRadius: 12))
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { openInMaps() }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Aufnahmeort, in Apple Karten öffnen")
|
||||
.task(id: coordinate.latitude + coordinate.longitude) { await reverseGeocode() }
|
||||
}
|
||||
|
||||
private func openInMaps() {
|
||||
let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
|
||||
mapItem.name = address ?? "Aufnahmeort"
|
||||
mapItem.openInMaps()
|
||||
}
|
||||
|
||||
private func reverseGeocode() async {
|
||||
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||
guard let placemark = try? await CLGeocoder().reverseGeocodeLocation(location).first else { return }
|
||||
let street = [placemark.thoroughfare, placemark.subThoroughfare]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
let city = [placemark.postalCode, placemark.locality]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
let composed = [street, city].filter { !$0.isEmpty }.joined(separator: ", ")
|
||||
if !composed.isEmpty { address = composed }
|
||||
}
|
||||
|
||||
private static func coordinateText(_ coordinate: CLLocationCoordinate2D) -> String {
|
||||
String(format: "%.5f, %.5f", coordinate.latitude, coordinate.longitude)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Galerie") {
|
||||
let sample = PreviewSupport.galleryProjectContainer()
|
||||
return NavigationStack {
|
||||
GalleryView(project: sample.project)
|
||||
}
|
||||
.modelContainer(sample.container)
|
||||
}
|
||||
|
||||
#Preview("Foto-Detail") {
|
||||
PhotoViewer(photo: PreviewSupport.samplePhoto()) {}
|
||||
}
|
||||
82
GasTankAR/Views/ProjectCreate.swift
Normal file
82
GasTankAR/Views/ProjectCreate.swift
Normal file
@ -0,0 +1,82 @@
|
||||
//
|
||||
// ProjectCreate.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 25.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ProjectCreateView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var leadID: String = ""
|
||||
@State private var customerName: String = ""
|
||||
@State private var creationDate: Date = .now
|
||||
@State private var tank: Tank = Tank()
|
||||
|
||||
/// Whether the entered values are sufficient to create a project.
|
||||
private var canSave: Bool {
|
||||
!leadID.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !customerName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Projektdaten") {
|
||||
TextField("Lead-ID", text: $leadID)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Kundenname", text: $customerName)
|
||||
.textInputAutocapitalization(.words)
|
||||
|
||||
DatePicker(
|
||||
"Erstellungsdatum",
|
||||
selection: $creationDate,
|
||||
displayedComponents: [.date]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Tankauswahl") {
|
||||
TankEditor(tank: $tank)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Projekt erstellen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen", systemImage: "xmark") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Speichern", systemImage: "checkmark") {
|
||||
save()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let project = Project(
|
||||
leadID: leadID.trimmingCharacters(in: .whitespaces),
|
||||
customerName: customerName.trimmingCharacters(in: .whitespaces),
|
||||
creationDate: creationDate,
|
||||
tank: tank
|
||||
)
|
||||
modelContext.insert(project)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProjectCreateView()
|
||||
.modelContainer(for: Project.self, inMemory: true)
|
||||
}
|
||||
66
GasTankAR/Views/ProjectDetailView.swift
Normal file
66
GasTankAR/Views/ProjectDetailView.swift
Normal file
@ -0,0 +1,66 @@
|
||||
//
|
||||
// ProjectDetailView.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 25.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ProjectDetailView: View {
|
||||
@Bindable var project: Project
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
detailsTab
|
||||
.tabItem { Label("Details", systemImage: "info.circle") }
|
||||
|
||||
GalleryView(project: project)
|
||||
.tabItem { Label("Galerie", systemImage: "photo.on.rectangle") }
|
||||
|
||||
DocuframeView(project: project)
|
||||
.tabItem { Label("DOCUframe", systemImage: "arrow.up.doc.on.clipboard") }
|
||||
}
|
||||
.navigationTitle(project.customerName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var detailsTab: some View {
|
||||
List {
|
||||
Section("Projektdaten") {
|
||||
LabeledContent("Kunde", value: project.customerName)
|
||||
LabeledContent("Lead-ID", value: project.leadID)
|
||||
LabeledContent(
|
||||
"Erstellt am",
|
||||
value: project.creationDate.formatted(.dateTime.day().month().year())
|
||||
)
|
||||
}
|
||||
|
||||
Section("Tankauswahl") {
|
||||
TankEditor(tank: $project.tank)
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
NavigationLink {
|
||||
ARExperienceView(project: project)
|
||||
} label: {
|
||||
Label("AR-Modus starten", systemImage: "arkit")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sample = PreviewSupport.singleProjectContainer()
|
||||
|
||||
return NavigationStack {
|
||||
ProjectDetailView(project: sample.project)
|
||||
}
|
||||
.modelContainer(sample.container)
|
||||
}
|
||||
58
GasTankAR/Views/ProjectListItem.swift
Normal file
58
GasTankAR/Views/ProjectListItem.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// ProjectListItem.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 25.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ProjectListItem: View {
|
||||
let project: Project
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(.tint)
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
Text(project.initials)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(project.customerName)
|
||||
.font(.body)
|
||||
Text(project.leadID)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(project.creationDate, format: .dateTime.day().month().year())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
ProjectListItem(
|
||||
project: Project(
|
||||
leadID: "LEAD-1001",
|
||||
customerName: "Müller Heizungsbau",
|
||||
creationDate: .now
|
||||
)
|
||||
)
|
||||
ProjectListItem(
|
||||
project: Project(
|
||||
leadID: "LEAD-1002",
|
||||
customerName: "Weber & Söhne",
|
||||
creationDate: .now
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
99
GasTankAR/Views/ProjectListView.swift
Normal file
99
GasTankAR/Views/ProjectListView.swift
Normal file
@ -0,0 +1,99 @@
|
||||
//
|
||||
// ProjectListView.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 25.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ProjectListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \Project.creationDate, order: .reverse) private var projects: [Project]
|
||||
|
||||
@State private var selectedProject: Project?
|
||||
@State private var isCreatingProject = false
|
||||
@State private var isShowingSettings = false
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selectedProject) {
|
||||
ForEach(projects) { project in
|
||||
ProjectListItem(project: project)
|
||||
.tag(project)
|
||||
}
|
||||
.onDelete(perform: deleteProjects)
|
||||
}
|
||||
.overlay {
|
||||
if projects.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Projekte",
|
||||
systemImage: "tray",
|
||||
description: Text("Tippe auf +, um dein erstes Projekt zu erstellen.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text("Projekte"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Einstellungen", systemImage: "gearshape") {
|
||||
isShowingSettings = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
GlassEffectContainer {
|
||||
Button {
|
||||
isCreatingProject = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2.weight(.semibold))
|
||||
.frame(width: 56, height: 56)
|
||||
.contentShape(.circle)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassEffect(.regular.interactive(), in: .circle)
|
||||
}
|
||||
.padding()
|
||||
.accessibilityLabel("Projekt erstellen")
|
||||
}
|
||||
.sheet(isPresented: $isCreatingProject) {
|
||||
ProjectCreateView()
|
||||
}
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
if let selectedProject {
|
||||
ProjectDetailView(project: selectedProject)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Kein Projekt ausgewählt",
|
||||
systemImage: "hand.point.up.left",
|
||||
description: Text("Wähle links ein Projekt aus, um die Details zu sehen.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteProjects(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
let project = projects[index]
|
||||
if project == selectedProject { selectedProject = nil }
|
||||
modelContext.delete(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Gefüllt") {
|
||||
ProjectListView()
|
||||
.modelContainer(PreviewSupport.container)
|
||||
}
|
||||
|
||||
#Preview("Leer") {
|
||||
ProjectListView()
|
||||
.modelContainer(PreviewSupport.makeEmptyContainer())
|
||||
}
|
||||
54
GasTankAR/Views/SettingsView.swift
Normal file
54
GasTankAR/Views/SettingsView.swift
Normal file
@ -0,0 +1,54 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import ARKit
|
||||
|
||||
/// Zentraler Schlüssel für die LiDAR-Einstellung (in AppStorage/UserDefaults).
|
||||
enum SettingsKeys {
|
||||
static let lidarFeaturesEnabled = "lidarFeaturesEnabled"
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// LiDAR ist verfügbar, wenn das Gerät Scene-Reconstruction (Mesh) unterstützt.
|
||||
private var lidarSupported: Bool {
|
||||
ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("LiDAR-Funktionen aktivieren", isOn: $lidarEnabled)
|
||||
.disabled(!lidarSupported)
|
||||
} header: {
|
||||
Text("Augmented Reality")
|
||||
} footer: {
|
||||
if lidarSupported {
|
||||
Text("Nutzt den LiDAR-Scanner für präzisere Flächenerkennung, Tiefe und realistische Verdeckung. Änderungen greifen beim nächsten Start des AR-Modus.")
|
||||
} else {
|
||||
Text("Dieses Gerät hat keinen LiDAR-Scanner. Die App nutzt automatisch das Verfahren ohne LiDAR.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Einstellungen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
}
|
||||
83
GasTankAR/Views/TankAR/ARControlOverlay.swift
Normal file
83
GasTankAR/Views/TankAR/ARControlOverlay.swift
Normal file
@ -0,0 +1,83 @@
|
||||
//
|
||||
// ARControlOverlay.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 27.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Runde Werkzeug-Buttons oben rechts über der AR-View. Untereinander angeordnet;
|
||||
/// weitere Funktionen lassen sich als zusätzliche `roundButton` ergänzen.
|
||||
struct ARControlOverlay: View {
|
||||
let controller: ARSessionController
|
||||
/// Öffnet die Strecken-Liste (Ausmessen).
|
||||
let onMeasure: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
roundButton(
|
||||
systemImage: "shield.lefthalf.filled",
|
||||
isActive: controller.showSafetyZones,
|
||||
label: "Sicherheitsabstand"
|
||||
) {
|
||||
controller.toggleSafetyZones()
|
||||
}
|
||||
|
||||
roundButton(
|
||||
systemImage: "ruler",
|
||||
isActive: controller.measurementPhase != .idle,
|
||||
label: "Ausmessen"
|
||||
) {
|
||||
onMeasure()
|
||||
}
|
||||
|
||||
roundButton(
|
||||
systemImage: "point.3.connected.trianglepath.dotted",
|
||||
isActive: controller.pipeRoutingPhase != .idle,
|
||||
label: "Rohrführung"
|
||||
) {
|
||||
if controller.pipeRoutingPhase == .idle {
|
||||
controller.startPipeRouting()
|
||||
}
|
||||
}
|
||||
|
||||
roundButton(
|
||||
systemImage: "camera.fill",
|
||||
isActive: false,
|
||||
label: "Foto aufnehmen"
|
||||
) {
|
||||
controller.captureSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func roundButton(
|
||||
systemImage: String,
|
||||
isActive: Bool,
|
||||
label: String,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3)
|
||||
.frame(width: 52, height: 52)
|
||||
.foregroundStyle(isActive ? Color.white : Color.primary)
|
||||
.background(
|
||||
isActive ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.ultraThinMaterial),
|
||||
in: .circle
|
||||
)
|
||||
.shadow(radius: 3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityAddTraits(isActive ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
284
GasTankAR/Views/TankAR/ARExperienceView.swift
Normal file
284
GasTankAR/Views/TankAR/ARExperienceView.swift
Normal file
@ -0,0 +1,284 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
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)
|
||||
}
|
||||
Color.white
|
||||
.opacity(flashOpacity)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
.onChange(of: controller.snapshotFlashCounter) {
|
||||
flashOpacity = 0.9
|
||||
withAnimation(.easeOut(duration: 0.35)) { flashOpacity = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(project.customerName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
252
GasTankAR/Views/TankAR/ARFlowStateMachine.swift
Normal file
252
GasTankAR/Views/TankAR/ARFlowStateMachine.swift
Normal file
@ -0,0 +1,252 @@
|
||||
//
|
||||
// ARFlowStateMachine.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Zustände des AR-Modus gemäß State-Machine (ohne den entfernten „Choose Tank"-State –
|
||||
/// stattdessen führt eine gefundene Location/ein erkannter Anker direkt in `immersive`).
|
||||
enum ARFlowState: Equatable {
|
||||
case worldNotInitialized
|
||||
case initializingWorld
|
||||
case initializingNotPossible
|
||||
case userMovesAround
|
||||
case showOptions
|
||||
case anchorDetection
|
||||
case askToCancel
|
||||
case selectLocation
|
||||
case immersive
|
||||
case relocating
|
||||
case relocationFailed
|
||||
}
|
||||
|
||||
/// Transparente ARKit-Statuslage (wird von der View in lokalisierten Text übersetzt).
|
||||
enum ARStatus: Equatable {
|
||||
case relocalizing
|
||||
case excessiveMotion
|
||||
case insufficientFeatures
|
||||
case unavailable
|
||||
}
|
||||
|
||||
/// Quelle der Transform beim Platzieren des Tanks.
|
||||
enum PlacementSource: Equatable {
|
||||
case storedTransform
|
||||
case screenCenter
|
||||
case imageAnchor
|
||||
}
|
||||
|
||||
/// Eingaben in die State-Machine: ARKit-Events, Timer und Nutzeraktionen.
|
||||
enum ARFlowEvent: Equatable {
|
||||
case started
|
||||
|
||||
// ARKit-Tracking
|
||||
case trackingNormal
|
||||
case trackingInitializing
|
||||
case trackingRelocalizing
|
||||
case trackingExcessiveMotion
|
||||
case trackingInsufficientFeatures
|
||||
case trackingUnavailable
|
||||
|
||||
// Coaching / Session
|
||||
case coachingWillActivate
|
||||
case coachingDidDeactivate
|
||||
case sessionInterruptionEnded
|
||||
case imageAnchorDetected
|
||||
|
||||
// Timer
|
||||
case moveAroundTimerFired
|
||||
case showOptionsTimerFired
|
||||
case anchorDetectionTimerFired
|
||||
case relocationTimerFired
|
||||
|
||||
// Nutzeraktionen
|
||||
case captureConfirmed // Raycast war erfolgreich (von der View vorgeprüft)
|
||||
case keepMovingTapped
|
||||
case placeAnchorMarkerTapped
|
||||
case tryAgainAnchorTapped
|
||||
case repositionTapped
|
||||
case restartTapped
|
||||
case retryRelocationTapped
|
||||
}
|
||||
|
||||
/// Seiteneffekte, die der Controller (unreine Schale) ausführt.
|
||||
enum ARFlowCommand: Equatable {
|
||||
case setStatus(ARStatus?)
|
||||
case runConfiguration(useImages: Bool, useInitialMap: Bool, resetTracking: Bool)
|
||||
case setCoachingAutomatic(Bool)
|
||||
case startMoveAroundTimer
|
||||
case startShowOptionsTimer
|
||||
case cancelEscalationTimers
|
||||
case startRelocationTimer
|
||||
case cancelRelocationTimer
|
||||
case startAnchorDetectionTimer
|
||||
case cancelAnchorDetectionTimer
|
||||
case placeTank(PlacementSource)
|
||||
case removeTank
|
||||
case persistWorldMap
|
||||
case persistTankTransform
|
||||
case clearStoredTransform
|
||||
}
|
||||
|
||||
/// Reine, testbare State-Machine des AR-Flows. Enthält keinerlei ARKit-/UI-Abhängigkeiten:
|
||||
/// `reduce` verändert den Zustand und liefert die auszuführenden Kommandos zurück.
|
||||
struct ARFlowStateMachine {
|
||||
private(set) var state: ARFlowState = .worldNotInitialized
|
||||
private(set) var hasPlacedTank = false
|
||||
private(set) var hasStoredTransform: Bool
|
||||
|
||||
init(hasStoredTransform: Bool) {
|
||||
self.hasStoredTransform = hasStoredTransform
|
||||
}
|
||||
|
||||
mutating func reduce(_ event: ARFlowEvent) -> [ARFlowCommand] {
|
||||
switch event {
|
||||
case .started:
|
||||
state = .worldNotInitialized
|
||||
return [.runConfiguration(useImages: false, useInitialMap: true, resetTracking: false)]
|
||||
|
||||
case .trackingNormal:
|
||||
var commands: [ARFlowCommand] = [
|
||||
.setStatus(nil), .cancelEscalationTimers, .cancelRelocationTimer, .setCoachingAutomatic(true),
|
||||
]
|
||||
commands += resolvePlacedOrRestore()
|
||||
return commands
|
||||
|
||||
case .trackingUnavailable:
|
||||
return [.setStatus(.unavailable)]
|
||||
|
||||
case .trackingInitializing:
|
||||
if !hasPlacedTank, state == .worldNotInitialized || state == .initializingWorld {
|
||||
state = .initializingWorld
|
||||
}
|
||||
return [.setStatus(nil)]
|
||||
|
||||
case .trackingRelocalizing:
|
||||
state = .relocating
|
||||
return [.setStatus(.relocalizing), .cancelRelocationTimer, .startRelocationTimer]
|
||||
|
||||
case .trackingExcessiveMotion:
|
||||
return [.setStatus(.excessiveMotion)]
|
||||
|
||||
case .trackingInsufficientFeatures:
|
||||
var commands: [ARFlowCommand] = [.setStatus(.insufficientFeatures)]
|
||||
let blockedStates: [ARFlowState] = [.anchorDetection, .showOptions, .askToCancel]
|
||||
if !hasPlacedTank, !blockedStates.contains(state), state != .userMovesAround {
|
||||
state = .initializingNotPossible
|
||||
commands += [.cancelEscalationTimers, .startMoveAroundTimer]
|
||||
}
|
||||
return commands
|
||||
|
||||
case .moveAroundTimerFired:
|
||||
guard state == .initializingNotPossible else { return [] }
|
||||
state = .userMovesAround
|
||||
return [.startShowOptionsTimer]
|
||||
|
||||
case .showOptionsTimerFired:
|
||||
guard state == .userMovesAround else { return [] }
|
||||
state = .showOptions
|
||||
return [.setCoachingAutomatic(false)]
|
||||
|
||||
case .keepMovingTapped:
|
||||
state = .userMovesAround
|
||||
return [.setCoachingAutomatic(true), .cancelEscalationTimers, .startShowOptionsTimer]
|
||||
|
||||
case .placeAnchorMarkerTapped:
|
||||
state = .anchorDetection
|
||||
return [
|
||||
.cancelEscalationTimers,
|
||||
.setCoachingAutomatic(false),
|
||||
.runConfiguration(useImages: true, useInitialMap: false, resetTracking: false),
|
||||
.cancelAnchorDetectionTimer,
|
||||
.startAnchorDetectionTimer,
|
||||
]
|
||||
|
||||
case .anchorDetectionTimerFired:
|
||||
guard state == .anchorDetection else { return [] }
|
||||
state = .askToCancel
|
||||
return []
|
||||
|
||||
case .tryAgainAnchorTapped:
|
||||
state = .showOptions
|
||||
return [
|
||||
.cancelAnchorDetectionTimer,
|
||||
.runConfiguration(useImages: false, useInitialMap: false, resetTracking: false),
|
||||
.setCoachingAutomatic(false),
|
||||
]
|
||||
|
||||
case .imageAnchorDetected:
|
||||
guard state == .anchorDetection else { return [] }
|
||||
state = .immersive
|
||||
hasPlacedTank = true
|
||||
return [.cancelAnchorDetectionTimer, .placeTank(.imageAnchor), .persistWorldMap, .persistTankTransform]
|
||||
|
||||
case .captureConfirmed:
|
||||
guard state == .selectLocation else { return [] }
|
||||
state = .immersive
|
||||
hasPlacedTank = true
|
||||
return [.placeTank(.screenCenter), .persistWorldMap, .persistTankTransform]
|
||||
|
||||
case .repositionTapped:
|
||||
state = .selectLocation
|
||||
hasPlacedTank = false
|
||||
hasStoredTransform = false
|
||||
return [.removeTank, .clearStoredTransform]
|
||||
|
||||
case .restartTapped:
|
||||
state = .worldNotInitialized
|
||||
hasPlacedTank = false
|
||||
hasStoredTransform = false
|
||||
return [
|
||||
.cancelRelocationTimer,
|
||||
.removeTank,
|
||||
.clearStoredTransform,
|
||||
.setStatus(nil),
|
||||
.runConfiguration(useImages: false, useInitialMap: false, resetTracking: true),
|
||||
]
|
||||
|
||||
case .retryRelocationTapped:
|
||||
state = .relocating
|
||||
return [.setStatus(.relocalizing), .cancelRelocationTimer, .startRelocationTimer]
|
||||
|
||||
case .relocationTimerFired:
|
||||
guard state == .relocating else { return [] }
|
||||
state = .relocationFailed
|
||||
return [.setStatus(nil)]
|
||||
|
||||
case .coachingWillActivate:
|
||||
if !hasPlacedTank { state = .initializingWorld }
|
||||
return []
|
||||
|
||||
case .coachingDidDeactivate:
|
||||
return [.cancelEscalationTimers] + resolvePlacedOrRestore()
|
||||
|
||||
case .sessionInterruptionEnded:
|
||||
var commands: [ARFlowCommand] = [.setStatus(nil)]
|
||||
if hasPlacedTank {
|
||||
state = .relocating
|
||||
commands += [.cancelRelocationTimer, .startRelocationTimer]
|
||||
}
|
||||
return commands
|
||||
}
|
||||
}
|
||||
|
||||
/// Gemeinsame Logik für „Tracking normal / Coaching fertig": Tank wiederherstellen,
|
||||
/// sonst in die Platzieren-UI wechseln (sofern nicht gerade Anker-Erkennung läuft).
|
||||
private mutating func resolvePlacedOrRestore() -> [ARFlowCommand] {
|
||||
if hasPlacedTank {
|
||||
state = .immersive
|
||||
return []
|
||||
}
|
||||
guard state != .anchorDetection else { return [] }
|
||||
if hasStoredTransform {
|
||||
state = .immersive
|
||||
hasPlacedTank = true
|
||||
return [.placeTank(.storedTransform)]
|
||||
}
|
||||
state = .selectLocation
|
||||
return []
|
||||
}
|
||||
}
|
||||
67
GasTankAR/Views/TankAR/ARProjectPersisting.swift
Normal file
67
GasTankAR/Views/TankAR/ARProjectPersisting.swift
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// ARProjectPersisting.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import ARKit
|
||||
import CoreLocation
|
||||
import SwiftData
|
||||
|
||||
/// Abstraktion der Projekt-Persistenz für den AR-Modus (Dependency Inversion):
|
||||
/// Der Controller kennt so kein SwiftData und ist unit-testbar.
|
||||
@MainActor
|
||||
protocol ARProjectPersisting: AnyObject {
|
||||
var tank: Tank { get }
|
||||
var worldMap: ARWorldMap? { get }
|
||||
var tankTransform: [Float]? { get }
|
||||
|
||||
func saveWorldMap(_ data: Data)
|
||||
func saveTankTransform(_ transform: [Float])
|
||||
func clearTankTransform()
|
||||
func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?)
|
||||
}
|
||||
|
||||
/// SwiftData-Adapter: verbindet das `Project`-Model mit dem `ARProjectPersisting`-Protokoll.
|
||||
@MainActor
|
||||
final class ProjectPersistenceAdapter: ARProjectPersisting {
|
||||
private let project: Project
|
||||
private let modelContext: ModelContext
|
||||
|
||||
init(project: Project, modelContext: ModelContext) {
|
||||
self.project = project
|
||||
self.modelContext = modelContext
|
||||
}
|
||||
|
||||
var tank: Tank { project.tank }
|
||||
var worldMap: ARWorldMap? { project.worldMap }
|
||||
var tankTransform: [Float]? { project.tankTransform }
|
||||
|
||||
func saveWorldMap(_ data: Data) {
|
||||
project.worldMapData = data
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
func saveTankTransform(_ transform: [Float]) {
|
||||
project.tankTransform = transform
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
func clearTankTransform() {
|
||||
project.tankTransform = nil
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?) {
|
||||
let photo = ProjectPhoto(
|
||||
creationDate: date,
|
||||
imageData: imageData,
|
||||
latitude: coordinate?.latitude,
|
||||
longitude: coordinate?.longitude,
|
||||
project: project
|
||||
)
|
||||
modelContext.insert(photo)
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
234
GasTankAR/Views/TankAR/ARStatusOverlay.swift
Normal file
234
GasTankAR/Views/TankAR/ARStatusOverlay.swift
Normal file
@ -0,0 +1,234 @@
|
||||
//
|
||||
// ARStatusOverlay.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import ARKit
|
||||
|
||||
/// Dezente, nicht-distraktive Overlays über der AR-View: ein schmaler Status-Banner
|
||||
/// oben sowie Aktions-Karten unten für die Entscheidungs-States der State-Machine.
|
||||
struct ARStatusOverlay: View {
|
||||
let controller: ARSessionController
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Fadenkreuz exakt im Bildschirmzentrum – dort trifft der Raycast.
|
||||
if controller.state == .selectLocation {
|
||||
crosshair
|
||||
}
|
||||
|
||||
VStack {
|
||||
if let message = controller.statusMessage {
|
||||
statusBanner(message)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
bottomContent
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: controller.statusMessage)
|
||||
.animation(.easeInOut(duration: 0.25), value: controller.state)
|
||||
.animation(.easeInOut(duration: 0.25), value: controller.isTankSelected)
|
||||
}
|
||||
|
||||
// MARK: - Fadenkreuz & Auslöser (Kamera-Style)
|
||||
|
||||
private var crosshair: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.9), lineWidth: 2)
|
||||
.frame(width: 44, height: 44)
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: 5, height: 5)
|
||||
}
|
||||
.shadow(radius: 2)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private var shutterButton: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("Tank platzieren")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial, in: .capsule)
|
||||
|
||||
Button {
|
||||
controller.capture()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.white, lineWidth: 4)
|
||||
.frame(width: 74, height: 74)
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: 60, height: 60)
|
||||
}
|
||||
.shadow(radius: 4)
|
||||
.contentShape(.circle)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Tank platzieren")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status-Banner
|
||||
|
||||
private func statusBanner(_ message: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial, in: .capsule)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Untere Aktions-Inhalte je State
|
||||
|
||||
@ViewBuilder
|
||||
private var bottomContent: some View {
|
||||
switch controller.state {
|
||||
case .selectLocation:
|
||||
shutterButton
|
||||
|
||||
case .showOptions:
|
||||
actionCard(
|
||||
title: "Zu wenige Merkmale erkannt",
|
||||
message: "Die Umgebung bietet ARKit zu wenig Struktur."
|
||||
) {
|
||||
Button("Weiter umhergehen") { controller.keepMoving() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Marker platzieren") { controller.placeAnchorMarker() }
|
||||
.buttonStyle(.bordered)
|
||||
Button("Abbrechen", role: .cancel, action: onCancel)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
case .anchorDetection:
|
||||
hintCard(
|
||||
icon: "viewfinder",
|
||||
text: "Richte die Kamera auf den Marker, um ihn zu erkennen.",
|
||||
showsProgress: true,
|
||||
onCancel: onCancel
|
||||
)
|
||||
|
||||
case .askToCancel:
|
||||
actionCard(
|
||||
title: "Marker nicht erkannt",
|
||||
message: "Der Marker konnte nicht zuverlässig erkannt werden."
|
||||
) {
|
||||
Button("Erneut versuchen") { controller.tryAgainAnchor() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Abbrechen", role: .cancel, action: onCancel)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
case .relocating:
|
||||
hintCard(
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
text: "Position wird wiederhergestellt …",
|
||||
showsProgress: true
|
||||
)
|
||||
|
||||
case .relocationFailed:
|
||||
actionCard(
|
||||
title: "Position nicht gefunden",
|
||||
message: "Die gespeicherte Position konnte nicht wiederhergestellt werden. Neu starten und den Tank neu platzieren?"
|
||||
) {
|
||||
Button("Neu starten & platzieren", systemImage: "arrow.counterclockwise") {
|
||||
controller.restart()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Weiter versuchen") {
|
||||
controller.retryRelocation()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Abbrechen", role: .cancel, action: onCancel)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
case .immersive:
|
||||
if controller.isTankSelected {
|
||||
TankManipulationPanel(controller: controller)
|
||||
} else if controller.measurementPhase == .idle && controller.pipeRoutingPhase == .idle {
|
||||
Text("Tank antippen zum Neu-Platzieren · Ziehen / 2 Finger / Pinch zum Anpassen")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial, in: .capsule)
|
||||
}
|
||||
|
||||
default:
|
||||
// initializingWorld / initializingNotPossible / userMovesAround übernimmt das
|
||||
// Coaching-Overlay; immersive/worldNotInitialized brauchen keine Aktion.
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
private func hintCard(
|
||||
icon: String,
|
||||
text: String,
|
||||
showsProgress: Bool = false,
|
||||
onCancel: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
if showsProgress {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
Spacer(minLength: 0)
|
||||
if let onCancel {
|
||||
Button("Abbrechen", role: .cancel, action: onCancel)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private func actionCard<Buttons: View>(
|
||||
title: String,
|
||||
message: String,
|
||||
@ViewBuilder buttons: () -> Buttons
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
buttons()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
29
GasTankAR/Views/TankAR/ARViewContainer.swift
Normal file
29
GasTankAR/Views/TankAR/ARViewContainer.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// ARViewContainer.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import RealityKit
|
||||
|
||||
/// Dünner UIKit-Adapter: hostet nur die `ARView` und übergibt sie an den Controller.
|
||||
/// Enthält bewusst keine Logik.
|
||||
struct ARViewContainer: UIViewRepresentable {
|
||||
let controller: ARSessionController
|
||||
|
||||
func makeCoordinator() -> ARSessionController { controller }
|
||||
|
||||
func makeUIView(context: Context) -> ARView {
|
||||
let view = ARView(frame: .zero)
|
||||
context.coordinator.attach(to: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ARView, context: Context) {}
|
||||
|
||||
static func dismantleUIView(_ uiView: ARView, coordinator: ARSessionController) {
|
||||
MainActor.assumeIsolated { coordinator.detach() }
|
||||
}
|
||||
}
|
||||
57
GasTankAR/Views/TankAR/DirtTextureFactory.swift
Normal file
57
GasTankAR/Views/TankAR/DirtTextureFactory.swift
Normal file
@ -0,0 +1,57 @@
|
||||
//
|
||||
// DirtTextureFactory.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
/// Erzeugt prozedural eine kachelbare Erd-/Dreck-Textur (ohne Bild-Asset) für die Grubenwand,
|
||||
/// damit sichtbar wird, dass sich der Tank unter der Erde befindet.
|
||||
@MainActor
|
||||
enum DirtTextureFactory {
|
||||
/// Einmalig erzeugte, wiederverwendete Textur.
|
||||
static let texture: TextureResource? = makeTexture()
|
||||
|
||||
private static func makeTexture() -> TextureResource? {
|
||||
let size = CGSize(width: 256, height: 256)
|
||||
let image = UIGraphicsImageRenderer(size: size).image { context in
|
||||
let rect = CGRect(origin: .zero, size: size)
|
||||
|
||||
// Erd-Grundton.
|
||||
UIColor(red: 0.42, green: 0.30, blue: 0.18, alpha: 1).setFill()
|
||||
context.fill(rect)
|
||||
|
||||
// Größere Erdflecken / Steine für Struktur.
|
||||
for _ in 0..<70 {
|
||||
let diameter = CGFloat.random(in: 6...26)
|
||||
let origin = CGPoint(x: .random(in: -10...size.width), y: .random(in: -10...size.height))
|
||||
let brightness = CGFloat.random(in: -0.12...0.12)
|
||||
UIColor(
|
||||
red: max(0, min(1, 0.42 + brightness)),
|
||||
green: max(0, min(1, 0.30 + brightness)),
|
||||
blue: max(0, min(1, 0.18 + brightness)),
|
||||
alpha: 1
|
||||
).setFill()
|
||||
context.cgContext.fillEllipse(in: CGRect(x: origin.x, y: origin.y, width: diameter, height: diameter))
|
||||
}
|
||||
|
||||
// Feine Körnung (helle + dunkle Sprenkel).
|
||||
for _ in 0..<2000 {
|
||||
let point = CGPoint(x: .random(in: 0..<size.width), y: .random(in: 0..<size.height))
|
||||
let diameter = CGFloat.random(in: 0.5...2.5)
|
||||
let isDark = Bool.random()
|
||||
let color = isDark
|
||||
? UIColor(white: 0, alpha: .random(in: 0.05...0.2))
|
||||
: UIColor(white: 1, alpha: .random(in: 0.03...0.12))
|
||||
color.setFill()
|
||||
context.cgContext.fillEllipse(in: CGRect(x: point.x, y: point.y, width: diameter, height: diameter))
|
||||
}
|
||||
}
|
||||
|
||||
guard let cgImage = image.cgImage else { return nil }
|
||||
return try? TextureResource.generate(from: cgImage, options: .init(semantic: .color))
|
||||
}
|
||||
}
|
||||
26
GasTankAR/Views/TankAR/Matrix+Array.swift
Normal file
26
GasTankAR/Views/TankAR/Matrix+Array.swift
Normal file
@ -0,0 +1,26 @@
|
||||
//
|
||||
// Matrix+Array.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import simd
|
||||
|
||||
extension simd_float4x4 {
|
||||
/// Spaltenweise Darstellung als 16 Floats (für die Persistenz).
|
||||
var floatArray: [Float] {
|
||||
[columns.0, columns.1, columns.2, columns.3].flatMap { [$0.x, $0.y, $0.z, $0.w] }
|
||||
}
|
||||
|
||||
/// Rekonstruiert eine Matrix aus 16 spaltenweisen Floats.
|
||||
init?(floatArray array: [Float]) {
|
||||
guard array.count == 16 else { return nil }
|
||||
self.init(
|
||||
SIMD4(array[0], array[1], array[2], array[3]),
|
||||
SIMD4(array[4], array[5], array[6], array[7]),
|
||||
SIMD4(array[8], array[9], array[10], array[11]),
|
||||
SIMD4(array[12], array[13], array[14], array[15])
|
||||
)
|
||||
}
|
||||
}
|
||||
37
GasTankAR/Views/TankAR/Measurement.swift
Normal file
37
GasTankAR/Views/TankAR/Measurement.swift
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// Measurement.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import simd
|
||||
|
||||
/// Eine benannte Strecke zwischen zwei im Raum gewählten Punkten (Welt-Koordinaten).
|
||||
struct Measurement: Identifiable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
let start: SIMD3<Float>
|
||||
let end: SIMD3<Float>
|
||||
|
||||
/// Länge der Strecke in Metern.
|
||||
var distance: Float { simd_distance(start, end) }
|
||||
|
||||
/// Länge als lesbarer Text, z. B. „1,23 m".
|
||||
var formattedDistance: String {
|
||||
String(format: "%.2f m", distance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ablaufphasen beim Anlegen einer Strecke.
|
||||
enum MeasurementPhase: Equatable {
|
||||
/// Kein Messvorgang aktiv.
|
||||
case idle
|
||||
/// Wartet auf den ersten Punkt.
|
||||
case awaitingFirstPoint
|
||||
/// Wartet auf den zweiten Punkt.
|
||||
case awaitingSecondPoint
|
||||
/// Beide Punkte gesetzt – wartet auf die Benennung.
|
||||
case naming
|
||||
}
|
||||
98
GasTankAR/Views/TankAR/MeasurementEntityFactory.swift
Normal file
98
GasTankAR/Views/TankAR/MeasurementEntityFactory.swift
Normal file
@ -0,0 +1,98 @@
|
||||
//
|
||||
// MeasurementEntityFactory.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
/// Baut die AR-Visualisierung einer Strecke: zwei Endpunkt-Kugeln, die verbindende Linie
|
||||
/// sowie ein Text-Label (Bezeichnung + Länge) am Mittelpunkt der Strecke.
|
||||
///
|
||||
/// Enthält außerdem wiederverwendbare Bausteine für die *Live*-Vorschau während des Messens
|
||||
/// (Cursor-Kugel, skalierbare Linie, aktualisierbares Label).
|
||||
@MainActor
|
||||
enum MeasurementEntityFactory {
|
||||
/// Name des Label-Halters – wird zum Wiederfinden fürs Billboarding genutzt.
|
||||
static let labelName = "MeasurementLabel"
|
||||
|
||||
private static let color = UIColor.systemYellow
|
||||
private static let pointRadius: Float = 0.012
|
||||
private static let lineThickness: Float = 0.006
|
||||
private static let labelHeight: Float = 0.06
|
||||
private static let labelYOffset: Float = 0.03
|
||||
|
||||
/// Kleine Kugel für einen (auch vorläufigen) Messpunkt.
|
||||
static func makePoint(at position: SIMD3<Float>) -> ModelEntity {
|
||||
let sphere = ModelEntity(
|
||||
mesh: .generateSphere(radius: pointRadius),
|
||||
materials: [UnlitMaterial(color: color)]
|
||||
)
|
||||
sphere.position = position
|
||||
return sphere
|
||||
}
|
||||
|
||||
/// Vollständige Strecken-Visualisierung (Punkte + Linie + Label).
|
||||
static func makeMeasurement(_ measurement: Measurement) -> Entity {
|
||||
let container = Entity()
|
||||
container.name = "Measurement-\(measurement.id.uuidString)"
|
||||
container.addChild(makePoint(at: measurement.start))
|
||||
container.addChild(makePoint(at: measurement.end))
|
||||
|
||||
let line = makeLineBase()
|
||||
configureLine(line, from: measurement.start, to: measurement.end)
|
||||
container.addChild(line)
|
||||
|
||||
let label = makeLabelHolder()
|
||||
label.position = labelPosition(from: measurement.start, to: measurement.end)
|
||||
label.addChild(makeText("\(measurement.name) (\(measurement.formattedDistance))"))
|
||||
container.addChild(label)
|
||||
return container
|
||||
}
|
||||
|
||||
/// Basis-Linie der Länge 1 m (entlang +Z). Für die Live-Linie wird sie via ``configureLine`` skaliert/rotiert.
|
||||
static func makeLineBase() -> ModelEntity {
|
||||
ModelEntity(
|
||||
mesh: .generateBox(size: SIMD3(lineThickness, lineThickness, 1)),
|
||||
materials: [UnlitMaterial(color: color)]
|
||||
)
|
||||
}
|
||||
|
||||
/// Richtet eine per ``makeLineBase`` erzeugte Linie zwischen zwei Punkten aus (Länge über Z-Skalierung).
|
||||
static func configureLine(_ line: Entity, from start: SIMD3<Float>, to end: SIMD3<Float>) {
|
||||
let length = max(simd_distance(start, end), 0.0001)
|
||||
line.transform = Transform(
|
||||
scale: SIMD3(1, 1, length),
|
||||
rotation: simd_quatf(from: SIMD3(0, 0, 1), to: normalize(end - start)),
|
||||
translation: (start + end) / 2
|
||||
)
|
||||
}
|
||||
|
||||
/// Leerer Label-Halter (Billboard-Anker). Der Text wird als zentriertes Kind eingehängt.
|
||||
static func makeLabelHolder() -> Entity {
|
||||
let holder = Entity()
|
||||
holder.name = labelName
|
||||
return holder
|
||||
}
|
||||
|
||||
/// Zentriertes Text-Entity für einen Label-Halter.
|
||||
static func makeText(_ string: String) -> ModelEntity {
|
||||
let mesh = MeshResource.generateText(
|
||||
string,
|
||||
extrusionDepth: 0.001,
|
||||
font: .systemFont(ofSize: CGFloat(labelHeight), weight: .semibold)
|
||||
)
|
||||
var material = UnlitMaterial(color: .white)
|
||||
material.faceCulling = .none
|
||||
let text = ModelEntity(mesh: mesh, materials: [material])
|
||||
text.position = -text.visualBounds(relativeTo: nil).center
|
||||
return text
|
||||
}
|
||||
|
||||
/// Position des Labels (Streckenmitte, leicht angehoben).
|
||||
static func labelPosition(from start: SIMD3<Float>, to end: SIMD3<Float>) -> SIMD3<Float> {
|
||||
(start + end) / 2 + SIMD3(0, labelYOffset, 0)
|
||||
}
|
||||
}
|
||||
59
GasTankAR/Views/TankAR/MeasurementListView.swift
Normal file
59
GasTankAR/Views/TankAR/MeasurementListView.swift
Normal file
@ -0,0 +1,59 @@
|
||||
//
|
||||
// MeasurementListView.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Von unten einschwebendes Sheet mit den angelegten Strecken. Ist keine vorhanden,
|
||||
/// wird zum Hinzufügen aufgefordert.
|
||||
struct MeasurementListView: View {
|
||||
let controller: ARSessionController
|
||||
/// Startet einen neuen Messvorgang (schließt das Sheet und lässt den Nutzer zwei Punkte wählen).
|
||||
let onAddStrecke: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if controller.measurements.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Keine Strecken", systemImage: "ruler")
|
||||
} description: {
|
||||
Text("Füge eine Strecke hinzu und wähle dafür zwei Punkte im Raum.")
|
||||
} actions: {
|
||||
Button("Strecke hinzufügen", systemImage: "plus", action: onAddStrecke)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(controller.measurements) { measurement in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(measurement.name)
|
||||
.font(.headline)
|
||||
Text(measurement.formattedDistance)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "ruler")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onDelete { controller.deleteMeasurements(at: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Strecken")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Hinzufügen", systemImage: "plus", action: onAddStrecke)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
107
GasTankAR/Views/TankAR/PhotoEncoder.swift
Normal file
107
GasTankAR/Views/TankAR/PhotoEncoder.swift
Normal file
@ -0,0 +1,107 @@
|
||||
//
|
||||
// PhotoEncoder.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
import CoreLocation
|
||||
import UIKit
|
||||
|
||||
/// Kodiert einen Snapshot als JPEG und bettet dabei EXIF-Aufnahmezeit sowie – falls vorhanden –
|
||||
/// GPS-Metadaten direkt in die Bilddatei ein (ImageIO). So tragen auch exportierte Bilder ihren
|
||||
/// Aufnahmeort mit sich.
|
||||
enum PhotoEncoder {
|
||||
/// Erzeugt JPEG-Daten mit eingebetteten Metadaten. Fällt bei Fehlern auf `UIImage.jpegData` zurück.
|
||||
static func jpegData(
|
||||
from image: UIImage,
|
||||
coordinate: CLLocationCoordinate2D?,
|
||||
date: Date,
|
||||
compressionQuality: CGFloat = 0.9
|
||||
) -> Data? {
|
||||
guard let cgImage = image.cgImage else {
|
||||
return image.jpegData(compressionQuality: compressionQuality)
|
||||
}
|
||||
|
||||
let output = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(
|
||||
output, UTType.jpeg.identifier as CFString, 1, nil
|
||||
) else {
|
||||
return image.jpegData(compressionQuality: compressionQuality)
|
||||
}
|
||||
|
||||
var properties: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: compressionQuality,
|
||||
kCGImagePropertyOrientation: CGImagePropertyOrientation(image.imageOrientation).rawValue,
|
||||
kCGImagePropertyExifDictionary: [
|
||||
kCGImagePropertyExifDateTimeOriginal: Self.exifDateFormatter.string(from: date),
|
||||
kCGImagePropertyExifDateTimeDigitized: Self.exifDateFormatter.string(from: date),
|
||||
],
|
||||
]
|
||||
|
||||
if let coordinate {
|
||||
properties[kCGImagePropertyGPSDictionary] = gpsProperties(for: coordinate, date: date)
|
||||
}
|
||||
|
||||
CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return image.jpegData(compressionQuality: compressionQuality)
|
||||
}
|
||||
return output as Data
|
||||
}
|
||||
|
||||
private static func gpsProperties(for coordinate: CLLocationCoordinate2D, date: Date) -> [CFString: Any] {
|
||||
[
|
||||
kCGImagePropertyGPSLatitude: abs(coordinate.latitude),
|
||||
kCGImagePropertyGPSLatitudeRef: coordinate.latitude >= 0 ? "N" : "S",
|
||||
kCGImagePropertyGPSLongitude: abs(coordinate.longitude),
|
||||
kCGImagePropertyGPSLongitudeRef: coordinate.longitude >= 0 ? "E" : "W",
|
||||
kCGImagePropertyGPSDateStamp: Self.gpsDateFormatter.string(from: date),
|
||||
kCGImagePropertyGPSTimeStamp: Self.gpsTimeFormatter.string(from: date),
|
||||
]
|
||||
}
|
||||
|
||||
/// EXIF-Zeitstempel in lokaler Zeit („yyyy:MM:dd HH:mm:ss").
|
||||
private static let exifDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// GPS-Datum in UTC („yyyy:MM:dd").
|
||||
private static let gpsDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
formatter.dateFormat = "yyyy:MM:dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// GPS-Uhrzeit in UTC („HH:mm:ss").
|
||||
private static let gpsTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
private extension CGImagePropertyOrientation {
|
||||
init(_ orientation: UIImage.Orientation) {
|
||||
switch orientation {
|
||||
case .up: self = .up
|
||||
case .down: self = .down
|
||||
case .left: self = .left
|
||||
case .right: self = .right
|
||||
case .upMirrored: self = .upMirrored
|
||||
case .downMirrored: self = .downMirrored
|
||||
case .leftMirrored: self = .leftMirrored
|
||||
case .rightMirrored: self = .rightMirrored
|
||||
@unknown default: self = .up
|
||||
}
|
||||
}
|
||||
}
|
||||
49
GasTankAR/Views/TankAR/PipeRoute.swift
Normal file
49
GasTankAR/Views/TankAR/PipeRoute.swift
Normal file
@ -0,0 +1,49 @@
|
||||
//
|
||||
// PipeRoute.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import simd
|
||||
|
||||
/// Eine Rohrführung als geordnete Folge von Wegpunkten (Welt-Koordinaten), die zu einem
|
||||
/// zusammenhängenden Pfad verbunden werden – visualisiert die geplante Rohrverlegung.
|
||||
struct PipeRoute {
|
||||
private(set) var waypoints: [SIMD3<Float>] = []
|
||||
|
||||
var count: Int { waypoints.count }
|
||||
|
||||
/// Gesamtlänge des Pfads über alle Segmente in Metern.
|
||||
var totalLength: Float {
|
||||
guard waypoints.count > 1 else { return 0 }
|
||||
return zip(waypoints, waypoints.dropFirst())
|
||||
.reduce(0) { $0 + simd_distance($1.0, $1.1) }
|
||||
}
|
||||
|
||||
var formattedLength: String {
|
||||
String(format: "%.2f m", totalLength)
|
||||
}
|
||||
|
||||
mutating func add(_ waypoint: SIMD3<Float>) {
|
||||
waypoints.append(waypoint)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func removeLast() -> SIMD3<Float>? {
|
||||
waypoints.isEmpty ? nil : waypoints.removeLast()
|
||||
}
|
||||
|
||||
mutating func clear() {
|
||||
waypoints.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ablaufphasen beim Erstellen einer Rohrführung.
|
||||
enum PipeRoutingPhase: Equatable {
|
||||
/// Kein Rohr-Routing aktiv.
|
||||
case idle
|
||||
/// Wegpunkte werden gesetzt.
|
||||
case placing
|
||||
}
|
||||
54
GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift
Normal file
54
GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift
Normal file
@ -0,0 +1,54 @@
|
||||
//
|
||||
// PipeRouteEntityFactory.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
/// Baut die AR-Visualisierung einer Rohrführung: Wegpunkt-Kugeln und die verbindenden
|
||||
/// Segmente. Bewusst in einer markanten, naturfremden Farbe (Pink/Magenta) gehalten.
|
||||
@MainActor
|
||||
enum PipeRouteEntityFactory {
|
||||
/// Markante, in der Natur kaum vorkommende Linien-/Punktfarbe.
|
||||
private static let color = UIColor.systemPink
|
||||
private static let waypointRadius: Float = 0.02
|
||||
private static let segmentThickness: Float = 0.012
|
||||
|
||||
/// Kugel für einen Wegpunkt.
|
||||
static func makeWaypoint(at position: SIMD3<Float>) -> ModelEntity {
|
||||
let sphere = ModelEntity(
|
||||
mesh: .generateSphere(radius: waypointRadius),
|
||||
materials: [UnlitMaterial(color: color)]
|
||||
)
|
||||
sphere.position = position
|
||||
return sphere
|
||||
}
|
||||
|
||||
/// Verbindungssegment zwischen zwei Wegpunkten.
|
||||
static func makeSegment(from start: SIMD3<Float>, to end: SIMD3<Float>) -> ModelEntity {
|
||||
let segment = makeSegmentBase()
|
||||
configureSegment(segment, from: start, to: end)
|
||||
return segment
|
||||
}
|
||||
|
||||
/// Basis-Segment der Länge 1 m (entlang +Z) – für die Live-Vorschau wiederverwendbar.
|
||||
static func makeSegmentBase() -> ModelEntity {
|
||||
ModelEntity(
|
||||
mesh: .generateBox(size: SIMD3(segmentThickness, segmentThickness, 1)),
|
||||
materials: [UnlitMaterial(color: color)]
|
||||
)
|
||||
}
|
||||
|
||||
/// Richtet ein Segment zwischen zwei Punkten aus (Länge über Z-Skalierung).
|
||||
static func configureSegment(_ segment: Entity, from start: SIMD3<Float>, to end: SIMD3<Float>) {
|
||||
let length = max(simd_distance(start, end), 0.0001)
|
||||
segment.transform = Transform(
|
||||
scale: SIMD3(1, 1, length),
|
||||
rotation: simd_quatf(from: SIMD3(0, 0, 1), to: normalize(end - start)),
|
||||
translation: (start + end) / 2
|
||||
)
|
||||
}
|
||||
}
|
||||
56
GasTankAR/Views/TankAR/ReferenceImageProvider.swift
Normal file
56
GasTankAR/Views/TankAR/ReferenceImageProvider.swift
Normal file
@ -0,0 +1,56 @@
|
||||
//
|
||||
// ReferenceImageProvider.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import ARKit
|
||||
import UIKit
|
||||
|
||||
/// Liefert die Referenzbilder für die Anker-Erkennung. Bevorzugt die AR-Ressourcengruppe
|
||||
/// „ARMarkers"; fällt sonst auf einen programmatisch erzeugten Platzhalter-Marker zurück.
|
||||
enum ReferenceImageProvider {
|
||||
static func load() -> Set<ARReferenceImage> {
|
||||
if let group = ARReferenceImage.referenceImages(inGroupNamed: "ARMarkers", bundle: .main) {
|
||||
return group
|
||||
}
|
||||
if let placeholder = makePlaceholder() {
|
||||
return [placeholder]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func makePlaceholder() -> ARReferenceImage? {
|
||||
let size = CGSize(width: 512, height: 512)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
let image = renderer.image { context in
|
||||
let cg = context.cgContext
|
||||
UIColor.white.setFill()
|
||||
cg.fill(CGRect(origin: .zero, size: size))
|
||||
|
||||
let palette: [UIColor] = [.black, .systemBlue, .systemRed, .systemGreen, .systemOrange, .systemPurple]
|
||||
let cells = 8
|
||||
let cellSize = size.width / CGFloat(cells)
|
||||
for row in 0..<cells {
|
||||
for column in 0..<cells {
|
||||
let index = (row * cells + column) % palette.count
|
||||
palette[index].setFill()
|
||||
let rect = CGRect(
|
||||
x: CGFloat(column) * cellSize,
|
||||
y: CGFloat(row) * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize
|
||||
)
|
||||
if (row + column).isMultiple(of: 2) {
|
||||
cg.fillEllipse(in: rect.insetBy(dx: 4, dy: 4))
|
||||
} else {
|
||||
cg.fill(rect.insetBy(dx: 6, dy: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let cgImage = image.cgImage else { return nil }
|
||||
return ARReferenceImage(cgImage, orientation: .up, physicalWidth: 0.15)
|
||||
}
|
||||
}
|
||||
128
GasTankAR/Views/TankAR/SafetyZone.swift
Normal file
128
GasTankAR/Views/TankAR/SafetyZone.swift
Normal file
@ -0,0 +1,128 @@
|
||||
//
|
||||
// SafetyZone.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 27.08.26.
|
||||
//
|
||||
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
/// Ein Sicherheitsabstand rund um den Tank: ein Kreis mit gegebenem Radius und Farbe,
|
||||
/// dessen Mittelpunkt der Tank ist.
|
||||
///
|
||||
/// 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).
|
||||
let radius: Float
|
||||
/// Farbe des Rings.
|
||||
let color: UIColor
|
||||
/// Anzeigename, z. B. für Barrierefreiheit oder eine spätere Legende.
|
||||
let label: String
|
||||
|
||||
/// Alle darzustellenden Sicherheitsabstände. Zum Ergänzen genügt ein weiterer Eintrag.
|
||||
static let all: [SafetyZone] = [
|
||||
SafetyZone(radius: 3, color: .systemRed, label: "3 m"),
|
||||
SafetyZone(radius: 5, color: .systemOrange, label: "5 m"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Baut die flachen, auf dem Boden liegenden Ringe für die Sicherheitsabstände.
|
||||
@MainActor
|
||||
enum SafetyZoneFactory {
|
||||
/// Halbe Linienbreite des Rings in Metern (Ring = Radius ± Halbbreite).
|
||||
private static let lineHalfWidth: Float = 0.03
|
||||
/// Anzahl der Segmente pro Ring (höher = runder).
|
||||
private static let segments = 96
|
||||
/// Deckkraft der Ringe.
|
||||
private static let opacity: Float = 0.85
|
||||
/// Texthöhe der Meter-Beschriftung in Metern.
|
||||
private static let labelHeight: Float = 0.25
|
||||
/// Winkelpositionen (im Bogenmaß), an denen die Beschriftung auf dem Ring liegt.
|
||||
/// Mehrere Positionen sorgen dafür, dass die Meterangabe aus jeder Richtung lesbar ist.
|
||||
private static let labelAngles: [Float] = [0, .pi / 2, .pi, 3 * .pi / 2]
|
||||
|
||||
/// Erzeugt eine Entity mit je einem Ring pro Sicherheitsabstand, zentriert im Ursprung
|
||||
/// und flach in der XZ-Ebene (y = 0) liegend.
|
||||
static func makeEntity(for zones: [SafetyZone] = SafetyZone.all) -> Entity {
|
||||
let root = Entity()
|
||||
root.name = "SafetyZones"
|
||||
for zone in zones {
|
||||
root.addChild(makeRing(for: zone))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private static func makeRing(for zone: SafetyZone) -> Entity {
|
||||
let container = Entity()
|
||||
container.name = "SafetyZone-\(zone.label)"
|
||||
|
||||
let mesh = annulusMesh(radius: zone.radius, halfWidth: lineHalfWidth, segments: segments)
|
||||
|
||||
var material = UnlitMaterial(color: zone.color.withAlphaComponent(CGFloat(opacity)))
|
||||
material.blending = .transparent(opacity: .init(floatLiteral: opacity))
|
||||
// Beidseitig sichtbar – unabhängig von der Blickrichtung auf den Boden.
|
||||
material.faceCulling = .none
|
||||
|
||||
container.addChild(ModelEntity(mesh: mesh, materials: [material]))
|
||||
|
||||
for angle in labelAngles {
|
||||
container.addChild(makeLabel(for: zone, at: angle))
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
/// Erzeugt eine flach auf dem Boden liegende Meter-Beschriftung, tangential zum Ring
|
||||
/// ausgerichtet (Oberkante zeigt zur Mitte, damit sie von außen aufrecht lesbar ist).
|
||||
private static func makeLabel(for zone: SafetyZone, at angle: Float) -> Entity {
|
||||
let mesh = MeshResource.generateText(
|
||||
zone.label,
|
||||
extrusionDepth: 0.002,
|
||||
font: .systemFont(ofSize: CGFloat(labelHeight), weight: .bold)
|
||||
)
|
||||
|
||||
var material = UnlitMaterial(color: zone.color)
|
||||
material.faceCulling = .none
|
||||
|
||||
let text = ModelEntity(mesh: mesh, materials: [material])
|
||||
// Auf den eigenen Ursprung zentrieren.
|
||||
let bounds = text.visualBounds(relativeTo: nil)
|
||||
text.position = -bounds.center
|
||||
|
||||
let holder = Entity()
|
||||
holder.addChild(text)
|
||||
holder.position = SIMD3(sin(angle) * zone.radius, 0.01, cos(angle) * zone.radius)
|
||||
holder.orientation = simd_quatf(angle: angle, axis: SIMD3(0, 1, 0))
|
||||
* simd_quatf(angle: -.pi / 2, axis: SIMD3(1, 0, 0))
|
||||
return holder
|
||||
}
|
||||
|
||||
/// Erzeugt einen flachen Kreisring (Annulus) in der XZ-Ebene.
|
||||
private static func annulusMesh(radius: Float, halfWidth: Float, segments: Int) -> MeshResource {
|
||||
var positions: [SIMD3<Float>] = []
|
||||
var indices: [UInt32] = []
|
||||
let inner = max(0, radius - halfWidth)
|
||||
let outer = radius + halfWidth
|
||||
|
||||
for i in 0...segments {
|
||||
let angle = Float(i) / Float(segments) * 2 * .pi
|
||||
let cosA = cos(angle)
|
||||
let sinA = sin(angle)
|
||||
positions.append(SIMD3(inner * cosA, 0, inner * sinA))
|
||||
positions.append(SIMD3(outer * cosA, 0, outer * sinA))
|
||||
}
|
||||
|
||||
for i in 0..<segments {
|
||||
let base = UInt32(i * 2)
|
||||
indices.append(contentsOf: [base, base + 1, base + 2])
|
||||
indices.append(contentsOf: [base + 2, base + 1, base + 3])
|
||||
}
|
||||
|
||||
var descriptor = MeshDescriptor(name: "safetyRing")
|
||||
descriptor.positions = MeshBuffers.Positions(positions)
|
||||
descriptor.primitives = .triangles(indices)
|
||||
return (try? MeshResource.generate(from: [descriptor])) ?? .generatePlane(width: 0, depth: 0)
|
||||
}
|
||||
}
|
||||
47
GasTankAR/Views/TankAR/SessionConfigurationFactory.swift
Normal file
47
GasTankAR/Views/TankAR/SessionConfigurationFactory.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// SessionConfigurationFactory.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import ARKit
|
||||
|
||||
/// Baut die `ARWorldTrackingConfiguration` für den AR-Modus.
|
||||
/// Kapselt die LiDAR-/People-Occlusion-/Image-Detection-Logik an einem Ort.
|
||||
struct SessionConfigurationFactory {
|
||||
let useLiDAR: Bool
|
||||
let referenceImages: Set<ARReferenceImage>
|
||||
|
||||
func make(withImages: Bool, initialWorldMap: ARWorldMap?) -> ARWorldTrackingConfiguration {
|
||||
let config = ARWorldTrackingConfiguration()
|
||||
config.planeDetection = [.horizontal]
|
||||
|
||||
if useLiDAR {
|
||||
if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) {
|
||||
config.sceneReconstruction = .mesh
|
||||
}
|
||||
if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
|
||||
config.frameSemantics.insert(.sceneDepth)
|
||||
}
|
||||
} else {
|
||||
config.sceneReconstruction = []
|
||||
config.frameSemantics = []
|
||||
}
|
||||
|
||||
// People Occlusion funktioniert auch ohne LiDAR (A12+): Personen zwischen Kamera
|
||||
// und Tank verdecken das Objekt korrekt. (Beliebige Objekte wie Stühle brauchen LiDAR.)
|
||||
if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) {
|
||||
config.frameSemantics.insert(.personSegmentationWithDepth)
|
||||
}
|
||||
|
||||
if let initialWorldMap {
|
||||
config.initialWorldMap = initialWorldMap
|
||||
}
|
||||
if withImages {
|
||||
config.detectionImages = referenceImages
|
||||
config.maximumNumberOfTrackedImages = 1
|
||||
}
|
||||
return config
|
||||
}
|
||||
}
|
||||
63
GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift
Normal file
63
GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift
Normal file
@ -0,0 +1,63 @@
|
||||
//
|
||||
// TankDepthFadeMaterial.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import RealityKit
|
||||
import Metal
|
||||
|
||||
/// Kapselt das Höhen-Fade-CustomMaterial für den unterirdischen Tank:
|
||||
/// verblasst die Opacity anhand der Welt-Höhe (Kopf opak, vergrabener Teil transparent).
|
||||
@MainActor
|
||||
enum TankDepthFadeMaterial {
|
||||
/// Fade-Band (Meter) unterhalb der Bodenkante, über das die Transparenz einsetzt.
|
||||
static let band: Float = 0.15
|
||||
/// Minimale Opacity des vergrabenen Teils.
|
||||
static let belowAlpha: Float = 0.3
|
||||
|
||||
private static let shader: CustomMaterial.SurfaceShader? = {
|
||||
guard let library = MTLCreateSystemDefaultDevice()?.makeDefaultLibrary() else { return nil }
|
||||
return CustomMaterial.SurfaceShader(named: "tankDepthFadeSurface", in: library)
|
||||
}()
|
||||
|
||||
/// Ersetzt die Materialien der Entity (rekursiv) durch das Fade-CustomMaterial.
|
||||
static func apply(to entity: Entity) {
|
||||
guard let shader else { return }
|
||||
forEachModelEntity(entity) { modelEntity in
|
||||
guard var component = modelEntity.model else { return }
|
||||
component.materials = component.materials.map { material in
|
||||
guard var custom = try? CustomMaterial(from: material, surfaceShader: shader) else {
|
||||
return material
|
||||
}
|
||||
custom.blending = .transparent(opacity: 1.0)
|
||||
custom.custom.value = SIMD4<Float>(0, band, belowAlpha, 0)
|
||||
return custom
|
||||
}
|
||||
modelEntity.model = component
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktualisiert die Boden-Höhe (Welt-Y) in bereits angewandten Fade-Materialien.
|
||||
static func updateGroundY(_ groundY: Float, on entity: Entity) {
|
||||
forEachModelEntity(entity) { modelEntity in
|
||||
guard var component = modelEntity.model else { return }
|
||||
component.materials = component.materials.map { material in
|
||||
guard var custom = material as? CustomMaterial else { return material }
|
||||
custom.custom.value = SIMD4<Float>(groundY, band, belowAlpha, 0)
|
||||
return custom
|
||||
}
|
||||
modelEntity.model = component
|
||||
}
|
||||
}
|
||||
|
||||
private static func forEachModelEntity(_ entity: Entity, _ body: (ModelEntity) -> Void) {
|
||||
if let modelEntity = entity as? ModelEntity {
|
||||
body(modelEntity)
|
||||
}
|
||||
for child in entity.children {
|
||||
forEachModelEntity(child, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
93
GasTankAR/Views/TankAR/TankEntityFactory.swift
Normal file
93
GasTankAR/Views/TankAR/TankEntityFactory.swift
Normal file
@ -0,0 +1,93 @@
|
||||
//
|
||||
// TankEntityFactory.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
/// Baut die darzustellende Tank-Entity (aus `GasTank.usdz`): lädt/cached das Modell,
|
||||
/// richtet es je nach Aufstellungsart aus, erzeugt die Kollisionsform und – bei
|
||||
/// unterirdischer Aufstellung – den Höhen-Transparenz-Effekt.
|
||||
@MainActor
|
||||
final class TankEntityFactory {
|
||||
/// Name des USDZ-Assets im Bundle.
|
||||
private static let modelName = "GasTank"
|
||||
/// Anteil der Modellhöhe, der bei unterirdischer Aufstellung oben herausragt („Kopf").
|
||||
private static let belowGroundExposedFraction: Float = 0.25
|
||||
/// Zusätzlicher Abstand zwischen Tank-Umkreis und Grubenwand (in Metern) – großzügig,
|
||||
/// damit die Grube nicht gequetscht wirkt.
|
||||
private static let pitMargin: Float = 0.4
|
||||
/// Zusätzliche Tiefe der Grubensohle unter der Tankunterkante (in Metern).
|
||||
private static let pitFloorClearance: Float = 0.1
|
||||
|
||||
/// Vorab geladenes Modell; wird pro Platzierung geklont.
|
||||
private var loadedModel: Entity?
|
||||
|
||||
/// Lädt das USDZ asynchron vor (blockiert den Main-Actor nicht).
|
||||
func preload() {
|
||||
guard loadedModel == nil else { return }
|
||||
Task { @MainActor in
|
||||
self.loadedModel = try? await Entity(named: Self.modelName)
|
||||
}
|
||||
}
|
||||
|
||||
/// Erzeugt den manipulierbaren Container (mit Kollision fürs Gesten-Hit-Testing).
|
||||
func makeContainer(for tank: Tank) -> ModelEntity {
|
||||
let model = loadModelClone()
|
||||
let bounds = model.visualBounds(relativeTo: nil)
|
||||
let container = ModelEntity()
|
||||
|
||||
switch tank.placement {
|
||||
case .aboveGround:
|
||||
model.position.y -= bounds.min.y // Unterkante auf die Fläche
|
||||
|
||||
case .belowGround:
|
||||
// Modell absenken, sodass nur der obere Teil („Kopf") herausragt …
|
||||
let exposed = bounds.extents.y * Self.belowGroundExposedFraction
|
||||
let buried = bounds.extents.y - exposed
|
||||
model.position.y -= (bounds.max.y - exposed)
|
||||
// … und den Boden „aufreißen": eine runde Grube ausheben, in welcher der Tank steht.
|
||||
// Radius = Umkreis der Grundfläche (halbe Diagonale) + großzügiger Rand.
|
||||
let footprintRadius = 0.5 * (bounds.extents.x * bounds.extents.x + bounds.extents.z * bounds.extents.z).squareRoot()
|
||||
container.addChild(TankPitFactory.makePit(
|
||||
radius: footprintRadius + Self.pitMargin,
|
||||
depth: buried + Self.pitFloorClearance,
|
||||
center: bounds.center
|
||||
))
|
||||
}
|
||||
|
||||
container.addChild(model)
|
||||
|
||||
let colliderBounds = model.visualBounds(relativeTo: container)
|
||||
container.collision = CollisionComponent(shapes: [
|
||||
ShapeResource
|
||||
.generateBox(size: colliderBounds.extents)
|
||||
.offsetBy(translation: colliderBounds.center)
|
||||
])
|
||||
|
||||
// Auswahl-Markierung (Eck-Winkel) – zunächst versteckt, wird bei Auswahl eingeblendet.
|
||||
let highlight = TankHighlightFactory.makeHighlight(for: colliderBounds)
|
||||
highlight.isEnabled = false
|
||||
container.addChild(highlight)
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
/// Liefert einen Klon des vorab geladenen Modells. Fällt auf synchrones Laden
|
||||
/// bzw. – wenn das USDZ fehlt – auf einen einfachen Cube zurück.
|
||||
private func loadModelClone() -> Entity {
|
||||
if loadedModel == nil {
|
||||
loadedModel = try? Entity.load(named: Self.modelName)
|
||||
}
|
||||
if let loadedModel {
|
||||
return loadedModel.clone(recursive: true)
|
||||
}
|
||||
return ModelEntity(
|
||||
mesh: .generateBox(size: 0.3),
|
||||
materials: [SimpleMaterial(color: .white, isMetallic: false)]
|
||||
)
|
||||
}
|
||||
}
|
||||
69
GasTankAR/Views/TankAR/TankHighlightFactory.swift
Normal file
69
GasTankAR/Views/TankAR/TankHighlightFactory.swift
Normal file
@ -0,0 +1,69 @@
|
||||
//
|
||||
// TankHighlightFactory.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
/// Baut die Auswahl-Markierung um den Tank: ein (ansonsten unsichtbarer) Quader, dessen
|
||||
/// acht Ecken mit kräftig gefärbten Winkeln hervorgehoben werden – deutlich sichtbar,
|
||||
/// ohne den Tank selbst zu verdecken.
|
||||
@MainActor
|
||||
enum TankHighlightFactory {
|
||||
/// Name der Markierungs-Entity (zum Ein-/Ausblenden bei Auswahl).
|
||||
static let name = "TankSelectionHighlight"
|
||||
|
||||
private static let color = UIColor.systemTeal
|
||||
|
||||
/// Erzeugt die Eck-Winkel passend zur Bounding-Box (im Koordinatensystem des Containers).
|
||||
static func makeHighlight(for bounds: BoundingBox) -> Entity {
|
||||
let root = Entity()
|
||||
root.name = name
|
||||
|
||||
let extents = bounds.extents
|
||||
let smallestEdge = min(extents.x, min(extents.y, extents.z))
|
||||
let armLength = max(0.05, smallestEdge * 0.3)
|
||||
let thickness = max(0.008, smallestEdge * 0.05)
|
||||
|
||||
var material = UnlitMaterial(color: color)
|
||||
material.faceCulling = .none
|
||||
|
||||
for x in [bounds.min.x, bounds.max.x] {
|
||||
for y in [bounds.min.y, bounds.max.y] {
|
||||
for z in [bounds.min.z, bounds.max.z] {
|
||||
let corner = SIMD3(x, y, z)
|
||||
// Vorzeichen: Winkel zeigt von der Ecke nach innen (zur Boxmitte).
|
||||
let signX: Float = (x == bounds.min.x) ? 1 : -1
|
||||
let signY: Float = (y == bounds.min.y) ? 1 : -1
|
||||
let signZ: Float = (z == bounds.min.z) ? 1 : -1
|
||||
|
||||
root.addChild(beam(
|
||||
size: SIMD3(armLength, thickness, thickness),
|
||||
center: corner + SIMD3(signX * armLength / 2, 0, 0),
|
||||
material: material
|
||||
))
|
||||
root.addChild(beam(
|
||||
size: SIMD3(thickness, armLength, thickness),
|
||||
center: corner + SIMD3(0, signY * armLength / 2, 0),
|
||||
material: material
|
||||
))
|
||||
root.addChild(beam(
|
||||
size: SIMD3(thickness, thickness, armLength),
|
||||
center: corner + SIMD3(0, 0, signZ * armLength / 2),
|
||||
material: material
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private static func beam(size: SIMD3<Float>, center: SIMD3<Float>, material: UnlitMaterial) -> ModelEntity {
|
||||
let beam = ModelEntity(mesh: .generateBox(size: size), materials: [material])
|
||||
beam.position = center
|
||||
return beam
|
||||
}
|
||||
}
|
||||
144
GasTankAR/Views/TankAR/TankManipulationPanel.swift
Normal file
144
GasTankAR/Views/TankAR/TankManipulationPanel.swift
Normal file
@ -0,0 +1,144 @@
|
||||
//
|
||||
// TankManipulationPanel.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
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.
|
||||
/// Buttons unterstützen Gedrückt-halten für kontinuierliche Anpassung.
|
||||
struct TankManipulationPanel: View {
|
||||
let controller: ARSessionController
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
HStack {
|
||||
Text("Tank anpassen")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
controller.deselectTank()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Schließen")
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 24) {
|
||||
heightGroup
|
||||
moveGroup
|
||||
rotateGroup
|
||||
}
|
||||
|
||||
Button("Neu platzieren", systemImage: "arrow.up.and.down.and.arrow.left.and.right") {
|
||||
controller.reposition()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
// MARK: - Gruppen
|
||||
|
||||
private var heightGroup: some View {
|
||||
controlGroup(title: "Höhe") {
|
||||
VStack(spacing: 8) {
|
||||
holdButton("chevron.up", label: "Höher") { controller.adjustTankHeight(up: true) }
|
||||
holdButton("chevron.down", label: "Tiefer") { controller.adjustTankHeight(up: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var moveGroup: some View {
|
||||
controlGroup(title: "Verschieben") {
|
||||
VStack(spacing: 8) {
|
||||
holdButton("arrow.up", label: "Vorwärts") { controller.moveTank(forward: true) }
|
||||
HStack(spacing: 8) {
|
||||
holdButton("arrow.left", label: "Nach links") { controller.moveTank(left: true) }
|
||||
holdButton("arrow.right", label: "Nach rechts") { controller.moveTank(left: false) }
|
||||
}
|
||||
holdButton("arrow.down", label: "Rückwärts") { controller.moveTank(forward: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rotateGroup: some View {
|
||||
controlGroup(title: "Drehen") {
|
||||
HStack(spacing: 8) {
|
||||
holdButton("rotate.left", label: "Gegen den Uhrzeigersinn") { controller.rotateTank(clockwise: false) }
|
||||
holdButton("rotate.right", label: "Im Uhrzeigersinn") { controller.rotateTank(clockwise: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func controlGroup<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
content()
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func holdButton(_ systemImage: String, label: String, action: @escaping () -> Void) -> some View {
|
||||
HoldRepeatButton(
|
||||
systemImage: systemImage,
|
||||
accessibilityLabel: label,
|
||||
action: action,
|
||||
onRelease: { controller.commitTankEdit() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runder Button, der bei Gedrückt-halten die Aktion wiederholt (Tastenwiederholung).
|
||||
/// Einzel-Tap = ein Schritt; Halten = fortlaufend. Beim Loslassen wird `onRelease` aufgerufen.
|
||||
private struct HoldRepeatButton: View {
|
||||
let systemImage: String
|
||||
let accessibilityLabel: String
|
||||
let action: () -> Void
|
||||
let onRelease: () -> Void
|
||||
|
||||
@State private var repeatTask: Task<Void, Never>?
|
||||
|
||||
private static let initialDelay: Duration = .milliseconds(350)
|
||||
private static let repeatInterval: Duration = .milliseconds(60)
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(.ultraThinMaterial, in: .circle)
|
||||
.contentShape(.circle)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in if repeatTask == nil { start() } }
|
||||
.onEnded { _ in stop() }
|
||||
)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
|
||||
private func start() {
|
||||
action() // sofortiger erster Schritt (Einzel-Tap)
|
||||
repeatTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: Self.initialDelay)
|
||||
while !Task.isCancelled {
|
||||
action()
|
||||
try? await Task.sleep(for: Self.repeatInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
repeatTask?.cancel()
|
||||
repeatTask = nil
|
||||
onRelease()
|
||||
}
|
||||
}
|
||||
211
GasTankAR/Views/TankAR/TankPitFactory.swift
Normal file
211
GasTankAR/Views/TankAR/TankPitFactory.swift
Normal file
@ -0,0 +1,211 @@
|
||||
//
|
||||
// TankPitFactory.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 28.08.26.
|
||||
//
|
||||
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
/// Baut die „ausgehobene Grube" für einen unterirdischen Tank als **Zylinder**: Der Boden wird
|
||||
/// mit einer kreisrunden Occlusion-Maske „aufgerissen" und darunter ein runder Schacht
|
||||
/// ausgehoben (sichtbarer Wandmantel + Sohle), in dem der Tank steht.
|
||||
///
|
||||
/// Aufbau der zurückgegebenen Entity (lokaler Ursprung = Bodenkante, y = 0):
|
||||
/// - Wandmantel + Sohle reichen von y = 0 bis y = -`depth`.
|
||||
/// - Eine kreisrunde Occlusion-Maske in der Bodenebene verdeckt alles Vergrabene außerhalb
|
||||
/// der Öffnung, sodass der Eindruck eines echten Lochs im Boden entsteht.
|
||||
@MainActor
|
||||
enum TankPitFactory {
|
||||
/// Anzahl der Segmente pro Kreis (höher = runder).
|
||||
private static let segments = 48
|
||||
/// Radius der kreisförmigen Boden-Occlusion-Maske (muss die Öffnung deutlich überragen).
|
||||
private static let maskOuterRadius: Float = 20
|
||||
/// Die Maske liegt minimal unter der Bodenkante, damit sie nicht mit bodennahen
|
||||
/// Overlays (z. B. den Sicherheitsabstands-Ringen bei y = 0) um die Tiefe konkurriert.
|
||||
private static let maskYOffset: Float = -0.005
|
||||
/// Breite der hervorgehobenen Rand-Ringe.
|
||||
private static let rimBand: Float = 0.03
|
||||
|
||||
/// Farbe des Wandmantels (heller, damit sich die Grube klar vom Boden abhebt).
|
||||
private static let wallColor = UIColor(red: 0.62, green: 0.47, blue: 0.31, alpha: 1)
|
||||
/// Farbe der Grubensohle (dunkler als die Wände – erzeugt Tiefeneindruck).
|
||||
private static let floorColor = UIColor(red: 0.24, green: 0.18, blue: 0.12, alpha: 1)
|
||||
/// Farbe der hervorgehobenen Rand-Ringe.
|
||||
private static let rimColor = UIColor.systemYellow
|
||||
|
||||
/// Erzeugt die zylindrische Grube.
|
||||
/// - Parameters:
|
||||
/// - radius: Radius der Grube (x/z) in Metern.
|
||||
/// - depth: Tiefe der Grube (y) in Metern.
|
||||
/// - center: horizontaler Mittelpunkt relativ zum Container (y wird ignoriert).
|
||||
static func makePit(radius: Float, depth: Float, center: SIMD3<Float>) -> Entity {
|
||||
let pit = Entity()
|
||||
pit.name = "TankPit"
|
||||
pit.position = SIMD3(center.x, 0, center.z)
|
||||
|
||||
// Sichtbarer Wandmantel mit Erd-/Dreck-Textur …
|
||||
pit.addChild(wallModel(radius: radius, depth: depth))
|
||||
// … runde Sohle (dunkler) …
|
||||
pit.addChild(model(mesh: floorMesh(radius: radius, depth: depth), color: floorColor))
|
||||
// … hervorgehobene Rand-Ringe oben und unten …
|
||||
pit.addChild(model(mesh: ringMesh(radius: radius, y: 0), color: rimColor))
|
||||
pit.addChild(model(mesh: ringMesh(radius: radius, y: -depth), color: rimColor))
|
||||
|
||||
// … und die kreisrunde Occlusion-Maske mit Loch über der Öffnung.
|
||||
let mask = ModelEntity(
|
||||
mesh: maskMesh(holeRadius: radius, outerRadius: maskOuterRadius),
|
||||
materials: [OcclusionMaterial()]
|
||||
)
|
||||
mask.position.y = maskYOffset
|
||||
pit.addChild(mask)
|
||||
|
||||
return pit
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
private static func model(mesh: MeshResource, color: UIColor) -> ModelEntity {
|
||||
var material = UnlitMaterial(color: color)
|
||||
material.faceCulling = .none // beidseitig sichtbar (Blick von außen und in die Grube)
|
||||
return ModelEntity(mesh: mesh, materials: [material])
|
||||
}
|
||||
|
||||
/// Wandmantel mit gekachelter Erd-Textur (Fallback: einfarbig).
|
||||
private static func wallModel(radius: Float, depth: Float) -> ModelEntity {
|
||||
var material = UnlitMaterial(color: wallColor)
|
||||
if let texture = DirtTextureFactory.texture {
|
||||
material.color = .init(tint: .white, texture: .init(texture))
|
||||
}
|
||||
material.faceCulling = .none
|
||||
return ModelEntity(mesh: wallMesh(radius: radius, depth: depth), materials: [material])
|
||||
}
|
||||
|
||||
/// Kreispunkte in der XZ-Ebene auf gegebener Höhe.
|
||||
private static func circle(radius: Float, y: Float) -> [SIMD3<Float>] {
|
||||
(0..<segments).map { index in
|
||||
let angle = Float(index) / Float(segments) * 2 * .pi
|
||||
return SIMD3(radius * cos(angle), y, radius * sin(angle))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wandmantel (senkrecht, y = 0 … -depth) inkl. UV-Koordinaten für die gekachelte Textur.
|
||||
private static func wallMesh(radius: Float, depth: Float) -> MeshResource {
|
||||
let top = circle(radius: radius, y: 0)
|
||||
let bottom = circle(radius: radius, y: -depth)
|
||||
|
||||
// Kachelgröße ~0,5 m: horizontal über den Umfang, vertikal über die Tiefe.
|
||||
let tileSize: Float = 0.5
|
||||
let uRepeat = (2 * .pi * radius) / tileSize
|
||||
let vMax = depth / tileSize
|
||||
|
||||
var builder = TriangleMeshBuilder()
|
||||
for i in 0..<segments {
|
||||
let next = (i + 1) % segments
|
||||
let u0 = Float(i) / Float(segments) * uRepeat
|
||||
let u1 = Float(i + 1) / Float(segments) * uRepeat
|
||||
builder.addQuad(
|
||||
top[i], top[next], bottom[next], bottom[i],
|
||||
uv: (SIMD2(u0, 0), SIMD2(u1, 0), SIMD2(u1, vMax), SIMD2(u0, vMax)),
|
||||
doubleSided: true
|
||||
)
|
||||
}
|
||||
return builder.makeMesh(name: "tankPitWall")
|
||||
}
|
||||
|
||||
/// Runde Sohle bei y = -depth (Dreiecksfächer um den Mittelpunkt).
|
||||
private static func floorMesh(radius: Float, depth: Float) -> MeshResource {
|
||||
let rim = circle(radius: radius, y: -depth)
|
||||
let center = SIMD3<Float>(0, -depth, 0)
|
||||
|
||||
var builder = TriangleMeshBuilder()
|
||||
for i in 0..<segments {
|
||||
let next = (i + 1) % segments
|
||||
builder.addTriangle(center, rim[i], rim[next], doubleSided: true)
|
||||
}
|
||||
return builder.makeMesh(name: "tankPitFloor")
|
||||
}
|
||||
|
||||
/// Flacher, hervorgehobener Ring (Rand) auf gegebener Höhe.
|
||||
private static func ringMesh(radius: Float, y: Float) -> MeshResource {
|
||||
let inner = circle(radius: max(0, radius - rimBand), y: y)
|
||||
let outer = circle(radius: radius + rimBand, y: y)
|
||||
|
||||
var builder = TriangleMeshBuilder()
|
||||
for i in 0..<segments {
|
||||
let next = (i + 1) % segments
|
||||
builder.addQuad(inner[i], inner[next], outer[next], outer[i], doubleSided: true)
|
||||
}
|
||||
return builder.makeMesh(name: "tankPitRim")
|
||||
}
|
||||
|
||||
/// Waagerechte Fläche in der Bodenebene mit kreisrundem Loch (Ring zwischen Loch und Außenrand).
|
||||
private static func maskMesh(holeRadius: Float, outerRadius: Float) -> MeshResource {
|
||||
let inner = circle(radius: holeRadius, y: 0)
|
||||
let outer = circle(radius: outerRadius, y: 0)
|
||||
|
||||
var builder = TriangleMeshBuilder()
|
||||
for i in 0..<segments {
|
||||
let next = (i + 1) % segments
|
||||
builder.addQuad(inner[i], inner[next], outer[next], outer[i], doubleSided: true)
|
||||
}
|
||||
return builder.makeMesh(name: "tankPitMask")
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleiner Helfer, der Dreiecke/Vierecke zu einem Mesh sammelt.
|
||||
private struct TriangleMeshBuilder {
|
||||
private var positions: [SIMD3<Float>] = []
|
||||
private var indices: [UInt32] = []
|
||||
private var uvs: [SIMD2<Float>] = []
|
||||
|
||||
/// Viereck mit UV-Koordinaten (für texturierte Flächen).
|
||||
mutating func addQuad(
|
||||
_ a: SIMD3<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>, _ d: SIMD3<Float>,
|
||||
uv: (SIMD2<Float>, SIMD2<Float>, SIMD2<Float>, SIMD2<Float>),
|
||||
doubleSided: Bool = false
|
||||
) {
|
||||
let base = UInt32(positions.count)
|
||||
positions.append(contentsOf: [a, b, c, d])
|
||||
uvs.append(contentsOf: [uv.0, uv.1, uv.2, uv.3])
|
||||
indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
|
||||
if doubleSided {
|
||||
indices.append(contentsOf: [base, base + 2, base + 1, base, base + 3, base + 2])
|
||||
}
|
||||
}
|
||||
|
||||
mutating func addTriangle(
|
||||
_ a: SIMD3<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>,
|
||||
doubleSided: Bool = false
|
||||
) {
|
||||
let base = UInt32(positions.count)
|
||||
positions.append(contentsOf: [a, b, c])
|
||||
indices.append(contentsOf: [base, base + 1, base + 2])
|
||||
if doubleSided {
|
||||
indices.append(contentsOf: [base, base + 2, base + 1])
|
||||
}
|
||||
}
|
||||
|
||||
mutating func addQuad(
|
||||
_ a: SIMD3<Float>, _ b: SIMD3<Float>, _ c: SIMD3<Float>, _ d: SIMD3<Float>,
|
||||
doubleSided: Bool = false
|
||||
) {
|
||||
let base = UInt32(positions.count)
|
||||
positions.append(contentsOf: [a, b, c, d])
|
||||
indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
|
||||
if doubleSided {
|
||||
indices.append(contentsOf: [base, base + 2, base + 1, base, base + 3, base + 2])
|
||||
}
|
||||
}
|
||||
|
||||
func makeMesh(name: String) -> MeshResource {
|
||||
var descriptor = MeshDescriptor(name: name)
|
||||
descriptor.positions = MeshBuffers.Positions(positions)
|
||||
descriptor.primitives = .triangles(indices)
|
||||
if uvs.count == positions.count {
|
||||
descriptor.textureCoordinates = MeshBuffers.TextureCoordinates(uvs)
|
||||
}
|
||||
return (try? MeshResource.generate(from: [descriptor])) ?? .generateBox(size: 0.01)
|
||||
}
|
||||
}
|
||||
56
GasTankAR/Views/TankAR/TankShaders.metal
Normal file
56
GasTankAR/Views/TankAR/TankShaders.metal
Normal file
@ -0,0 +1,56 @@
|
||||
//
|
||||
// TankShaders.metal
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
#include <metal_stdlib>
|
||||
#include <RealityKit/RealityKit.h>
|
||||
|
||||
using namespace metal;
|
||||
|
||||
// Sampler für die durchgereichten Texturen des Originalmaterials.
|
||||
constexpr sampler textureSampler(address::clamp_to_edge, filter::bicubic);
|
||||
|
||||
/// Surface-Shader für den unterirdischen Tank: reicht Basisfarbe/Roughness/Metallic
|
||||
/// des Originalmaterials durch und verblasst die Opacity anhand der Welt-Höhe.
|
||||
///
|
||||
/// custom_parameter():
|
||||
/// x = Boden-Höhe (Welt-Y)
|
||||
/// y = Fade-Band (Meter, über die die Transparenz einsetzt)
|
||||
/// z = minimale Opacity des vergrabenen Teils
|
||||
[[visible]]
|
||||
void tankDepthFadeSurface(realitykit::surface_parameters params)
|
||||
{
|
||||
auto tex = params.textures();
|
||||
auto constants = params.material_constants();
|
||||
|
||||
float2 uv = params.geometry().uv0();
|
||||
// Modelle aus USDZ nutzen eine gespiegelte Y-UV.
|
||||
uv.y = 1.0 - uv.y;
|
||||
|
||||
// Basisfarbe = Tint * Textur.
|
||||
half3 tint = (half3)constants.base_color_tint();
|
||||
half3 color = (half3)tex.base_color().sample(textureSampler, uv).rgb;
|
||||
color *= tint;
|
||||
params.surface().set_base_color(color);
|
||||
|
||||
// Roughness / Metallic durchreichen (wie PhysicallyBasedMaterial).
|
||||
half roughness = tex.roughness().sample(textureSampler, uv).r * constants.roughness_scale();
|
||||
params.surface().set_roughness(roughness);
|
||||
|
||||
half metallic = tex.metallic().sample(textureSampler, uv).r * constants.metallic_scale();
|
||||
params.surface().set_metallic(metallic);
|
||||
|
||||
// Höhenbasierter Alpha-Fade.
|
||||
float3 worldPosition = params.geometry().world_position();
|
||||
float4 custom = params.uniforms().custom_parameter();
|
||||
float groundY = custom.x;
|
||||
float band = max(custom.y, 1e-3);
|
||||
float belowAlpha = custom.z;
|
||||
|
||||
float t = clamp((groundY - worldPosition.y) / band, 0.0, 1.0);
|
||||
float alpha = mix(1.0, belowAlpha, t);
|
||||
params.surface().set_opacity(half(alpha));
|
||||
}
|
||||
20
GasTankAR/Views/TankAR/WorldMapArchiver.swift
Normal file
20
GasTankAR/Views/TankAR/WorldMapArchiver.swift
Normal file
@ -0,0 +1,20 @@
|
||||
//
|
||||
// WorldMapArchiver.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 26.08.26.
|
||||
//
|
||||
|
||||
import ARKit
|
||||
|
||||
/// Kapselt das (De-)Serialisieren einer `ARWorldMap` via `NSKeyedArchiver`.
|
||||
/// Bewusst thread-neutral gehalten – das Archivieren sollte off-main erfolgen (teuer).
|
||||
enum WorldMapArchiver {
|
||||
static func archive(_ worldMap: ARWorldMap) -> Data? {
|
||||
try? NSKeyedArchiver.archivedData(withRootObject: worldMap, requiringSecureCoding: true)
|
||||
}
|
||||
|
||||
static func unarchive(_ data: Data) -> ARWorldMap? {
|
||||
try? NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data)
|
||||
}
|
||||
}
|
||||
102
GasTankAR/Views/TankEditor.swift
Normal file
102
GasTankAR/Views/TankEditor.swift
Normal file
@ -0,0 +1,102 @@
|
||||
//
|
||||
// TankEditor.swift
|
||||
// GasTankAR
|
||||
//
|
||||
// Created by Dennis Nemec on 25.08.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Wiederverwendbare Auswahl für die Tank-Eigenschaften (Größe, Farbe,
|
||||
/// Aufstellungsart und halbtransparente Darstellung).
|
||||
struct TankEditor: View {
|
||||
@Binding var tank: Tank
|
||||
|
||||
var body: some View {
|
||||
Picker("Größe", selection: $tank.size) {
|
||||
ForEach(TankSize.allCases) { size in
|
||||
Text(size.displayName).tag(size)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Farbe", selection: $tank.color) {
|
||||
ForEach(TankColor.allCases) { color in
|
||||
Text(color.displayName).tag(color)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Aufstellungsart")
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(TankPlacement.allCases) { placement in
|
||||
PlacementOption(
|
||||
placement: placement,
|
||||
isSelected: tank.placement == placement
|
||||
) {
|
||||
tank.placement = placement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Toggle("Halbtransparent (nur Deckel)", isOn: $tank.showLidOnly)
|
||||
.disabled(tank.placement != .belowGround)
|
||||
.onChange(of: tank.placement) { _, newPlacement in
|
||||
// Option nur für unterirdische Tanks sinnvoll – sonst zurücksetzen.
|
||||
if newPlacement != .belowGround {
|
||||
tank.showLidOnly = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auswahlkachel für eine Aufstellungsart mit Icon und umrahmter Auswahl.
|
||||
private struct PlacementOption: View {
|
||||
let placement: TankPlacement
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
Image(placement.imageName)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(height: 72)
|
||||
.scaleEffect(1.25)
|
||||
.padding(8)
|
||||
|
||||
Text(placement.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isSelected ? .primary : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(8)
|
||||
.background(
|
||||
(isSelected ? Color.accentColor.opacity(0.12) : Color.clear),
|
||||
in: .rect(cornerRadius: 12)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(
|
||||
isSelected ? Color.accentColor : Color.secondary.opacity(0.4),
|
||||
lineWidth: isSelected ? 2 : 1
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@Previewable @State var tank = Tank()
|
||||
return Form {
|
||||
Section("Tankauswahl") {
|
||||
TankEditor(tank: $tank)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user