diff --git a/GasTankAR.xcodeproj/project.pbxproj b/GasTankAR.xcodeproj/project.pbxproj index 89f1a12..d1ecd29 100644 --- a/GasTankAR.xcodeproj/project.pbxproj +++ b/GasTankAR.xcodeproj/project.pbxproj @@ -184,7 +184,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2660; - LastUpgradeCheck = 2660; + LastUpgradeCheck = 2700; TargetAttributes = { 95BEEDCC3035F1C100D17653 = { CreatedOnToolsVersion = 26.6; @@ -288,6 +288,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 37Z4ZA4QU2; @@ -296,7 +297,7 @@ INFOPLIST_FILE = GasTankAR/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; INFOPLIST_KEY_NSCameraUsageDescription = "Die Kamera wird benötigt, um Gastanks im AR-Modus auf dem Grundstück zu visualisieren."; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Der Standort wird verwendet, um aufgenommene Fotos mit ihrem Aufnahmeort zu verknüpfen und auf der Karte anzuzeigen."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Der Standort wird verwendet, um den Aufnahmeort von AR-Fotos in der Galerie anzuzeigen."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit; @@ -311,6 +312,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankAR; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -326,6 +328,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 37Z4ZA4QU2; @@ -334,7 +337,7 @@ INFOPLIST_FILE = GasTankAR/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; INFOPLIST_KEY_NSCameraUsageDescription = "Die Kamera wird benötigt, um Gastanks im AR-Modus auf dem Grundstück zu visualisieren."; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Der Standort wird verwendet, um aufgenommene Fotos mit ihrem Aufnahmeort zu verknüpfen und auf der Karte anzuzeigen."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Der Standort wird verwendet, um den Aufnahmeort von AR-Fotos in der Galerie anzuzeigen."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit; @@ -349,6 +352,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankAR; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -364,6 +368,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -418,6 +423,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -428,6 +434,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -475,6 +482,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; diff --git a/GasTankAR.xcodeproj/xcuserdata/dennis.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/GasTankAR.xcodeproj/xcuserdata/dennis.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 0c0ffb2..09c9372 100644 --- a/GasTankAR.xcodeproj/xcuserdata/dennis.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/GasTankAR.xcodeproj/xcuserdata/dennis.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -14,8 +14,8 @@ filePath = "GasTankAR/ContentView.swift" startingColumnNumber = "9223372036854775807" endingColumnNumber = "9223372036854775807" - startingLineNumber = "20" - endingLineNumber = "20" + startingLineNumber = "21" + endingLineNumber = "21" landmarkName = "unknown" landmarkType = "0"> diff --git a/GasTankAR/GasTankAR/TankModels/DG-2700_mi.usdz b/GasTankAR/GasTankAR/TankModels/DG-2700_mi.usdz new file mode 100644 index 0000000..bc86c5a Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-2700_mi.usdz differ diff --git a/GasTankAR/GasTankAR/TankModels/DG-2700_ol.usdz b/GasTankAR/GasTankAR/TankModels/DG-2700_ol.usdz new file mode 100644 index 0000000..7ca1633 Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-2700_ol.usdz differ diff --git a/GasTankAR/GasTankAR/TankModels/DG-2700_wh.usdz b/GasTankAR/GasTankAR/TankModels/DG-2700_wh.usdz new file mode 100644 index 0000000..779abc9 Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-2700_wh.usdz differ diff --git a/GasTankAR/GasTankAR/TankModels/DG-4850_mi.usdz b/GasTankAR/GasTankAR/TankModels/DG-4850_mi.usdz new file mode 100644 index 0000000..bb98ba4 Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-4850_mi.usdz differ diff --git a/GasTankAR/GasTankAR/TankModels/DG-4850_ol.usdz b/GasTankAR/GasTankAR/TankModels/DG-4850_ol.usdz new file mode 100644 index 0000000..02233af Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-4850_ol.usdz differ diff --git a/GasTankAR/GasTankAR/TankModels/DG-4850_wh.usdz b/GasTankAR/GasTankAR/TankModels/DG-4850_wh.usdz new file mode 100644 index 0000000..ee062c0 Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-4850_wh.usdz differ diff --git a/GasTankAR/GasTankAR/TankModels/DG-6400_mi.usdz b/GasTankAR/GasTankAR/TankModels/DG-6400_mi.usdz new file mode 100644 index 0000000..df62a84 Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-6400_mi.usdz differ diff --git a/GasTankAR/GasTankAR/TankModels/DG-6400_ol.usdz b/GasTankAR/GasTankAR/TankModels/DG-6400_ol.usdz new file mode 100644 index 0000000..4f9e765 Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-6400_ol.usdz differ diff --git a/GasTankAR/GasTankAR/TankModels/DG-6400_wh.usdz b/GasTankAR/GasTankAR/TankModels/DG-6400_wh.usdz new file mode 100644 index 0000000..b62fb82 Binary files /dev/null and b/GasTankAR/GasTankAR/TankModels/DG-6400_wh.usdz differ diff --git a/GasTankAR/Model/Project.swift b/GasTankAR/Model/Project.swift index f51d348..201a717 100644 --- a/GasTankAR/Model/Project.swift +++ b/GasTankAR/Model/Project.swift @@ -31,6 +31,12 @@ final class Project { /// 16 Floats (spaltenweise). Zusammen mit der WorldMap wird der Tank so wiederhergestellt. var tankTransform: [Float]? + /// Persistierte, frei eingetragene Strecken als Codable-Daten. + @Attribute(.externalStorage) var measurementsData: Data? + + /// Persistierte Rohr-Wegpunkte und ihre Verbindungen als Codable-Daten. + @Attribute(.externalStorage) var pipeRouteData: Data? + /// Im AR-Modus aufgenommene Fotos. Werden beim Löschen des Projekts mitentfernt. @Relationship(deleteRule: .cascade, inverse: \ProjectPhoto.project) var photos: [ProjectPhoto] = [] @@ -41,7 +47,9 @@ final class Project { creationDate: Date, tank: Tank = Tank(), worldMapData: Data? = nil, - tankTransform: [Float]? = nil + tankTransform: [Float]? = nil, + measurementsData: Data? = nil, + pipeRouteData: Data? = nil ) { self.leadID = leadID self.customerName = customerName @@ -49,6 +57,8 @@ final class Project { self.tank = tank self.worldMapData = worldMapData self.tankTransform = tankTransform + self.measurementsData = measurementsData + self.pipeRouteData = pipeRouteData } /// Human-readable title combining the lead identifier and customer name. diff --git a/GasTankAR/Model/ProjectPhoto.swift b/GasTankAR/Model/ProjectPhoto.swift index 669afaa..fac3a3d 100644 --- a/GasTankAR/Model/ProjectPhoto.swift +++ b/GasTankAR/Model/ProjectPhoto.swift @@ -16,9 +16,15 @@ final class ProjectPhoto { /// Aufnahmezeitpunkt – Grundlage für die Gruppierung in der Galerie. var creationDate: Date - /// JPEG-kodierte Bilddaten. + /// Aktuelle Bilddaten. Nach einer Bearbeitung enthält diese Property die gerenderte PNG-Datei. @Attribute(.externalStorage) var imageData: Data + /// Unveränderte Originaldaten für die Funktion „Zeichnung zurücksetzen“. + @Attribute(.externalStorage) var originalImageData: Data? + + /// Gespeicherte Version des Fotos mit Zeichnung. + @Attribute(.externalStorage) var annotatedImageData: Data? + /// Geografische Breite des Aufnahmeorts, sofern beim Aufnehmen ein Standort vorlag. var latitude: Double? /// Geografische Länge des Aufnahmeorts, sofern beim Aufnehmen ein Standort vorlag. @@ -30,12 +36,16 @@ final class ProjectPhoto { init( creationDate: Date, imageData: Data, + originalImageData: Data? = nil, + annotatedImageData: Data? = nil, latitude: Double? = nil, longitude: Double? = nil, project: Project? = nil ) { self.creationDate = creationDate self.imageData = imageData + self.originalImageData = originalImageData + self.annotatedImageData = annotatedImageData self.latitude = latitude self.longitude = longitude self.project = project diff --git a/GasTankAR/Model/Tank.swift b/GasTankAR/Model/Tank.swift index 98d1819..714fad7 100644 --- a/GasTankAR/Model/Tank.swift +++ b/GasTankAR/Model/Tank.swift @@ -7,40 +7,30 @@ import Foundation -/// Verfügbare Tankgrößen laut Projektplan Phase 4 (Modell + korrekter Maßstab). -enum TankSize: String, CaseIterable, Identifiable, Codable, Hashable { +/// Verfügbare Tankgrößen. Die Enum-Fälle sind reine, stabile Identitäten +/// (werden so persistiert). Anzeigenamen und 3D-Modelle liefert der +/// `TankCatalog` (aus `Tanks.json`) – siehe `TankDefinition`. +nonisolated enum TankSize: String, CaseIterable, Identifiable, Codable, Hashable { case t1_2 case t2_1 case t2_9 var id: String { rawValue } - - var displayName: String { - switch self { - case .t1_2: "1,2 t" - case .t2_1: "2,1 t" - case .t2_9: "2,9 t" - } - } } -/// Verfügbare Tankfarben (Farbvariante mit Logo). -enum TankColor: String, CaseIterable, Identifiable, Codable, Hashable { +/// Verfügbare Tankfarben. Welche Farben tatsächlich auswählbar sind, ergibt +/// sich dynamisch aus dem `TankCatalog` (abhängig von Größe & Aufstellungsart). +nonisolated enum TankColor: String, CaseIterable, Identifiable, Codable, Hashable { case white case green + case olive var id: String { rawValue } - - var displayName: String { - switch self { - case .white: "Weiß" - case .green: "Grün" - } - } } -/// Aufstellungsart des Tanks. -enum TankPlacement: String, CaseIterable, Identifiable, Codable, Hashable { +/// Aufstellungsart des Tanks. Steuert neben der Modellauswahl auch das +/// AR-Verhalten (Grube, Occlusion, „nur Deckel"). +nonisolated enum TankPlacement: String, CaseIterable, Identifiable, Codable, Hashable { case aboveGround case belowGround @@ -62,19 +52,14 @@ enum TankPlacement: String, CaseIterable, Identifiable, Codable, Hashable { } } -/// Ein für ein Projekt ausgewählter Tank. Kombiniert die in Phase 4 des -/// Projektplans vorgesehenen Auswahlmöglichkeiten: Größe, Farbe und -/// Aufstellungsart sowie die optionale halbtransparente Darstellung. -struct Tank: Hashable, Codable { +/// Die für ein Projekt getroffene Tank-Auswahl. `Tank` ist die persistierte +/// Identität (Größe, Farbe, Aufstellungsart); die zugehörigen Anzeigenamen und +/// das 3D-Modell werden über den `TankCatalog` aufgelöst. +nonisolated struct Tank: Hashable, Codable { var size: TankSize = .t1_2 var color: TankColor = .white var placement: TankPlacement = .aboveGround /// Optionale Darstellung, bei der nur der Deckel sichtbar ist. var showLidOnly: Bool = false - - /// Kurzbeschreibung für Listen und Detailanzeigen. - var displayName: String { - "\(size.displayName) · \(color.displayName) · \(placement.displayName)" - } } diff --git a/GasTankAR/Model/TankDefinition.swift b/GasTankAR/Model/TankDefinition.swift new file mode 100644 index 0000000..4e21ae6 --- /dev/null +++ b/GasTankAR/Model/TankDefinition.swift @@ -0,0 +1,38 @@ +// +// TankDefinition.swift +// GasTankAR +// +// Created by Dennis Nemec on 14.09.26. +// + +import Foundation + +/// Eine im Katalog (`Tanks.json`) hinterlegte Tank-Variante. Verbindet die +/// Identität eines Tanks (Größe, Farbe, Aufstellungsart) mit dem Pfad zum +/// 3D-Modell. Die Anzeigenamen werden separat gepflegt (siehe `TankCatalog`). +nonisolated struct TankDefinition: Codable, Hashable, Identifiable { + let size: TankSize + let color: TankColor + let placement: TankPlacement + /// Pfad bzw. Dateiname des 3D-Modells im App-Bundle (z. B. `DG-2700_wh.usdz`). + let modelPath: String + + /// Stabile Identität aus der Kombination der Auswahlmerkmale. + var id: String { "\(size.rawValue)_\(color.rawValue)_\(placement.rawValue)" } + + /// Ressourcenname ohne Dateiendung, wie ihn RealityKit/SceneKit zum Laden + /// aus dem Bundle erwarten (z. B. `DG-2700_wh`). + var modelResourceName: String { + (modelPath as NSString).deletingPathExtension + } + + /// Dateiendung des Modells (z. B. `usdz`). + var modelFileExtension: String { + (modelPath as NSString).pathExtension + } + + /// Entspricht diese Definition der getroffenen Tank-Auswahl? + func matches(_ tank: Tank) -> Bool { + size == tank.size && color == tank.color && placement == tank.placement + } +} diff --git a/GasTankAR/Services/TankCatalog.swift b/GasTankAR/Services/TankCatalog.swift new file mode 100644 index 0000000..7e2be36 --- /dev/null +++ b/GasTankAR/Services/TankCatalog.swift @@ -0,0 +1,137 @@ +// +// TankCatalog.swift +// GasTankAR +// +// Created by Dennis Nemec on 14.09.26. +// + +import Foundation + +/// Zugriff auf den Katalog verfügbarer Tanks. Die abgeleiteten Abfragen (welche +/// Größen/Farben je Aufstellungsart verfügbar sind, Auflösung einer Auswahl auf +/// eine konkrete Variante) leiten sich allein aus `definitions` ab und sind +/// daher in einer Protokoll-Extension implementiert. +nonisolated protocol TankCatalogProviding { + var definitions: [TankDefinition] { get } + /// Anzeigenamen je Größe (`TankSize.rawValue` → Anzeigename). + var sizeDisplayNames: [String: String] { get } + /// Anzeigenamen je Farbe (`TankColor.rawValue` → Anzeigename). + var colorDisplayNames: [String: String] { get } +} + +nonisolated extension TankCatalogProviding { + + /// Verfügbare Aufstellungsarten (in Reihenfolge des ersten Auftretens). + var placements: [TankPlacement] { + orderedUnique(definitions.map(\.placement)) + } + + /// Für eine Aufstellungsart verfügbare Größen. + func sizes(for placement: TankPlacement) -> [TankSize] { + orderedUnique(definitions.filter { $0.placement == placement }.map(\.size)) + } + + /// Für eine Größe & Aufstellungsart verfügbare Farben. + func colors(for size: TankSize, placement: TankPlacement) -> [TankColor] { + orderedUnique( + definitions + .filter { $0.size == size && $0.placement == placement } + .map(\.color) + ) + } + + /// Exakte Definition zur getroffenen Auswahl (oder `nil`, falls die + /// Kombination nicht existiert). + func definition(for tank: Tank) -> TankDefinition? { + definitions.first { $0.matches(tank) } + } + + /// Definition zur Auswahl – korrigiert inkonsistente Kombinationen (z. B. aus + /// Altdaten) zuvor auf eine gültige Variante. Gibt `nil` zurück, wenn der + /// Katalog leer ist. + func resolvedDefinition(for tank: Tank) -> TankDefinition? { + definition(for: tank) ?? definition(for: normalized(tank)) + } + + /// Korrigiert eine Auswahl so, dass sie einer existierenden Definition + /// entspricht: passt zuerst die Farbe an, danach – falls nötig – die Größe. + /// Existiert bereits eine passende Definition, bleibt die Auswahl unverändert. + func normalized(_ tank: Tank) -> Tank { + guard definition(for: tank) == nil else { return tank } + + var result = tank + + // Farbe an Größe & Aufstellungsart anpassen. + let colors = colors(for: result.size, placement: result.placement) + if let color = colors.first, !colors.contains(result.color) { + result.color = color + } + if definition(for: result) != nil { return result } + + // Größe (und daraus folgend Farbe) an die Aufstellungsart anpassen. + if let size = sizes(for: result.placement).first { + result.size = size + if let color = self.colors(for: result.size, placement: result.placement).first { + result.color = color + } + } + return result + } + + /// Anzeigename einer Größe (aus dem Mapping; Fallback: `rawValue`). + func displayName(for size: TankSize) -> String { + sizeDisplayNames[size.rawValue] ?? size.rawValue + } + + /// Anzeigename einer Farbe (aus dem Mapping; Fallback: `rawValue`). + func displayName(for color: TankColor) -> String { + colorDisplayNames[color.rawValue] ?? color.rawValue + } + + /// Reihenfolge-erhaltende Deduplizierung. + private func orderedUnique(_ values: [T]) -> [T] { + var seen = Set() + return values.filter { seen.insert($0).inserted } + } +} + +/// Konkreter, unveränderlicher Katalog verfügbarer Tanks. +nonisolated struct TankCatalog: TankCatalogProviding { + let definitions: [TankDefinition] + let sizeDisplayNames: [String: String] + let colorDisplayNames: [String: String] + + init( + definitions: [TankDefinition], + sizeDisplayNames: [String: String] = [:], + colorDisplayNames: [String: String] = [:] + ) { + self.definitions = definitions + self.sizeDisplayNames = sizeDisplayNames + self.colorDisplayNames = colorDisplayNames + } + + /// Erstellt einen Katalog aus geladenen Rohdaten. + init(file: TankCatalogFile) { + self.init( + definitions: file.tanks, + sizeDisplayNames: file.sizeDisplayNames, + colorDisplayNames: file.colorDisplayNames + ) + } + + /// Erstellt einen Katalog aus einem Loader. Schlägt das Laden fehl, entsteht + /// ein leerer Katalog (die App bleibt bedienbar, zeigt aber keine Tanks an). + init(loader: TankCatalogLoading) { + if let file = try? loader.load() { + self.init(file: file) + } else { + self.init(definitions: []) + } + } +} + +nonisolated extension TankCatalog { + /// Gemeinsamer, aus dem App-Bundle geladener Katalog für den Produktivbetrieb. + static let shared = TankCatalog(loader: BundleTankCatalogLoader()) +} diff --git a/GasTankAR/Services/TankCatalogLoader.swift b/GasTankAR/Services/TankCatalogLoader.swift new file mode 100644 index 0000000..0e669dd --- /dev/null +++ b/GasTankAR/Services/TankCatalogLoader.swift @@ -0,0 +1,58 @@ +// +// TankCatalogLoader.swift +// GasTankAR +// +// Created by Dennis Nemec on 14.09.26. +// + +import Foundation + +/// Rohdaten des Tank-Katalogs, wie sie in `Tanks.json` abgelegt sind: die +/// verfügbaren Varianten sowie die separat gepflegten Anzeigenamen-Mappings +/// (jeweils `rawValue` → Anzeigename). +nonisolated struct TankCatalogFile: Decodable { + let sizeDisplayNames: [String: String] + let colorDisplayNames: [String: String] + let tanks: [TankDefinition] +} + +/// Lädt den Tank-Katalog aus einer JSON-Quelle. Über das Protokoll lässt sich +/// die Quelle für Tests austauschen (z. B. In-Memory-Daten). +nonisolated protocol TankCatalogLoading { + func load() throws -> TankCatalogFile +} + +nonisolated enum TankCatalogLoaderError: Error, Equatable { + /// Die JSON-Ressource wurde im Bundle nicht gefunden. + case resourceNotFound(name: String, extension: String) +} + +/// Lädt den Katalog aus einer JSON-Datei im App-Bundle. +nonisolated struct BundleTankCatalogLoader: TankCatalogLoading { + let bundle: Bundle + let resourceName: String + let resourceExtension: String + + init(bundle: Bundle = .main, resourceName: String = "Tanks", resourceExtension: String = "json") { + self.bundle = bundle + self.resourceName = resourceName + self.resourceExtension = resourceExtension + } + + func load() throws -> TankCatalogFile { + guard let url = bundle.url(forResource: resourceName, withExtension: resourceExtension) else { + throw TankCatalogLoaderError.resourceNotFound(name: resourceName, extension: resourceExtension) + } + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(TankCatalogFile.self, from: data) + } +} + +/// Lädt den Katalog aus bereits vorliegenden JSON-Daten (nützlich für Tests). +nonisolated struct DataTankCatalogLoader: TankCatalogLoading { + let data: Data + + func load() throws -> TankCatalogFile { + try JSONDecoder().decode(TankCatalogFile.self, from: data) + } +} diff --git a/GasTankAR/TankModels/Tanks.json b/GasTankAR/TankModels/Tanks.json new file mode 100644 index 0000000..d179d92 --- /dev/null +++ b/GasTankAR/TankModels/Tanks.json @@ -0,0 +1,23 @@ +{ + "sizeDisplayNames": { + "t1_2": "1,2 t", + "t2_1": "2,1 t", + "t2_9": "2,9 t" + }, + "colorDisplayNames": { + "white": "Weiß", + "green": "Hellgrün", + "olive": "Olivgrün" + }, + "tanks": [ + { "size": "t1_2", "color": "white", "placement": "aboveGround", "modelPath": "DG-2700_wh.usdz" }, + { "size": "t1_2", "color": "green", "placement": "aboveGround", "modelPath": "DG-2700_mi.usdz" }, + { "size": "t1_2", "color": "olive", "placement": "belowGround", "modelPath": "DG-2700_ol.usdz" }, + { "size": "t2_1", "color": "white", "placement": "aboveGround", "modelPath": "DG-4850_wh.usdz" }, + { "size": "t2_1", "color": "green", "placement": "aboveGround", "modelPath": "DG-4850_mi.usdz" }, + { "size": "t2_1", "color": "olive", "placement": "belowGround", "modelPath": "DG-4850_ol.usdz" }, + { "size": "t2_9", "color": "white", "placement": "aboveGround", "modelPath": "DG-6400_wh.usdz" }, + { "size": "t2_9", "color": "green", "placement": "aboveGround", "modelPath": "DG-6400_mi.usdz" }, + { "size": "t2_9", "color": "olive", "placement": "belowGround", "modelPath": "DG-6400_ol.usdz" } + ] +} diff --git a/GasTankAR/Views/GalleryView.swift b/GasTankAR/Views/GalleryView.swift index 8053288..01452ac 100644 --- a/GasTankAR/Views/GalleryView.swift +++ b/GasTankAR/Views/GalleryView.swift @@ -8,6 +8,7 @@ import SwiftUI import SwiftData import MapKit +import UIKit import CoreLocation /// Galerie der im AR-Modus aufgenommenen Fotos eines Projekts. @@ -56,14 +57,17 @@ struct GalleryView: View { } } .fullScreenCover(item: $selectedPhoto) { photo in - PhotoViewer(photo: photo) { + PhotoViewer( + photo: photo, + photos: project.photos.sorted { $0.creationDate > $1.creationDate } + ) { delete(photo) } } } private func thumbnail(for photo: ProjectPhoto) -> some View { - PhotoThumbnail(imageData: photo.imageData) + PhotoThumbnail(imageData: photo.annotatedImageData ?? photo.imageData) .aspectRatio(1, contentMode: .fit) .clipped() .contentShape(.rect) @@ -130,52 +134,406 @@ struct PhotoThumbnail: View { } } -/// Vollbild-Betrachter für ein einzelnes Foto mit Schließen- und Löschen-Aktion. +/// Vollbild-Betrachter für die Projektfotos mit Schließen-, Lösch- und Wisch-Aktion. +private enum DrawingTool: String, CaseIterable, Identifiable { + case pen + case eraser + + var id: Self { self } + + var title: String { + switch self { + case .pen: + "Stift" + case .eraser: + "Radierer" + } + } +} + +private struct DrawingStroke { + var points: [CGPoint] + var color: Color + var width: CGFloat + var isEraser: Bool +} + private struct PhotoViewer: View { - let photo: ProjectPhoto + let photos: [ProjectPhoto] let onDelete: () -> Void @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @State private var currentIndex: Int + @State private var isDrawing = false + @State private var strokes: [DrawingStroke] = [] + @State private var activeStroke: DrawingStroke? + @State private var drawingTool: DrawingTool = .pen + @State private var drawingColor = Color.red + @State private var drawingWidth: Double = 0.012 + @State private var imageRevision = 0 + + private var currentPhoto: ProjectPhoto { + photos[currentIndex] + } + + init(photo: ProjectPhoto, photos: [ProjectPhoto], onDelete: @escaping () -> Void) { + self.photos = photos + self.onDelete = onDelete + _currentIndex = State(initialValue: photos.firstIndex(of: photo) ?? 0) + } 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() - } + TabView(selection: $currentIndex) { + ForEach(photos.indices, id: \.self) { index in + photoLayout(for: photos[index]) + .tag(index) } } + .tabViewStyle(.page(indexDisplayMode: .never)) .background(Color(.systemGroupedBackground)) .toolbar { ToolbarItem(placement: .topBarLeading) { - Button("Fertig") { dismiss() } + if isDrawing { + EmptyView() + } else { + Button("Fertig") { dismiss() } + } } ToolbarItem(placement: .topBarTrailing) { - Button("Löschen", systemImage: "trash", role: .destructive) { - onDelete() - dismiss() + if isDrawing { + Button { + saveDrawing() + } label: { + Image(systemName: "checkmark") + } + .buttonStyle(.glassProminent) + .tint(.accentColor) + .accessibilityLabel("Zeichnung speichern") + } else { + Button("Löschen", systemImage: "trash", role: .destructive) { + onDelete() + dismiss() + } } } } + .onChange(of: currentIndex) { + isDrawing = false + strokes.removeAll() + activeStroke = nil + } .toolbarBackground(.visible, for: .navigationBar) - .navigationTitle(photo.creationDate.formatted(.dateTime.day().month().year().hour().minute())) + .toolbar { + ToolbarItem(placement: .principal) { + HStack(spacing: 12) { + Button { + currentIndex -= 1 + } label: { + Image(systemName: "chevron.left") + } + .disabled(currentIndex == 0) + .accessibilityLabel("Vorheriges Bild") + + Text(currentPhoto.creationDate.formatted(.dateTime.day().month().year().hour().minute())) + .font(.subheadline) + + Button { + currentIndex += 1 + } label: { + Image(systemName: "chevron.right") + } + .disabled(currentIndex == photos.count - 1) + .accessibilityLabel("Nächstes Bild") + } + } + } .navigationBarTitleDisplayMode(.inline) } } + + private func photoLayout(for photo: ProjectPhoto) -> some View { + GeometryReader { geometry in + HStack(spacing: 0) { + imagePane(for: photo) + .padding(.top, 16) + .padding(.leading, 24) + .padding(.bottom, 24) + .padding(.trailing, 24) + .frame( + width: geometry.size.width * 0.75, + height: geometry.size.height + ) + + metadataPane(for: photo) + .frame(width: geometry.size.width * 0.25) + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + } + + private func imagePane(for photo: ProjectPhoto) -> some View { + GeometryReader { geometry in + ZStack { + if let image = UIImage(data: photo.annotatedImageData ?? photo.imageData) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + .id(imageRevision) + } else { + ContentUnavailableView("Bild nicht verfügbar", systemImage: "photo") + } + + if isDrawing { + drawingCanvas(in: geometry.size) + } + } + } + } + + private func drawingCanvas(in size: CGSize) -> some View { + Canvas { context, canvasSize in + for stroke in strokes + (activeStroke.map { [$0] } ?? []) { + draw(stroke, in: &context, size: canvasSize) + } + } + .contentShape(.rect) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let point = CGPoint( + x: value.location.x / max(size.width, 1), + y: value.location.y / max(size.height, 1) + ) + if drawingTool == .eraser { + strokes.removeAll { stroke in + stroke.points.contains { pointDistance($0, point) < drawingWidth * 2 } + } + } else { + if activeStroke == nil { + activeStroke = DrawingStroke( + points: [point], + color: drawingColor, + width: drawingWidth, + isEraser: false + ) + } else { + activeStroke?.points.append(point) + } + } + } + .onEnded { _ in + if let activeStroke { + strokes.append(activeStroke) + self.activeStroke = nil + } + } + ) + } + + private func draw( + _ stroke: DrawingStroke, + in context: inout GraphicsContext, + size: CGSize + ) { + guard let first = stroke.points.first else { return } + var path = Path() + path.move(to: CGPoint(x: first.x * size.width, y: first.y * size.height)) + for point in stroke.points.dropFirst() { + path.addLine(to: CGPoint(x: point.x * size.width, y: point.y * size.height)) + } + context.stroke( + path, + with: .color(stroke.color), + style: StrokeStyle( + lineWidth: stroke.width * min(size.width, size.height), + lineCap: .round, + lineJoin: .round + ) + ) + } + + private func pointDistance(_ lhs: CGPoint, _ rhs: CGPoint) -> CGFloat { + hypot(lhs.x - rhs.x, lhs.y - rhs.y) + } + + private var drawingSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Zeichnen") + .font(.headline) + + GlassEffectContainer(spacing: 12) { + HStack(spacing: 12) { + Button { + if isDrawing && drawingTool == .pen { + isDrawing = false + activeStroke = nil + } else { + isDrawing = true + drawingTool = .pen + } + } label: { + Image(systemName: "pencil.tip") + .font(.system(size: 20, weight: .semibold)) + .frame(width: 44, height: 44) + } + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .tint(isDrawing && drawingTool == .pen ? .accentColor : .primary) + .accessibilityLabel( + isDrawing && drawingTool == .pen + ? "Zeichnen beenden" + : "Stift auswählen" + ) + + Button { + isDrawing = true + drawingTool = .eraser + activeStroke = nil + } label: { + Image(systemName: "eraser.fill") + .font(.system(size: 20, weight: .semibold)) + .frame(width: 44, height: 44) + } + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .tint(isDrawing && drawingTool == .eraser ? .accentColor : .primary) + .accessibilityLabel("Radierer auswählen") + + if (isDrawing && drawingTool == .pen) || currentPhoto.annotatedImageData != nil { + Spacer() + + Button { + if let originalImageData = currentPhoto.originalImageData { + currentPhoto.imageData = originalImageData + } + currentPhoto.originalImageData = nil + currentPhoto.annotatedImageData = nil + strokes.removeAll() + activeStroke = nil + imageRevision += 1 + try? modelContext.save() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 19, weight: .semibold)) + .frame(width: 44, height: 44) + } + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .tint(.primary) + .accessibilityLabel("Zeichnung zurücksetzen") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if isDrawing && drawingTool == .pen { + ColorPicker("Farbe", selection: $drawingColor) + + VStack(alignment: .leading, spacing: 4) { + Text("Stiftdicke") + Slider(value: $drawingWidth, in: 0.003...0.04) + } + } + + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func saveDrawing() { + guard let baseImage = UIImage(data: currentPhoto.annotatedImageData ?? currentPhoto.imageData), + !strokes.isEmpty else { + isDrawing = false + return + } + + let renderer = UIGraphicsImageRenderer(size: baseImage.size) + let renderedPNG = renderer.pngData { rendererContext in + baseImage.draw(in: CGRect(origin: .zero, size: baseImage.size)) + let context = rendererContext.cgContext + for stroke in strokes { + guard let first = stroke.points.first else { continue } + context.setStrokeColor(UIColor(stroke.color).cgColor) + context.setLineWidth(stroke.width * min(baseImage.size.width, baseImage.size.height)) + context.setLineCap(.round) + context.setLineJoin(.round) + context.beginPath() + context.move( + to: CGPoint( + x: first.x * baseImage.size.width, + y: first.y * baseImage.size.height + ) + ) + for point in stroke.points.dropFirst() { + context.addLine( + to: CGPoint( + x: point.x * baseImage.size.width, + y: point.y * baseImage.size.height + ) + ) + } + context.strokePath() + } + } + + if currentPhoto.originalImageData == nil { + currentPhoto.originalImageData = currentPhoto.imageData + } + currentPhoto.imageData = renderedPNG + currentPhoto.annotatedImageData = renderedPNG + + try? modelContext.save() + imageRevision += 1 + strokes.removeAll() + activeStroke = nil + isDrawing = false + } + + private func metadataPane(for photo: ProjectPhoto) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Metadaten") + .font(.title3.weight(.semibold)) + + VStack(alignment: .leading, spacing: 4) { + Text("Aufgenommen") + .font(.subheadline.weight(.semibold)) + Text(photo.creationDate.formatted(.dateTime.day().month(.wide).year().hour().minute())) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Divider() + + if let coordinate = photo.coordinate { + VStack(alignment: .leading, spacing: 8) { + Text("Standort") + .font(.headline) + PhotoLocationCard(coordinate: coordinate) + } + } else { + VStack(alignment: .leading, spacing: 6) { + Label("Kein Standort gespeichert", systemImage: "mappin.slash") + .font(.headline) + Text("Bei diesem Bild wurden keine Standortdaten aufgenommen.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + drawingSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .background(Color(.systemGroupedBackground)) + } } /// Schwebende Karte, die den Aufnahmeort eines Fotos zeigt: Karten-Vorschau plus @@ -249,7 +607,6 @@ private struct PhotoLocationCard: View { String(format: "%.5f, %.5f", coordinate.latitude, coordinate.longitude) } } - #Preview("Galerie") { let sample = PreviewSupport.galleryProjectContainer() return NavigationStack { @@ -259,5 +616,6 @@ private struct PhotoLocationCard: View { } #Preview("Foto-Detail") { - PhotoViewer(photo: PreviewSupport.samplePhoto()) {} + let photo = PreviewSupport.samplePhoto() + PhotoViewer(photo: photo, photos: [photo]) {} } diff --git a/GasTankAR/Views/ProjectCreate.swift b/GasTankAR/Views/ProjectCreate.swift index 23b055b..f22b2b0 100644 --- a/GasTankAR/Views/ProjectCreate.swift +++ b/GasTankAR/Views/ProjectCreate.swift @@ -12,6 +12,8 @@ struct ProjectCreateView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss + let onCreated: (Project) -> Void + @State private var leadID: String = "" @State private var customerName: String = "" @State private var creationDate: Date = .now @@ -41,9 +43,7 @@ struct ProjectCreateView: View { ) } - Section("Tankauswahl") { - TankEditor(tank: $tank) - } + TankSelectionSection(tank: $tank) } .navigationTitle("Projekt erstellen") .navigationBarTitleDisplayMode(.inline) @@ -72,11 +72,12 @@ struct ProjectCreateView: View { tank: tank ) modelContext.insert(project) + onCreated(project) dismiss() } } #Preview { - ProjectCreateView() + ProjectCreateView { _ in } .modelContainer(for: Project.self, inMemory: true) } diff --git a/GasTankAR/Views/ProjectDetailView.swift b/GasTankAR/Views/ProjectDetailView.swift index 2bd425c..71d3ec6 100644 --- a/GasTankAR/Views/ProjectDetailView.swift +++ b/GasTankAR/Views/ProjectDetailView.swift @@ -10,6 +10,7 @@ import SwiftData struct ProjectDetailView: View { @Bindable var project: Project + @State private var isARPresented = false var body: some View { TabView { @@ -37,13 +38,11 @@ struct ProjectDetailView: View { ) } - Section("Tankauswahl") { - TankEditor(tank: $project.tank) - } + TankSelectionSection(tank: $project.tank) } .safeAreaInset(edge: .bottom) { - NavigationLink { - ARExperienceView(project: project) + Button { + isARPresented = true } label: { Label("AR-Modus starten", systemImage: "arkit") .font(.headline) @@ -53,6 +52,9 @@ struct ProjectDetailView: View { .buttonStyle(.borderedProminent) .padding() } + .fullScreenCover(isPresented: $isARPresented) { + ARExperienceView(project: project) + } } } diff --git a/GasTankAR/Views/ProjectListView.swift b/GasTankAR/Views/ProjectListView.swift index 455f5b7..a5c2dd4 100644 --- a/GasTankAR/Views/ProjectListView.swift +++ b/GasTankAR/Views/ProjectListView.swift @@ -62,7 +62,9 @@ struct ProjectListView: View { .accessibilityLabel("Projekt erstellen") } .sheet(isPresented: $isCreatingProject) { - ProjectCreateView() + ProjectCreateView { project in + selectedProject = project + } } } detail: { NavigationStack { diff --git a/GasTankAR/Views/SettingsView.swift b/GasTankAR/Views/SettingsView.swift index 7f34ab5..22b1a9a 100644 --- a/GasTankAR/Views/SettingsView.swift +++ b/GasTankAR/Views/SettingsView.swift @@ -11,10 +11,12 @@ import ARKit /// Zentraler Schlüssel für die LiDAR-Einstellung (in AppStorage/UserDefaults). enum SettingsKeys { static let lidarFeaturesEnabled = "lidarFeaturesEnabled" + static let arExposure = "arExposure" } struct SettingsView: View { @AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false + @AppStorage(SettingsKeys.arExposure) private var arExposure = 1.0 @Environment(\.dismiss) private var dismiss /// LiDAR ist verfügbar, wenn das Gerät Scene-Reconstruction (Mesh) unterstützt. @@ -37,6 +39,23 @@ struct SettingsView: View { Text("Dieses Gerät hat keinen LiDAR-Scanner. Die App nutzt automatisch das Verfahren ohne LiDAR.") } } + + Section { + HStack { + Text("Belichtung") + Spacer() + Text(arExposure.formatted(.number.precision(.fractionLength(1))) + "×") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $arExposure, in: 0.5...2.0, step: 0.1) + .accessibilityLabel("AR-Belichtung") + .accessibilityValue(arExposure.formatted(.number.precision(.fractionLength(1))) + " mal") + } header: { + Text("Darstellung") + } footer: { + Text("Passt die zusätzliche Beleuchtung der oberirdischen Tanks an. Änderungen greifen beim nächsten Start des AR-Modus.") + } } .navigationTitle("Einstellungen") .navigationBarTitleDisplayMode(.inline) diff --git a/GasTankAR/Views/TankAR/ARExperienceView.swift b/GasTankAR/Views/TankAR/ARExperienceView.swift index e05cbea..7cfc5ff 100644 --- a/GasTankAR/Views/TankAR/ARExperienceView.swift +++ b/GasTankAR/Views/TankAR/ARExperienceView.swift @@ -14,6 +14,7 @@ struct ARExperienceView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false + @AppStorage(SettingsKeys.arExposure) private var arExposure = 1.0 let project: Project @@ -41,6 +42,9 @@ struct ARExperienceView: View { if controller.pipeRoutingPhase == .placing { pipeRoutingOverlay(controller: controller) } + if controller.selectedPipeSegmentIndex != nil { + pipeSegmentRemovalOverlay(controller: controller) + } Color.white .opacity(flashOpacity) .ignoresSafeArea() @@ -51,8 +55,22 @@ struct ARExperienceView: View { } } } - .navigationTitle(project.customerName) - .navigationBarTitleDisplayMode(.inline) + .overlay(alignment: .topLeading) { + Button { + dismiss() + } label: { + Image(systemName: "arrow.left") + .font(.title2.weight(.semibold)) + .frame(width: 44, height: 44) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .shadow(radius: 3) + .accessibilityLabel("Zurück") + .padding(.leading, 12) + .safeAreaPadding(.top) + } .sheet(isPresented: $showMeasurements) { if let controller { MeasurementListView(controller: controller) { @@ -77,7 +95,11 @@ struct ARExperienceView: View { .task { guard controller == nil else { return } let persistence = ProjectPersistenceAdapter(project: project, modelContext: modelContext) - controller = ARSessionController(persistence: persistence, useLiDAR: lidarEnabled) + controller = ARSessionController( + persistence: persistence, + useLiDAR: lidarEnabled, + arExposure: arExposure + ) } } @@ -185,6 +207,32 @@ struct ARExperienceView: View { } } + private func pipeSegmentRemovalOverlay(controller: ARSessionController) -> some View { + VStack { + Spacer() + + HStack(spacing: 12) { + Text("Verbindung entfernen?") + .font(.subheadline.weight(.medium)) + + Button("Abbrechen") { + controller.cancelPipeSegmentRemoval() + } + .buttonStyle(.bordered) + + Button("Entfernen", role: .destructive) { + controller.confirmPipeSegmentRemoval() + } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: .capsule) + } + .padding() + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + // MARK: - Rohrführung-Overlay /// Overlay für die Rohrführung: pinkes Fadenkreuz (Vollbild-Mitte), Info-Banner oben, diff --git a/GasTankAR/Views/TankAR/ARProjectPersisting.swift b/GasTankAR/Views/TankAR/ARProjectPersisting.swift index 8fa6989..c87cff1 100644 --- a/GasTankAR/Views/TankAR/ARProjectPersisting.swift +++ b/GasTankAR/Views/TankAR/ARProjectPersisting.swift @@ -16,9 +16,13 @@ protocol ARProjectPersisting: AnyObject { var tank: Tank { get } var worldMap: ARWorldMap? { get } var tankTransform: [Float]? { get } + var savedMeasurements: [Measurement] { get } + var savedPipeRoute: PipeRoute { get } func saveWorldMap(_ data: Data) func saveTankTransform(_ transform: [Float]) + func saveMeasurements(_ measurements: [Measurement]) + func savePipeRoute(_ route: PipeRoute) func clearTankTransform() func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?) } @@ -38,6 +42,16 @@ final class ProjectPersistenceAdapter: ARProjectPersisting { var worldMap: ARWorldMap? { project.worldMap } var tankTransform: [Float]? { project.tankTransform } + var savedMeasurements: [Measurement] { + guard let data = project.measurementsData else { return [] } + return (try? JSONDecoder().decode([Measurement].self, from: data)) ?? [] + } + + var savedPipeRoute: PipeRoute { + guard let data = project.pipeRouteData else { return PipeRoute() } + return (try? JSONDecoder().decode(PipeRoute.self, from: data)) ?? PipeRoute() + } + func saveWorldMap(_ data: Data) { project.worldMapData = data try? modelContext.save() @@ -48,6 +62,16 @@ final class ProjectPersistenceAdapter: ARProjectPersisting { try? modelContext.save() } + func saveMeasurements(_ measurements: [Measurement]) { + project.measurementsData = try? JSONEncoder().encode(measurements) + try? modelContext.save() + } + + func savePipeRoute(_ route: PipeRoute) { + project.pipeRouteData = try? JSONEncoder().encode(route) + try? modelContext.save() + } + func clearTankTransform() { project.tankTransform = nil try? modelContext.save() diff --git a/GasTankAR/Views/TankAR/ARSessionController.swift b/GasTankAR/Views/TankAR/ARSessionController.swift index 4eada74..325e816 100644 --- a/GasTankAR/Views/TankAR/ARSessionController.swift +++ b/GasTankAR/Views/TankAR/ARSessionController.swift @@ -32,6 +32,20 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV private(set) var measurements: [Measurement] = [] /// Aktuelle Phase des Messvorgangs. private(set) var measurementPhase: MeasurementPhase = .idle + /// Vom Nutzer aktivierte, automatisch am Tank angelegte Maße. + private(set) var visibleTankDimensions: Set = [] + /// Transparenz des vergrabenen Tankkörpers: 0 = opak, 1 = vollständig transparent. + private(set) var undergroundTransparency: Double = 1.0 - Double(TankDepthFadeMaterial.belowAlpha) + /// Ob die Grube beim aktuell platzierten unterirdischen Tank sichtbar ist. + private(set) var showsPit: Bool + /// Unterirdische Tanks bieten zusätzliche Darstellungsoptionen. + var isUndergroundTank: Bool { + persistence.tank.placement == .belowGround + } + /// Ob der aktuell platzierte Tank halbtransparent dargestellt wird. + var isSemiTransparentTank: Bool { + isUndergroundTank && !showsPit + } /// Live-Distanz (Meter) zwischen erstem Punkt und aktueller Bildschirmmitte – für die UI. private(set) var liveDistance: Float? @@ -47,7 +61,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV @ObservationIgnored private let persistence: ARProjectPersisting @ObservationIgnored private let useLiDAR: Bool @ObservationIgnored private let configFactory: SessionConfigurationFactory - @ObservationIgnored private let entityFactory = TankEntityFactory() + @ObservationIgnored private let entityFactory: TankEntityFactory @ObservationIgnored private var machine: ARFlowStateMachine @ObservationIgnored private weak var arView: ARView? @@ -61,8 +75,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV @ObservationIgnored private var measurementAnchor: AnchorEntity? /// Container der fertigen Strecken, nach `Measurement.id`. @ObservationIgnored private var measurementEntities: [UUID: Entity] = [:] + @ObservationIgnored private var tankDimensionEntities: [TankDimension: Entity] = [:] + @ObservationIgnored private var tankBaseExtents: SIMD3? + @ObservationIgnored private var lastTankDimensionScale: SIMD3? /// Label-Halter für das Billboarding (Ausrichtung zur Kamera). @ObservationIgnored private var measurementLabels: [Entity] = [] + @ObservationIgnored private var tankDimensionLabels: [Entity] = [] /// Vorläufige Punkt-Kugeln während eines laufenden Messvorgangs. @ObservationIgnored private var previewPointEntities: [Entity] = [] @ObservationIgnored private var pendingMeasurementStart: SIMD3? @@ -79,6 +97,9 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV @ObservationIgnored private var pipeAnchor: AnchorEntity? @ObservationIgnored private var pipeWaypointEntities: [Entity] = [] @ObservationIgnored private var pipeSegmentEntities: [Entity] = [] + @ObservationIgnored private var pipeSegmentIndices: [Int] = [] + /// Index der Verbindung, die nach Doppeltipp bestätigt entfernt werden kann. + private(set) var selectedPipeSegmentIndex: Int? /// Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte. @ObservationIgnored private var pipeLiveSegment: ModelEntity? /// Per-Frame-Callback zum Ausrichten der Labels zur Kamera. @@ -106,14 +127,18 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV private static let anchorDetectionTimeout: Duration = .seconds(20) private static let relocationTimeout: Duration = .seconds(15) - init(persistence: ARProjectPersisting, useLiDAR: Bool) { + init(persistence: ARProjectPersisting, useLiDAR: Bool, arExposure: Double = 1.0) { self.persistence = persistence self.useLiDAR = useLiDAR + self.showsPit = persistence.tank.placement == .belowGround && !persistence.tank.showLidOnly + self.entityFactory = TankEntityFactory(aboveGroundLightMultiplier: Float(arExposure)) self.configFactory = SessionConfigurationFactory( useLiDAR: useLiDAR, referenceImages: ReferenceImageProvider.load() ) self.machine = ARFlowStateMachine(hasStoredTransform: persistence.tankTransform != nil) + self.measurements = persistence.savedMeasurements + self.pipeRoute = persistence.savedPipeRoute super.init() locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters @@ -145,13 +170,22 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) view.addGestureRecognizer(tap) + let doubleTap = UITapGestureRecognizer( + target: self, + action: #selector(handlePipeSegmentDoubleTap(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + view.addGestureRecognizer(doubleTap) + tap.require(toFail: doubleTap) + // Pro Frame die Mess-Labels zur Kamera ausrichten (Billboarding). sceneUpdateSubscription = view.scene.subscribe(to: SceneEvents.Update.self) { [weak self] _ in guard let self else { return } MainActor.assumeIsolated { self.onSceneUpdate() } } - entityFactory.preload() + entityFactory.preload(persistence.tank) + restorePersistedAnnotations() startMappingPoll() startLocationUpdates() send(.started) @@ -184,6 +218,50 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV func retryRelocation() { send(.retryRelocationTapped) } func deselectTank() { isTankSelected = false } + /// Passt ausschließlich die Transparenz des vergrabenen Tankkörpers an. + func setUndergroundTransparency(_ value: Double) { + guard isSemiTransparentTank else { return } + undergroundTransparency = min(max(value, 0), 1) + updateDepthFadeGroundY() + } + + /// Schaltet die Grube eines unterirdischen Tanks direkt im AR-Modus um. + func setShowsPit(_ visible: Bool) { + guard isUndergroundTank, visible != showsPit, + let currentTank = tankEntity, + let tankAnchor else { return } + + showsPit = visible + var tank = persistence.tank + tank.showLidOnly = !visible + let transform = currentTank.transform + + currentTank.removeFromParent() + tankDimensionEntities.removeAll() + tankDimensionLabels.removeAll() + tankBaseExtents = nil + lastTankDimensionScale = nil + + let replacement = entityFactory.makeContainer(for: tank) + replacement.transform = transform + tankAnchor.addChild(replacement) + tankEntity = replacement + installManipulationGestures(on: replacement) + + updateDepthFadeGroundY() + updateSafetyZones() + updateTankDimensions() + } + + /// Setzt die Tank-Skalierung auf die Originalgröße zurück. + func resetTankScale() { + guard let tankEntity else { return } + tankEntity.scale = SIMD3(repeating: 1) + updateSafetyZones() + updateTankDimensionValues() + persistTankTransform() + } + // MARK: - Tank-Feinjustierung (über das Steuer-Panel) private static let heightStep: Float = 0.05 @@ -261,7 +339,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV /// Standort für die spätere Karten-Verortung – im Projekt. func captureSnapshot() { guard let arView else { return } - let coordinate = currentLocation?.coordinate + let coordinate = locationManager.location?.coordinate ?? currentLocation?.coordinate arView.snapshot(saveToHDR: false) { [weak self] image in guard let image else { return } let date = Date() @@ -276,6 +354,16 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV // MARK: - Ausmessen (Strecken) + /// Schaltet ein automatisch am Tank angelegtes Maß ein oder aus. + func setTankDimension(_ dimension: TankDimension, visible: Bool) { + if visible { + visibleTankDimensions.insert(dimension) + } else { + visibleTankDimensions.remove(dimension) + } + updateTankDimensions() + } + /// Startet einen neuen Messvorgang; der Nutzer tippt danach zwei Punkte an. func startMeasurement() { clearMeasurementPreview() @@ -306,6 +394,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV end: end ) measurements.append(measurement) + persistence.saveMeasurements(measurements) renderMeasurement(measurement) clearMeasurementPreview() @@ -327,6 +416,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV } } measurements.remove(atOffsets: offsets) + persistence.saveMeasurements(measurements) } /// Setzt den aktuellen Punkt aus der Bildschirmmitte (wie beim Platzieren des Tanks). @@ -375,6 +465,130 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV } } + private func restorePersistedAnnotations() { + for measurement in measurements { + renderMeasurement(measurement) + } + + rebuildPipeRouteEntities() + } + + private func rebuildPipeRouteEntities() { + pipeWaypointEntities.forEach { $0.removeFromParent() } + pipeSegmentEntities.forEach { $0.removeFromParent() } + pipeWaypointEntities.removeAll() + pipeSegmentEntities.removeAll() + pipeSegmentIndices.removeAll() + + guard !pipeRoute.waypoints.isEmpty else { return } + let anchor = ensurePipeAnchor() + + for waypoint in pipeRoute.waypoints { + let entity = PipeRouteEntityFactory.makeWaypoint(at: waypoint) + anchor.addChild(entity) + pipeWaypointEntities.append(entity) + } + + for (index, pair) in zip(pipeRoute.waypoints, pipeRoute.waypoints.dropFirst()).enumerated() + where !pipeRoute.isSegmentRemoved(at: index) { + let segment = PipeRouteEntityFactory.makeSegment(from: pair.0, to: pair.1) + anchor.addChild(segment) + pipeSegmentEntities.append(segment) + pipeSegmentIndices.append(index) + } + } + + private func updateTankDimensions() { + tankDimensionEntities.values.forEach { $0.removeFromParent() } + tankDimensionEntities.removeAll() + tankDimensionLabels.removeAll() + + guard let tankEntity, + let tankModel = tankEntity.findEntity(named: TankEntityFactory.modelEntityName) else { return } + // Nur das eigentliche Modell vermessen; Grube, Occlusion-Maske und Markierung + // dürfen die Tankmaße nicht beeinflussen. + let bounds = tankModel.visualBounds(relativeTo: tankEntity) + tankBaseExtents = bounds.extents + lastTankDimensionScale = tankEntity.scale + guard !visibleTankDimensions.isEmpty else { return } + let lower = bounds.min + let upper = bounds.max + let center = bounds.center + let dimensionGap = Swift.max(Swift.max(bounds.extents.x, bounds.extents.y), bounds.extents.z) * 0.08 + let labelGap: Float = 0.02 + + for dimension in visibleTankDimensions { + let start: SIMD3 + let end: SIMD3 + let labelOffset: SIMD3 + + switch dimension { + case .height: + start = SIMD3(upper.x + dimensionGap, lower.y, center.z) + end = SIMD3(upper.x + dimensionGap, upper.y, center.z) + labelOffset = SIMD3(labelGap, 0, 0) + case .length: + // Die Längenlinie liegt direkt an der unteren Modellkante. Ein + // zusätzlicher Abstand nach unten würde bei unterirdischen Tanks + // unter der Grubensohle liegen und dadurch verdeckt werden. + start = SIMD3(lower.x - dimensionGap, lower.y, lower.z) + end = SIMD3(lower.x - dimensionGap, lower.y, upper.z) + labelOffset = SIMD3(-labelGap, 0, 0) + case .width: + start = SIMD3(lower.x, upper.y + dimensionGap, center.z) + end = SIMD3(upper.x, upper.y + dimensionGap, center.z) + labelOffset = SIMD3(0, labelGap, 0) + } + + let entity = MeasurementEntityFactory.makeDimension( + dimension, + from: start, + to: end, + labelOffset: labelOffset + ) + tankEntity.addChild(entity) + tankDimensionEntities[dimension] = entity + if let label = entity.findEntity(named: MeasurementEntityFactory.labelName) { + tankDimensionLabels.append(label) + } + } + } + + private func updateTankDimensionValues() { + guard let tankEntity, + let tankBaseExtents, + !visibleTankDimensions.isEmpty else { return } + + let scale = tankEntity.scale + guard lastTankDimensionScale != scale else { return } + lastTankDimensionScale = scale + + let absoluteScale = SIMD3( + abs(scale.x), + abs(scale.y), + abs(scale.z) + ) + let scaledExtents = tankBaseExtents * absoluteScale + + for dimension in visibleTankDimensions { + guard let entity = tankDimensionEntities[dimension] else { continue } + let distance: Float + switch dimension { + case .height: + distance = scaledExtents.y + case .length: + distance = scaledExtents.z + case .width: + distance = scaledExtents.x + } + MeasurementEntityFactory.updateDimensionLabel( + entity, + dimension: dimension, + distance: distance + ) + } + } + private func ensureMeasurementAnchor() -> AnchorEntity { if let measurementAnchor { return measurementAnchor } let anchor = AnchorEntity(world: matrix_identity_float4x4) @@ -392,7 +606,6 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV private func onSceneUpdate() { updateLivePreview() updatePipeLivePreview() - billboardLabels() } /// Aktualisiert Cursor, Live-Linie und Distanz-Label anhand der aktuellen Bildschirmmitte. @@ -428,7 +641,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV liveDistance = distance if let holder = liveLabelEntity { holder.children.forEach { $0.removeFromParent() } - holder.addChild(MeasurementEntityFactory.makeText(String(format: "%.2f m", distance))) + holder.addChild( + MeasurementEntityFactory.makeText( + String(format: "%.2f m", distance), + fontSize: 0.09 + ) + ) } } } @@ -460,7 +678,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV private func billboardLabels() { guard let arView else { return } let camera = arView.cameraTransform.translation - var labels = measurementLabels + var labels = measurementLabels + tankDimensionLabels if let liveLabelEntity, liveLabelEntity.isEnabled { labels.append(liveLabelEntity) } guard !labels.isEmpty else { return } @@ -494,11 +712,13 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV let segment = PipeRouteEntityFactory.makeSegment(from: previous, to: position) anchor.addChild(segment) pipeSegmentEntities.append(segment) + pipeSegmentIndices.append(pipeRoute.waypoints.count - 1) } let waypoint = PipeRouteEntityFactory.makeWaypoint(at: position) anchor.addChild(waypoint) pipeWaypointEntities.append(waypoint) pipeRoute.add(position) + persistence.savePipeRoute(pipeRoute) } /// Entfernt den zuletzt gesetzten Wegpunkt (und sein Segment). @@ -506,6 +726,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV guard pipeRoute.removeLast() != nil else { return } pipeWaypointEntities.popLast()?.removeFromParent() pipeSegmentEntities.popLast()?.removeFromParent() + pipeSegmentIndices.popLast() + persistence.savePipeRoute(pipeRoute) } /// Beendet das Setzen; der fertige Pfad bleibt sichtbar. @@ -533,8 +755,11 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV pipeSegmentEntities.forEach { $0.removeFromParent() } pipeWaypointEntities.removeAll() pipeSegmentEntities.removeAll() + pipeSegmentIndices.removeAll() + selectedPipeSegmentIndex = nil hidePipeLivePreview() pipeRoute.clear() + persistence.savePipeRoute(pipeRoute) } /// Zeichnet das Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte. @@ -745,11 +970,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV updateDepthFadeGroundY() updateSceneOcclusion() updateSafetyZones() + updateTankDimensions() } /// Baut die Sicherheitsabstände auf, aktualisiert ihre Bodenposition oder entfernt sie. /// Die Ringe hängen am Tank-Anker (nicht am Tank selbst), damit Skalierung/Rotation - /// des Tanks sie nicht verzerrt; sie werden nur auf die Bodenmitte des Tanks gesetzt. + /// des Tanks sie nicht verzerrt. Ihr Mittelpunkt liegt am seitlichen Domschacht. private func updateSafetyZones() { guard showSafetyZones, let tankAnchor, let tankEntity else { safetyZoneEntity?.removeFromParent() @@ -765,7 +991,26 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV tankAnchor.addChild(entity) safetyZoneEntity = entity } - entity.position = tankEntity.position(relativeTo: tankAnchor) + + let tankPosition = tankEntity.position(relativeTo: tankAnchor) + let localDomeOffset = SIMD4(0, 0, safetyZoneDomeOffset, 0) + let tankTransform = tankEntity.transformMatrix(relativeTo: tankAnchor) + let domeOffset = tankTransform * localDomeOffset + + entity.position = tankPosition + SIMD3(domeOffset.x, domeOffset.y, domeOffset.z) + } + + /// Abstand vom Tankmittelpunkt zum seitlichen Domschacht entlang der lokalen + /// Tank-Längsachse (+Z), abgeleitet aus den Vorgabemaßen. + private var safetyZoneDomeOffset: Float { + switch persistence.tank.size { + case .t1_2: + return 2.460 / 2 + case .t2_1: + return 4.255 / 2 + case .t2_9: + return 5.500 / 2 + } } private func installManipulationGestures(on entity: ModelEntity) { @@ -779,8 +1024,11 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV @objc private func handleManipulationChanged(_ recognizer: UIGestureRecognizer) { updateSafetyZones() // Ringe live mit dem Tank mitführen. switch recognizer.state { - case .ended, .cancelled, .failed: persistTankTransform() - default: break + case .ended, .cancelled, .failed: + updateTankDimensionValues() + persistTankTransform() + default: + break } } @@ -800,11 +1048,59 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV } } + @objc private func handlePipeSegmentDoubleTap(_ gesture: UITapGestureRecognizer) { + guard state == .immersive, + measurementPhase == .idle, + pipeRoutingPhase == .idle, + let arView else { return } + + let point = gesture.location(in: arView) + guard let hit = arView.entity(at: point) else { return } + + for (entityIndex, segment) in pipeSegmentEntities.enumerated() { + if Self.entity(hit, isPartOf: segment) { + if let selectedPipeSegmentIndex { + setPipeSegmentHighlight(selectedPipeSegmentIndex, highlighted: false) + } + selectedPipeSegmentIndex = pipeSegmentIndices[entityIndex] + setPipeSegmentHighlight(pipeSegmentIndices[entityIndex], highlighted: true) + return + } + } + } + + func cancelPipeSegmentRemoval() { + if let selectedPipeSegmentIndex { + setPipeSegmentHighlight(selectedPipeSegmentIndex, highlighted: false) + } + selectedPipeSegmentIndex = nil + } + + func confirmPipeSegmentRemoval() { + guard selectedPipeSegmentIndex != nil else { return } + pipeRoute.removeSegmentAndOrphanedWaypoints(at: selectedPipeSegmentIndex!) + selectedPipeSegmentIndex = nil + rebuildPipeRouteEntities() + persistence.savePipeRoute(pipeRoute) + } + + private func setPipeSegmentHighlight(_ index: Int, highlighted: Bool) { + guard let entityIndex = pipeSegmentIndices.firstIndex(of: index) else { return } + PipeRouteEntityFactory.setSegmentColor( + pipeSegmentEntities[entityIndex], + color: highlighted ? .systemYellow : .systemPink + ) + } + private func removeTank() { if let tankAnchor { arView?.scene.removeAnchor(tankAnchor) } tankAnchor = nil tankEntity = nil safetyZoneEntity = nil + tankDimensionEntities.removeAll() + tankDimensionLabels.removeAll() + tankBaseExtents = nil + lastTankDimensionScale = nil isTankSelected = false } @@ -819,7 +1115,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV private func updateDepthFadeGroundY() { guard persistence.tank.placement == .belowGround, let tankEntity else { return } let groundY = tankEntity.position(relativeTo: nil).y - TankDepthFadeMaterial.updateGroundY(groundY, on: tankEntity) + let belowAlpha = 1.0 - Float(undergroundTransparency) + TankDepthFadeMaterial.updateGroundY(groundY, belowAlpha: belowAlpha, on: tankEntity) } // MARK: - Persistenz & Status diff --git a/GasTankAR/Views/TankAR/Measurement.swift b/GasTankAR/Views/TankAR/Measurement.swift index fcd69b4..b3082b4 100644 --- a/GasTankAR/Views/TankAR/Measurement.swift +++ b/GasTankAR/Views/TankAR/Measurement.swift @@ -9,7 +9,23 @@ import Foundation import simd /// Eine benannte Strecke zwischen zwei im Raum gewählten Punkten (Welt-Koordinaten). -struct Measurement: Identifiable { +enum TankDimension: String, CaseIterable, Hashable, Identifiable { + case height + case length + case width + + var id: Self { self } + + var title: String { + switch self { + case .height: "Höhe" + case .length: "Länge" + case .width: "Breite" + } + } +} + +struct Measurement: Identifiable, Codable { let id = UUID() var name: String let start: SIMD3 diff --git a/GasTankAR/Views/TankAR/MeasurementEntityFactory.swift b/GasTankAR/Views/TankAR/MeasurementEntityFactory.swift index 206444e..4a83fd8 100644 --- a/GasTankAR/Views/TankAR/MeasurementEntityFactory.swift +++ b/GasTankAR/Views/TankAR/MeasurementEntityFactory.swift @@ -20,9 +20,11 @@ enum MeasurementEntityFactory { 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 + private static let lineThickness: Float = 0.02 + private static let labelHeight: Float = 0.14 + private static let measurementLabelHeight: Float = 0.09 + private static let labelBaseGap: Float = 0.02 + private static let textClearance: Float = 0.015 /// Kleine Kugel für einen (auch vorläufigen) Messpunkt. static func makePoint(at position: SIMD3) -> ModelEntity { @@ -45,13 +47,90 @@ enum MeasurementEntityFactory { 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))")) + let lineDirection = normalize(measurement.end - measurement.start) + var labelOrientation = simd_quatf( + from: SIMD3(1, 0, 0), + to: lineDirection + ) + labelOrientation = simd_quatf( + angle: -.pi / 2, + axis: lineDirection + ) * labelOrientation + + let text = makeText( + "\(measurement.name) (\(measurement.formattedDistance))", + fontSize: measurementLabelHeight + ) + let textBounds = text.visualBounds(relativeTo: nil) + let label = makeLabelHolder(usesBillboard: false) + label.orientation = labelOrientation + label.position = labelPosition( + from: measurement.start, + to: measurement.end, + verticalOffset: textBounds.extents.y / 2 + labelBaseGap + textClearance + ) + label.addChild(text) container.addChild(label) return container } + /// Erzeugt eine vorgefertigte Tankmaß-Linie mit verständlicher Beschriftung. + static func makeDimension( + _ dimension: TankDimension, + from start: SIMD3, + to end: SIMD3, + labelOffset: SIMD3 + ) -> Entity { + let container = Entity() + let line = makeLineBase() + configureLine(line, from: start, to: end) + container.addChild(line) + + let label = makeLabelHolder(usesBillboard: false) + let lineDirection = normalize(end - start) + var labelOrientation = simd_quatf( + from: SIMD3(1, 0, 0), + to: lineDirection + ) + if dimension == .length { + labelOrientation = simd_quatf( + angle: -.pi / 2, + axis: lineDirection + ) * labelOrientation + } + label.orientation = labelOrientation + let distance = simd_distance(start, end) + let text = makeText("\(dimension.title): \(String(format: "%.2f m", distance))") + let textBounds = text.visualBounds(relativeTo: nil) + var safeLabelOffset = labelOffset + + if abs(labelOffset.x) > abs(labelOffset.y) { + safeLabelOffset.x += labelOffset.x >= 0 + ? textBounds.extents.y / 2 + textClearance + : -(textBounds.extents.y / 2 + textClearance) + } else { + safeLabelOffset.y += labelOffset.y >= 0 + ? textBounds.extents.y / 2 + textClearance + : -(textBounds.extents.y / 2 + textClearance) + } + + label.position = (start + end) / 2 + safeLabelOffset + label.addChild(text) + container.addChild(label) + return container + } + + /// Aktualisiert die Beschriftung einer vorhandenen Tankmaß-Linie. + static func updateDimensionLabel( + _ entity: Entity, + dimension: TankDimension, + distance: Float + ) { + guard let label = entity.findEntity(named: labelName) else { return } + label.children.forEach { $0.removeFromParent() } + label.addChild(makeText("\(dimension.title): \(String(format: "%.2f m", distance))")) + } + /// 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( @@ -71,18 +150,21 @@ enum MeasurementEntityFactory { } /// Leerer Label-Halter (Billboard-Anker). Der Text wird als zentriertes Kind eingehängt. - static func makeLabelHolder() -> Entity { + static func makeLabelHolder(usesBillboard: Bool = true) -> Entity { let holder = Entity() holder.name = labelName + if usesBillboard { + holder.components.set(BillboardComponent()) + } return holder } /// Zentriertes Text-Entity für einen Label-Halter. - static func makeText(_ string: String) -> ModelEntity { + static func makeText(_ string: String, fontSize: Float = 0.14) -> ModelEntity { let mesh = MeshResource.generateText( string, extrusionDepth: 0.001, - font: .systemFont(ofSize: CGFloat(labelHeight), weight: .semibold) + font: .systemFont(ofSize: CGFloat(fontSize), weight: .semibold) ) var material = UnlitMaterial(color: .white) material.faceCulling = .none @@ -92,7 +174,11 @@ enum MeasurementEntityFactory { } /// Position des Labels (Streckenmitte, leicht angehoben). - static func labelPosition(from start: SIMD3, to end: SIMD3) -> SIMD3 { - (start + end) / 2 + SIMD3(0, labelYOffset, 0) + static func labelPosition( + from start: SIMD3, + to end: SIMD3, + verticalOffset: Float = 0.105 + ) -> SIMD3 { + (start + end) / 2 + SIMD3(0, verticalOffset, 0) } } diff --git a/GasTankAR/Views/TankAR/MeasurementListView.swift b/GasTankAR/Views/TankAR/MeasurementListView.swift index 2df343b..1eabef6 100644 --- a/GasTankAR/Views/TankAR/MeasurementListView.swift +++ b/GasTankAR/Views/TankAR/MeasurementListView.swift @@ -16,18 +16,23 @@ struct MeasurementListView: View { 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) + List { + Section("Tankmaße") { + Toggle("Alle anzeigen", isOn: allDimensionsBinding) + + ForEach(TankDimension.allCases) { dimension in + Toggle( + dimension.title, + isOn: dimensionBinding(for: dimension) + ) } - } else { - List { + } + + Section("Eigene Strecken") { + if controller.measurements.isEmpty { + Text("Noch keine eigenen Strecken angelegt.") + .foregroundStyle(.secondary) + } else { ForEach(controller.measurements) { measurement in HStack { VStack(alignment: .leading, spacing: 2) { @@ -56,4 +61,22 @@ struct MeasurementListView: View { } .presentationDetents([.medium, .large]) } + + private var allDimensionsBinding: Binding { + Binding( + get: { TankDimension.allCases.allSatisfy { controller.visibleTankDimensions.contains($0) } }, + set: { isVisible in + for dimension in TankDimension.allCases { + controller.setTankDimension(dimension, visible: isVisible) + } + } + ) + } + + private func dimensionBinding(for dimension: TankDimension) -> Binding { + Binding( + get: { controller.visibleTankDimensions.contains(dimension) }, + set: { controller.setTankDimension(dimension, visible: $0) } + ) + } } diff --git a/GasTankAR/Views/TankAR/PipeRoute.swift b/GasTankAR/Views/TankAR/PipeRoute.swift index d402e3b..d362651 100644 --- a/GasTankAR/Views/TankAR/PipeRoute.swift +++ b/GasTankAR/Views/TankAR/PipeRoute.swift @@ -10,16 +10,56 @@ 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 { +struct PipeRoute: Codable { private(set) var waypoints: [SIMD3] = [] + private(set) var removedSegmentIndices: Set = [] var count: Int { waypoints.count } - /// Gesamtlänge des Pfads über alle Segmente in Metern. + /// Gesamtlänge des Pfads über alle aktiven 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) } + .enumerated() + .filter { !removedSegmentIndices.contains($0.offset) } + .reduce(0) { $0 + simd_distance($1.element.0, $1.element.1) } + } + + func isSegmentRemoved(at index: Int) -> Bool { + removedSegmentIndices.contains(index) + } + + mutating func removeSegmentAndOrphanedWaypoints(at index: Int) { + guard index >= 0, index < waypoints.count - 1 else { return } + removedSegmentIndices.insert(index) + + var connected = Array(repeating: false, count: waypoints.count) + for segmentIndex in 0..<(waypoints.count - 1) + where !removedSegmentIndices.contains(segmentIndex) { + connected[segmentIndex] = true + connected[segmentIndex + 1] = true + } + + let oldToNew = connected.enumerated().reduce(into: [Int: Int]()) { result, item in + if item.element { + result[item.offset] = result.count + } + } + + let oldRemovedSegments = removedSegmentIndices + waypoints = waypoints.enumerated() + .filter { connected[$0.offset] } + .map(\.element) + + removedSegmentIndices = Set( + oldRemovedSegments.compactMap { oldIndex in + guard let newIndex = oldToNew[oldIndex], + oldToNew[oldIndex + 1] != nil else { + return nil + } + return newIndex + } + ) } var formattedLength: String { @@ -32,11 +72,20 @@ struct PipeRoute { @discardableResult mutating func removeLast() -> SIMD3? { - waypoints.isEmpty ? nil : waypoints.removeLast() + guard !waypoints.isEmpty else { return nil } + let removedIndex = waypoints.count - 2 + removedSegmentIndices.remove(removedIndex) + removedSegmentIndices = Set( + removedSegmentIndices.map { index in + index > removedIndex ? index - 1 : index + } + ) + return waypoints.removeLast() } mutating func clear() { waypoints.removeAll() + removedSegmentIndices.removeAll() } } diff --git a/GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift b/GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift index b1b5239..98ac70c 100644 --- a/GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift +++ b/GasTankAR/Views/TankAR/PipeRouteEntityFactory.swift @@ -31,9 +31,18 @@ enum PipeRouteEntityFactory { static func makeSegment(from start: SIMD3, to end: SIMD3) -> ModelEntity { let segment = makeSegmentBase() configureSegment(segment, from: start, to: end) + segment.collision = CollisionComponent(shapes: [ + ShapeResource.generateBox(size: SIMD3(0.08, 0.08, 1)) + ]) + segment.components.set(InputTargetComponent()) return segment } + static func setSegmentColor(_ segment: Entity, color: UIColor) { + guard let model = segment as? ModelEntity else { return } + model.model?.materials = [UnlitMaterial(color: color)] + } + /// Basis-Segment der Länge 1 m (entlang +Z) – für die Live-Vorschau wiederverwendbar. static func makeSegmentBase() -> ModelEntity { ModelEntity( diff --git a/GasTankAR/Views/TankAR/SafetyZone.swift b/GasTankAR/Views/TankAR/SafetyZone.swift index 5aa2e50..3811eb1 100644 --- a/GasTankAR/Views/TankAR/SafetyZone.swift +++ b/GasTankAR/Views/TankAR/SafetyZone.swift @@ -91,11 +91,24 @@ enum SafetyZoneFactory { let bounds = text.visualBounds(relativeTo: nil) text.position = -bounds.center + let radialDirection = SIMD3(sin(angle), 0, cos(angle)) + let tangentDirection = SIMD3(cos(angle), 0, -sin(angle)) 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)) + + // Wie bei der Längenbeschriftung: in die Bodenebene drehen und tangential + // zum Kreis ausrichten. Der Text steht radial außerhalb des Rings. + let textClearance: Float = 0.015 + let radialOffset = bounds.extents.y / 2 + textClearance + holder.position = radialDirection * (zone.radius + radialOffset) + holder.position.y = 0.01 + holder.orientation = simd_quatf( + from: SIMD3(1, 0, 0), + to: tangentDirection + ) * simd_quatf( + angle: -.pi / 2, + axis: tangentDirection + ) return holder } diff --git a/GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift b/GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift index ef685c3..e8ba0dc 100644 --- a/GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift +++ b/GasTankAR/Views/TankAR/TankDepthFadeMaterial.swift @@ -7,6 +7,7 @@ import RealityKit import Metal +import UIKit /// Kapselt das Höhen-Fade-CustomMaterial für den unterirdischen Tank: /// verblasst die Opacity anhand der Welt-Höhe (Kopf opak, vergrabener Teil transparent). @@ -22,7 +23,9 @@ enum TankDepthFadeMaterial { return CustomMaterial.SurfaceShader(named: "tankDepthFadeSurface", in: library) }() - /// Ersetzt die Materialien der Entity (rekursiv) durch das Fade-CustomMaterial. + /// Ersetzt die Originalmaterialien rekursiv durch CustomMaterial-Versionen, + /// wobei Texturen und Farben des USDZ erhalten bleiben. Schatten werden für + /// transparente Model-Entities separat deaktiviert. static func apply(to entity: Entity) { guard let shader else { return } forEachModelEntity(entity) { modelEntity in @@ -36,16 +39,26 @@ enum TankDepthFadeMaterial { return custom } modelEntity.model = component + modelEntity.components.set(GroundingShadowComponent(castsShadow: false)) + modelEntity.components.set(DynamicLightShadowComponent(castsShadow: false)) } } /// Aktualisiert die Boden-Höhe (Welt-Y) in bereits angewandten Fade-Materialien. - static func updateGroundY(_ groundY: Float, on entity: Entity) { + static func updateGroundY( + _ groundY: Float, + belowAlpha: Float = 0.3, + 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(groundY, band, belowAlpha, 0) + var customValue = custom.custom.value + customValue.x = groundY + customValue.y = band + customValue.z = belowAlpha + custom.custom.value = customValue return custom } modelEntity.model = component diff --git a/GasTankAR/Views/TankAR/TankEntityFactory.swift b/GasTankAR/Views/TankAR/TankEntityFactory.swift index 15db2ea..dcfc626 100644 --- a/GasTankAR/Views/TankAR/TankEntityFactory.swift +++ b/GasTankAR/Views/TankAR/TankEntityFactory.swift @@ -8,13 +8,14 @@ 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. +/// Baut die darzustellende Tank-Entity: löst das zum Tank passende 3D-Modell über +/// den `TankCatalog` auf, lädt/cached es, richtet es je nach Aufstellungsart aus, +/// erzeugt die Kollisionsform und – bei unterirdischer Aufstellung – die Grube. @MainActor final class TankEntityFactory { - /// Name des USDZ-Assets im Bundle. - private static let modelName = "GasTank" + /// Stabiler Name, damit spätere Berechnungen ausschließlich die Modell-Entity vermessen. + static let modelEntityName = "TankModel" + /// 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, @@ -22,27 +23,47 @@ final class TankEntityFactory { private static let pitMargin: Float = 0.4 /// Zusätzliche Tiefe der Grubensohle unter der Tankunterkante (in Metern). private static let pitFloorClearance: Float = 0.1 + /// Zusätzliche weiße Füllbeleuchtung für oberirdische Tanks im AR-Modus. + private static let aboveGroundLightIntensity: Float = 6_000 + private let aboveGroundLightMultiplier: Float - /// Vorab geladenes Modell; wird pro Platzierung geklont. - private var loadedModel: Entity? + /// Katalog zur Auflösung der Auswahl auf ein konkretes Modell. + private let catalog: any TankCatalogProviding - /// Lädt das USDZ asynchron vor (blockiert den Main-Actor nicht). - func preload() { - guard loadedModel == nil else { return } + /// Vorab geladene Modelle, zwischengespeichert pro Ressourcenname; werden pro + /// Platzierung geklont. + private var loadedModels: [String: Entity] = [:] + + init( + catalog: any TankCatalogProviding = TankCatalog.shared, + aboveGroundLightMultiplier: Float = 1.0 + ) { + self.catalog = catalog + self.aboveGroundLightMultiplier = aboveGroundLightMultiplier + } + + /// Lädt das zum Tank passende Modell asynchron vor (blockiert den Main-Actor nicht). + func preload(_ tank: Tank) { + guard let name = catalog.resolvedDefinition(for: tank)?.modelResourceName else { return } + guard loadedModels[name] == nil else { return } Task { @MainActor in - self.loadedModel = try? await Entity(named: Self.modelName) + if let entity = try? await Entity(named: name) { + self.loadedModels[name] = entity + } } } /// Erzeugt den manipulierbaren Container (mit Kollision fürs Gesten-Hit-Testing). func makeContainer(for tank: Tank) -> ModelEntity { - let model = loadModelClone() + let model = loadModelClone(for: tank) + model.name = Self.modelEntityName 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 + addAboveGroundFillLight(to: container) case .belowGround: // Modell absenken, sodass nur der obere Teil („Kopf") herausragt … @@ -51,12 +72,18 @@ final class TankEntityFactory { 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 - )) + if tank.showLidOnly { + // Der Domschacht oberhalb der Bodenhöhe bleibt opak; der + // vergrabene Tankkörper wird über den Höhen-Shader transparent. + TankDepthFadeMaterial.apply(to: model) + } else { + 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) @@ -76,16 +103,36 @@ final class TankEntityFactory { 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) + /// Fügt eine neutrale gerichtete Füllbeleuchtung hinzu, damit oberirdische + /// Modelle bei dunkler Umgebung nicht unnötig grau dargestellt werden. + private func addAboveGroundFillLight(to container: Entity) { + let light = Entity(components: DirectionalLightComponent( + color: .white, + intensity: Self.aboveGroundLightIntensity * aboveGroundLightMultiplier + )) + light.orientation = simd_quatf(angle: -.pi / 4, axis: [1, 0, 0]) + container.addChild(light) + } + + /// Liefert einen Klon des zum Tank passenden Modells. Fällt auf synchrones + /// Laden bzw. – wenn kein Modell auflösbar/ladbar ist – auf einen einfachen + /// Cube zurück. + private func loadModelClone(for tank: Tank) -> Entity { + guard let name = catalog.resolvedDefinition(for: tank)?.modelResourceName else { + return fallbackModel() } - if let loadedModel { - return loadedModel.clone(recursive: true) + if loadedModels[name] == nil { + loadedModels[name] = try? Entity.load(named: name) } - return ModelEntity( + if let model = loadedModels[name] { + return model.clone(recursive: true) + } + return fallbackModel() + } + + /// Einfacher Ersatzkörper, falls kein Modell geladen werden kann. + private func fallbackModel() -> Entity { + ModelEntity( mesh: .generateBox(size: 0.3), materials: [SimpleMaterial(color: .white, isMetallic: false)] ) diff --git a/GasTankAR/Views/TankAR/TankManipulationPanel.swift b/GasTankAR/Views/TankAR/TankManipulationPanel.swift index 5115fc1..cb7d0c9 100644 --- a/GasTankAR/Views/TankAR/TankManipulationPanel.swift +++ b/GasTankAR/Views/TankAR/TankManipulationPanel.swift @@ -14,21 +14,22 @@ 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") + Group { + if controller.isUndergroundTank { + splitPanel + } else { + standardPanel } + } + .padding() + .background(.ultraThinMaterial, in: .rect(cornerRadius: 16)) + .fixedSize(horizontal: false, vertical: true) + } + + private var standardPanel: some View { + VStack(spacing: 14) { + dismissHandle + panelTitle HStack(alignment: .center, spacing: 24) { heightGroup @@ -36,13 +37,111 @@ struct TankManipulationPanel: View { rotateGroup } + actionButtons + } + } + + private var splitPanel: some View { + VStack(spacing: 14) { + dismissHandle + + HStack(alignment: .top, spacing: 0) { + VStack(spacing: 24) { + panelTitle + + HStack(alignment: .bottom, spacing: 24) { + heightGroup + moveGroup + rotateGroup + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + + Divider() + + VStack(spacing: 8) { + Toggle( + "Grube anzeigen", + isOn: Binding( + get: { controller.showsPit }, + set: { controller.setShowsPit($0) } + ) + ) + .toggleStyle(.switch) + + transparencyControl + .disabled(controller.showsPit) + + Spacer(minLength: 0) + + actionButtons + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + } + } + .padding(.bottom, 10) + } + + private var dismissHandle: some View { + Capsule() + .fill(.secondary.opacity(0.7)) + .frame(width: 36, height: 4) + .padding(.vertical, 2) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 10) + .onEnded { value in + if value.translation.height > 60 { + controller.deselectTank() + } + } + ) + .accessibilityLabel("Zum Schließen nach unten wischen") + } + + private var panelTitle: some View { + Text("Tank anpassen") + .font(.headline) + } + + private var actionButtons: some View { + HStack(spacing: 10) { + Button("Skalierung zurücksetzen", systemImage: "arrow.counterclockwise") { + controller.resetTankScale() + } + .buttonStyle(.bordered) + Button("Neu platzieren", systemImage: "arrow.up.and.down.and.arrow.left.and.right") { controller.reposition() } .buttonStyle(.bordered) } - .padding() - .background(.ultraThinMaterial, in: .rect(cornerRadius: 16)) + } + + private var transparencyControl: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Transparenz unter Boden") + .font(.caption) + .foregroundStyle(.secondary) + Slider( + value: Binding( + get: { controller.undergroundTransparency }, + set: { controller.setUndergroundTransparency($0) } + ), + in: 0...1 + ) + HStack { + Text("sichtbar") + Spacer() + Text("transparent") + } + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(minWidth: 180) } // MARK: - Gruppen @@ -79,7 +178,7 @@ struct TankManipulationPanel: View { } private func controlGroup(title: String, @ViewBuilder content: () -> Content) -> some View { - VStack(spacing: 8) { + VStack(spacing: 10) { content() Text(title) .font(.caption) @@ -113,7 +212,7 @@ private struct HoldRepeatButton: View { var body: some View { Image(systemName: systemImage) .font(.title3) - .frame(width: 48, height: 48) + .frame(width: 54, height: 54) .background(.ultraThinMaterial, in: .circle) .contentShape(.circle) .gesture( diff --git a/GasTankAR/Views/TankAR/TankShaders.metal b/GasTankAR/Views/TankAR/TankShaders.metal index 78d426f..18395a2 100644 --- a/GasTankAR/Views/TankAR/TankShaders.metal +++ b/GasTankAR/Views/TankAR/TankShaders.metal @@ -27,16 +27,13 @@ void tankDepthFadeSurface(realitykit::surface_parameters params) 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. + // Originale USDZ-Farbe und Textur beibehalten. 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); + params.surface().set_base_color(color * tint); - // Roughness / Metallic durchreichen (wie PhysicallyBasedMaterial). half roughness = tex.roughness().sample(textureSampler, uv).r * constants.roughness_scale(); params.surface().set_roughness(roughness); @@ -50,6 +47,13 @@ void tankDepthFadeSurface(realitykit::surface_parameters params) float band = max(custom.y, 1e-3); float belowAlpha = custom.z; + // Bei maximaler Transparenz wird alles unterhalb der Bodenhöhe + // vollständig ausgeblendet – nicht nur abgeschwächt. + if (belowAlpha <= 0.001 && worldPosition.y < groundY) { + params.surface().set_opacity(half(0.0)); + return; + } + float t = clamp((groundY - worldPosition.y) / band, 0.0, 1.0); float alpha = mix(1.0, belowAlpha, t); params.surface().set_opacity(half(alpha)); diff --git a/GasTankAR/Views/TankCatalogEnvironment.swift b/GasTankAR/Views/TankCatalogEnvironment.swift new file mode 100644 index 0000000..c06fc2b --- /dev/null +++ b/GasTankAR/Views/TankCatalogEnvironment.swift @@ -0,0 +1,28 @@ +// +// TankCatalogEnvironment.swift +// GasTankAR +// +// Created by Dennis Nemec on 14.09.26. +// + +import SwiftUI + +private struct TankCatalogKey: EnvironmentKey { + static let defaultValue: any TankCatalogProviding = TankCatalog.shared +} + +extension EnvironmentValues { + /// Der in der View-Hierarchie verfügbare Tank-Katalog. Standard: der aus dem + /// Bundle geladene `TankCatalog.shared`; in Tests/Previews überschreibbar. + var tankCatalog: any TankCatalogProviding { + get { self[TankCatalogKey.self] } + set { self[TankCatalogKey.self] = newValue } + } +} + +extension View { + /// Injiziert einen Tank-Katalog in die View-Hierarchie. + func tankCatalog(_ catalog: any TankCatalogProviding) -> some View { + environment(\.tankCatalog, catalog) + } +} diff --git a/GasTankAR/Views/TankEditor.swift b/GasTankAR/Views/TankEditor.swift index 8d0fb28..42f5eae 100644 --- a/GasTankAR/Views/TankEditor.swift +++ b/GasTankAR/Views/TankEditor.swift @@ -7,21 +7,33 @@ import SwiftUI -/// Wiederverwendbare Auswahl für die Tank-Eigenschaften (Größe, Farbe, -/// Aufstellungsart und halbtransparente Darstellung). +/// Wiederverwendbare Auswahl für die Tank-Eigenschaften (Größe, Farbe +/// und Aufstellungsart). Die verfügbaren Optionen +/// stammen dynamisch aus dem `TankCatalog`. struct TankEditor: View { @Binding var tank: Tank + @Environment(\.tankCatalog) private var catalog + + /// Für die aktuelle Aufstellungsart verfügbare Größen. + private var availableSizes: [TankSize] { + catalog.sizes(for: tank.placement) + } + + /// Für aktuelle Größe & Aufstellungsart verfügbare Farben. + private var availableColors: [TankColor] { + catalog.colors(for: tank.size, placement: tank.placement) + } var body: some View { Picker("Größe", selection: $tank.size) { - ForEach(TankSize.allCases) { size in - Text(size.displayName).tag(size) + ForEach(availableSizes) { size in + Text(catalog.displayName(for: size)).tag(size) } } Picker("Farbe", selection: $tank.color) { - ForEach(TankColor.allCases) { color in - Text(color.displayName).tag(color) + ForEach(availableColors) { color in + Text(catalog.displayName(for: color)).tag(color) } } @@ -29,7 +41,7 @@ struct TankEditor: View { Text("Aufstellungsart") HStack(spacing: 12) { - ForEach(TankPlacement.allCases) { placement in + ForEach(catalog.placements) { placement in PlacementOption( placement: placement, isSelected: tank.placement == placement @@ -41,14 +53,24 @@ struct TankEditor: View { } .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 - } - } + // Auswahl bei jeder Änderung auf eine gültige Katalog-Variante bringen … + .onChange(of: tank.placement) { _, _ in normalizeSelection() } + .onChange(of: tank.size) { _, _ in normalizeSelection() } + // … und Altdaten beim Öffnen selbstheilen. + .onAppear { normalizeSelection() } + } + + /// Sorgt dafür, dass die aktuelle Auswahl einer existierenden Definition + /// entspricht, und setzt die „nur Deckel"-Option außerhalb unterirdischer + /// Tanks zurück. + private func normalizeSelection() { + let normalized = catalog.normalized(tank) + if normalized != tank { + tank = normalized + } + if tank.placement != .belowGround && tank.showLidOnly { + tank.showLidOnly = false + } } } @@ -87,6 +109,7 @@ private struct PlacementOption: View { lineWidth: isSelected ? 2 : 1 ) } + .contentShape(.rect(cornerRadius: 12)) } .buttonStyle(.plain) } diff --git a/GasTankAR/Views/TankPreview.swift b/GasTankAR/Views/TankPreview.swift new file mode 100644 index 0000000..98b4896 --- /dev/null +++ b/GasTankAR/Views/TankPreview.swift @@ -0,0 +1,106 @@ +// +// TankPreview.swift +// GasTankAR +// +// Created by Dennis Nemec on 14.09.26. +// + +import SwiftUI +import SceneKit + +/// 3D-Vorschau des ausgewählten Tanks. Lädt das passende USDZ-Modell aus dem +/// App-Bundle und stellt es interaktiv (drehbar) dar. Die Darstellung +/// aktualisiert sich automatisch, sobald sich die Tank-Konfiguration ändert. +struct TankPreview: View { + let tank: Tank + @Environment(\.tankCatalog) private var catalog + + /// Zum aktuellen Tank passende Katalog-Definition (mit `modelPath`). + private var definition: TankDefinition? { + catalog.resolvedDefinition(for: tank) + } + + var body: some View { + Group { + if let definition { + TankSceneView( + resourceName: definition.modelResourceName, + fileExtension: definition.modelFileExtension + ) + .overlay(alignment: .bottomTrailing) { + Label("Zum Drehen ziehen", systemImage: "hand.draw") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(6) + } + } else { + ContentUnavailableView( + "Kein Modell verfügbar", + systemImage: "cube.transparent" + ) + } + } + .frame(height: 220) + .frame(maxWidth: .infinity) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(.rect(cornerRadius: 12)) + } +} + +/// SceneKit-Wrapper, der ein 3D-Modell aus dem Bundle anzeigt und bei +/// Namensänderung neu lädt. +private struct TankSceneView: UIViewRepresentable { + let resourceName: String + let fileExtension: String + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> SCNView { + let view = SCNView() + view.autoenablesDefaultLighting = true + view.allowsCameraControl = true + view.backgroundColor = .clear + view.antialiasingMode = .multisampling4X + load(into: view, context: context) + return view + } + + func updateUIView(_ uiView: SCNView, context: Context) { + guard context.coordinator.loadedResourceName != resourceName else { return } + load(into: uiView, context: context) + } + + /// Lädt das aktuelle Modell und merkt sich den geladenen Namen, damit + /// unnötiges Neuladen vermieden wird. + private func load(into view: SCNView, context: Context) { + context.coordinator.loadedResourceName = resourceName + view.scene = Self.scene(resourceName: resourceName, fileExtension: fileExtension) + } + + /// Erzeugt die Szene für ein Modell im Bundle. Fällt auf eine leere Szene + /// zurück, falls die Datei nicht gefunden wird. + private static func scene(resourceName: String, fileExtension: String) -> SCNScene { + guard let url = Bundle.main.url(forResource: resourceName, withExtension: fileExtension), + let scene = try? SCNScene(url: url) else { + return SCNScene() + } + return scene + } + + /// Behält den zuletzt geladenen Ressourcennamen, um Neuladen zu vermeiden. + final class Coordinator { + var loadedResourceName: String? + } +} + +#Preview { + @Previewable @State var tank = Tank() + return Form { + Section("Tankauswahl") { + TankPreview(tank: tank) + TankEditor(tank: $tank) + } + } +} diff --git a/GasTankAR/Views/TankSelectionSection.swift b/GasTankAR/Views/TankSelectionSection.swift new file mode 100644 index 0000000..ded5b6d --- /dev/null +++ b/GasTankAR/Views/TankSelectionSection.swift @@ -0,0 +1,30 @@ +// +// TankSelectionSection.swift +// GasTankAR +// +// Created by Dennis Nemec on 14.09.26. +// + +import SwiftUI + +/// Gemeinsame „Tankauswahl"-Section mit 3D-Vorschau und Editor. Wird sowohl beim +/// Erstellen (`ProjectCreateView`) als auch beim Bearbeiten (`ProjectDetailView`) +/// eines Projekts verwendet, damit Änderungen an einer Stelle gepflegt werden. +struct TankSelectionSection: View { + @Binding var tank: Tank + + var body: some View { + Section("Tankauswahl") { + TankPreview(tank: tank) + + TankEditor(tank: $tank) + } + } +} + +#Preview { + @Previewable @State var tank = Tank() + return Form { + TankSelectionSection(tank: $tank) + } +} diff --git a/GasTankARTests/TankCatalogTests.swift b/GasTankARTests/TankCatalogTests.swift new file mode 100644 index 0000000..13511a9 --- /dev/null +++ b/GasTankARTests/TankCatalogTests.swift @@ -0,0 +1,134 @@ +// +// TankCatalogTests.swift +// GasTankARTests +// +// Created by Dennis Nemec on 14.09.26. +// + +import Testing +import Foundation +@testable import GasTankAR + +/// Prüft das Laden/Decodieren des Tank-Katalogs aus JSON sowie die abgeleiteten +/// Abfragen (verfügbare Größen/Farben, Auflösung & Normalisierung der Auswahl). +struct TankCatalogTests { + + // Kleiner, in sich konsistenter Beispielkatalog für Query-Tests. + private static let sampleJSON = """ + { + "sizeDisplayNames": { "t1_2": "1,2 t", "t2_9": "2,9 t" }, + "colorDisplayNames": { "white": "Weiß", "green": "Hellgrün", "olive": "Olivgrün" }, + "tanks": [ + { "size": "t1_2", "color": "white", "placement": "aboveGround", "modelPath": "DG-2700_wh.usdz" }, + { "size": "t1_2", "color": "green", "placement": "aboveGround", "modelPath": "DG-2700_mi.usdz" }, + { "size": "t1_2", "color": "olive", "placement": "belowGround", "modelPath": "DG-2700_ol.usdz" }, + { "size": "t2_9", "color": "white", "placement": "aboveGround", "modelPath": "DG-6400_wh.usdz" }, + { "size": "t2_9", "color": "olive", "placement": "belowGround", "modelPath": "DG-6400_ol.usdz" } + ] + } + """ + + private static func sampleCatalog() throws -> TankCatalog { + let loader = DataTankCatalogLoader(data: Data(sampleJSON.utf8)) + return TankCatalog(file: try loader.load()) + } + + // MARK: - Decoding + + @Test func decodesAllFieldsFromJSON() throws { + let catalog = try Self.sampleCatalog() + let def = try #require(catalog.definitions.first) + + #expect(def.size == .t1_2) + #expect(def.color == .white) + #expect(def.placement == .aboveGround) + #expect(def.modelPath == "DG-2700_wh.usdz") + #expect(def.modelResourceName == "DG-2700_wh") + #expect(def.modelFileExtension == "usdz") + } + + @Test func decodesDisplayNameMappings() throws { + let catalog = try Self.sampleCatalog() + #expect(catalog.sizeDisplayNames["t1_2"] == "1,2 t") + #expect(catalog.colorDisplayNames["olive"] == "Olivgrün") + } + + @Test func displayNameFallsBackToRawValueWhenMissing() { + // Katalog ohne Mappings → Anzeigename ist der rawValue. + let catalog = TankCatalog(definitions: []) + #expect(catalog.displayName(for: TankSize.t2_1) == "t2_1") + #expect(catalog.displayName(for: TankColor.green) == "green") + } + + @Test func missingResourceThrows() { + let loader = BundleTankCatalogLoader( + bundle: .main, + resourceName: "Datei-die-nicht-existiert", + resourceExtension: "json" + ) + #expect(throws: TankCatalogLoaderError.self) { + _ = try loader.load() + } + } + + @Test func emptyCatalogWhenLoaderFails() { + let loader = BundleTankCatalogLoader(resourceName: "gibt-es-nicht") + let catalog = TankCatalog(loader: loader) + #expect(catalog.definitions.isEmpty) + } + + // MARK: - Abgeleitete Abfragen + + @Test func availableColorsDependOnPlacement() throws { + let catalog = try Self.sampleCatalog() + #expect(catalog.colors(for: .t1_2, placement: .aboveGround) == [.white, .green]) + #expect(catalog.colors(for: .t1_2, placement: .belowGround) == [.olive]) + } + + @Test func availableSizesPerPlacement() throws { + let catalog = try Self.sampleCatalog() + #expect(catalog.sizes(for: .aboveGround) == [.t1_2, .t2_9]) + #expect(catalog.sizes(for: .belowGround) == [.t1_2, .t2_9]) + } + + @Test func placementsPreserveOrder() throws { + let catalog = try Self.sampleCatalog() + #expect(catalog.placements == [.aboveGround, .belowGround]) + } + + @Test func displayNamesResolveFromCatalog() throws { + let catalog = try Self.sampleCatalog() + #expect(catalog.displayName(for: TankSize.t2_9) == "2,9 t") + #expect(catalog.displayName(for: TankColor.olive) == "Olivgrün") + } + + // MARK: - Auflösung & Normalisierung + + @Test func definitionMatchesExactSelection() throws { + let catalog = try Self.sampleCatalog() + let tank = Tank(size: .t2_9, color: .olive, placement: .belowGround) + #expect(catalog.definition(for: tank)?.modelPath == "DG-6400_ol.usdz") + } + + @Test func normalizesInconsistentColorForPlacement() throws { + let catalog = try Self.sampleCatalog() + // Weiß gibt es unterirdisch nicht → sollte auf Olivgrün korrigiert werden. + let tank = Tank(size: .t2_9, color: .white, placement: .belowGround) + let normalized = catalog.normalized(tank) + #expect(normalized.color == .olive) + #expect(catalog.definition(for: normalized) != nil) + } + + @Test func resolvedDefinitionHealsLegacySelection() throws { + let catalog = try Self.sampleCatalog() + // Altdaten: unterirdisch + weiß → muss trotzdem das Olivgrün-Modell liefern. + let legacy = Tank(size: .t2_9, color: .white, placement: .belowGround) + #expect(catalog.resolvedDefinition(for: legacy)?.modelPath == "DG-6400_ol.usdz") + } + + @Test func validSelectionStaysUnchanged() throws { + let catalog = try Self.sampleCatalog() + let tank = Tank(size: .t1_2, color: .green, placement: .aboveGround) + #expect(catalog.normalized(tank) == tank) + } +} diff --git a/GasTankARTests/TankModelSelectionTests.swift b/GasTankARTests/TankModelSelectionTests.swift new file mode 100644 index 0000000..dbb1fa7 --- /dev/null +++ b/GasTankARTests/TankModelSelectionTests.swift @@ -0,0 +1,74 @@ +// +// TankModelSelectionTests.swift +// GasTankARTests +// +// Created by Dennis Nemec on 14.09.26. +// + +import Testing +import Foundation +import RealityKit +@testable import GasTankAR + +/// Prüft, dass für eine Tank-Auswahl im AR-Modus die richtige Datei geladen wird: +/// Der Katalog aus dem echten App-Bundle (`Tanks.json`) muss vollständig sein, +/// jede referenzierte Datei muss existieren und die AR-Factory muss das echte +/// Modell (statt des Ersatzkörpers) erzeugen. +struct TankModelSelectionTests { + + /// Katalog, geladen aus dem realen App-Bundle (dem Test-Host). + private static func bundleCatalog() -> TankCatalog { + let bundle = Bundle(for: TankEntityFactory.self) + return TankCatalog(loader: BundleTankCatalogLoader(bundle: bundle)) + } + + // MARK: - Katalog aus dem Bundle + + @Test func bundleCatalogContainsAllNineVariants() { + let catalog = Self.bundleCatalog() + #expect(catalog.definitions.count == 9) + // 3 Größen, jeweils oberirdisch weiß/grün + unterirdisch oliv. + #expect(catalog.sizes(for: .aboveGround).count == 3) + #expect(catalog.sizes(for: .belowGround).count == 3) + } + + @Test func everyModelFileExistsInBundle() { + let bundle = Bundle(for: TankEntityFactory.self) + let catalog = Self.bundleCatalog() + + for def in catalog.definitions { + let url = bundle.url(forResource: def.modelResourceName, withExtension: def.modelFileExtension) + #expect(url != nil, "Modell-Datei fehlt im Bundle: \(def.modelPath)") + } + } + + // MARK: - Auswahl → richtige Datei + + @Test func aboveGroundColorsMapToExpectedFiles() { + let catalog = Self.bundleCatalog() + let white = catalog.resolvedDefinition(for: Tank(size: .t1_2, color: .white, placement: .aboveGround)) + let green = catalog.resolvedDefinition(for: Tank(size: .t1_2, color: .green, placement: .aboveGround)) + #expect(white?.modelPath == "DG-2700_wh.usdz") + #expect(green?.modelPath == "DG-2700_mi.usdz") + } + + @Test func belowGroundMapsToUndergroundFile() { + let catalog = Self.bundleCatalog() + let def = catalog.resolvedDefinition(for: Tank(size: .t2_9, color: .olive, placement: .belowGround)) + #expect(def?.modelPath == "DG-6400_ol.usdz") + } + + // MARK: - AR-Factory lädt das echte Modell (kein Ersatzkörper) + + @MainActor + @Test func factoryLoadsRealModelNotFallbackCube() { + // Repräsentative oberirdische Konfiguration: der Tank ist deutlich länger + // als der 0,3 m große Ersatzwürfel, der nur bei fehlendem Modell entsteht. + let tank = Tank(size: .t2_9, color: .white, placement: .aboveGround) + let factory = TankEntityFactory(catalog: Self.bundleCatalog()) + let container = factory.makeContainer(for: tank) + + let bounds = container.visualBounds(relativeTo: nil) + #expect(bounds.extents.x > 0.5, "Es wurde vermutlich der Ersatzwürfel geladen statt des echten Modells.") + } +} diff --git a/drachengas_tanks_usdz/DG-2700_mi.usdz b/drachengas_tanks_usdz/DG-2700_mi.usdz new file mode 100644 index 0000000..bc86c5a Binary files /dev/null and b/drachengas_tanks_usdz/DG-2700_mi.usdz differ diff --git a/drachengas_tanks_usdz/DG-2700_ol.usdz b/drachengas_tanks_usdz/DG-2700_ol.usdz new file mode 100644 index 0000000..7ca1633 Binary files /dev/null and b/drachengas_tanks_usdz/DG-2700_ol.usdz differ diff --git a/drachengas_tanks_usdz/DG-2700_wh.usdz b/drachengas_tanks_usdz/DG-2700_wh.usdz new file mode 100644 index 0000000..779abc9 Binary files /dev/null and b/drachengas_tanks_usdz/DG-2700_wh.usdz differ diff --git a/drachengas_tanks_usdz/DG-4850_mi.usdz b/drachengas_tanks_usdz/DG-4850_mi.usdz new file mode 100644 index 0000000..bb98ba4 Binary files /dev/null and b/drachengas_tanks_usdz/DG-4850_mi.usdz differ diff --git a/drachengas_tanks_usdz/DG-4850_ol.usdz b/drachengas_tanks_usdz/DG-4850_ol.usdz new file mode 100644 index 0000000..02233af Binary files /dev/null and b/drachengas_tanks_usdz/DG-4850_ol.usdz differ diff --git a/drachengas_tanks_usdz/DG-4850_wh.usdz b/drachengas_tanks_usdz/DG-4850_wh.usdz new file mode 100644 index 0000000..ee062c0 Binary files /dev/null and b/drachengas_tanks_usdz/DG-4850_wh.usdz differ diff --git a/drachengas_tanks_usdz/DG-6400_mi.usdz b/drachengas_tanks_usdz/DG-6400_mi.usdz new file mode 100644 index 0000000..df62a84 Binary files /dev/null and b/drachengas_tanks_usdz/DG-6400_mi.usdz differ diff --git a/drachengas_tanks_usdz/DG-6400_mi_M.usdz b/drachengas_tanks_usdz/DG-6400_mi_M.usdz new file mode 100644 index 0000000..fc74e0b Binary files /dev/null and b/drachengas_tanks_usdz/DG-6400_mi_M.usdz differ diff --git a/drachengas_tanks_usdz/DG-6400_ol.usdz b/drachengas_tanks_usdz/DG-6400_ol.usdz new file mode 100644 index 0000000..4f9e765 Binary files /dev/null and b/drachengas_tanks_usdz/DG-6400_ol.usdz differ diff --git a/drachengas_tanks_usdz/DG-6400_ol_M.usdz b/drachengas_tanks_usdz/DG-6400_ol_M.usdz new file mode 100644 index 0000000..54bc89c Binary files /dev/null and b/drachengas_tanks_usdz/DG-6400_ol_M.usdz differ diff --git a/drachengas_tanks_usdz/DG-6400_wh.usdz b/drachengas_tanks_usdz/DG-6400_wh.usdz new file mode 100644 index 0000000..b62fb82 Binary files /dev/null and b/drachengas_tanks_usdz/DG-6400_wh.usdz differ diff --git a/drachengas_tanks_usdz/DG-6400_wh_M.usdz b/drachengas_tanks_usdz/DG-6400_wh_M.usdz new file mode 100644 index 0000000..7e654c2 Binary files /dev/null and b/drachengas_tanks_usdz/DG-6400_wh_M.usdz differ diff --git a/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/ProjectData/main.json b/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/ProjectData/main.json new file mode 100644 index 0000000..4d184e0 --- /dev/null +++ b/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/ProjectData/main.json @@ -0,0 +1,9 @@ +{ + "pathsToIds" : { + "\/Untitled\/Sources\/Untitled\/Untitled.rkassets\/Scene.usda" : "1DEDB0B0-1F79-405C-9A35-ED4EC1C658C3" + }, + "projectID" : 7040775203090935802, + "uuidToIntID" : { + "1DEDB0B0-1F79-405C-9A35-ED4EC1C658C3" : -7314685049933580595 + } +} \ No newline at end of file diff --git a/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/SceneMetadataList.json b/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/SceneMetadataList.json new file mode 100644 index 0000000..4b4d4e0 --- /dev/null +++ b/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/SceneMetadataList.json @@ -0,0 +1,44 @@ +{ + "1DEDB0B0-1F79-405C-9A35-ED4EC1C658C3" : { + "objectMetadataList" : [ + [ + "1DEDB0B0-1F79-405C-9A35-ED4EC1C658C3", + "Root", + "DG_2700_mi", + "DG_2700_mi" + ], + { + "isExpanded" : true, + "isLocked" : false + }, + [ + "1DEDB0B0-1F79-405C-9A35-ED4EC1C658C3", + "Root" + ], + { + "isExpanded" : true, + "isLocked" : false + }, + [ + "1DEDB0B0-1F79-405C-9A35-ED4EC1C658C3", + "Root", + "DG_2700_mi", + "env_light" + ], + { + "disallowsFraming" : true, + "isExpanded" : false, + "isLocked" : false + }, + [ + "1DEDB0B0-1F79-405C-9A35-ED4EC1C658C3", + "Root", + "DG_2700_mi" + ], + { + "isExpanded" : true, + "isLocked" : false + } + ] + } +} \ No newline at end of file diff --git a/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/Settings.rcprojectdata b/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/Settings.rcprojectdata new file mode 100644 index 0000000..8f828a7 --- /dev/null +++ b/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/Settings.rcprojectdata @@ -0,0 +1,17 @@ +{ + "cameraPresets" : { + + }, + "secondaryToolbarData" : { + "isGridVisible" : true + }, + "unitDefaults" : { + "kg" : "g", + "kg⋅m²" : "kg⋅m²", + "m" : "cm", + "m\/s" : "m\/s", + "m\/s²" : "m\/s²", + "s" : "s", + "°" : "°" + } +} \ No newline at end of file diff --git a/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/dennis.rcuserdata b/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/dennis.rcuserdata new file mode 100644 index 0000000..50094f8 --- /dev/null +++ b/drachengas_tanks_usdz/Untitled/Package.realitycomposerpro/WorkspaceData/dennis.rcuserdata @@ -0,0 +1,236 @@ +{ + "advancedEditorSelectedIdentifier" : "ProjectBrowser", + "openSceneRelativePaths" : [ + "Scene.usda" + ], + "recentIdentifiers" : { + + }, + "sceneCameraHistory" : { + "1DEDB0B0-1F79-405C-9A35-ED4EC1C658C3" : [ + { + "date" : 811100635.690705, + "title" : "Untitled", + "transform" : [ + 0.87493426, + 1.0034403e-08, + -0.48424158, + 0, + -0.16792409, + 0.9379474, + -0.30340752, + 0, + 0.45419315, + 0.3467775, + 0.82064235, + 0, + 2.352992, + 0.96073854, + 2.5279448, + 1 + ] + }, + { + "date" : 810850313.275216, + "title" : "Untitled", + "transform" : [ + 0.87493426, + 1.0034403e-08, + -0.48424158, + 0, + -0.16792409, + 0.9379474, + -0.30340752, + 0, + 0.45419315, + 0.3467775, + 0.82064235, + 0, + 2.0599923, + 1.1571296, + 2.6071205, + 1 + ] + }, + { + "date" : 810850282.789156, + "title" : "Untitled", + "transform" : [ + 0.9991962, + -2.2912117e-11, + 0.040089205, + 0, + 0.0073407786, + 0.98309225, + -0.18296391, + 0, + -0.039411385, + 0.1831111, + 0.98230195, + 0, + 0.19498296, + 0.8688625, + 3.384911, + 1 + ] + }, + { + "date" : 810850197.664428, + "title" : "Untitled", + "transform" : [ + 0.99273306, + -1.2817892e-09, + 0.12033713, + 0, + 0.02792162, + 0.97270906, + -0.23034216, + 0, + -0.11705301, + 0.23202829, + 0.9656404, + 0, + -0.072999835, + 1.0375953, + 3.3419511, + 1 + ] + }, + { + "date" : 810850191.194057, + "title" : "Untitled", + "transform" : [ + -0.9798933, + -2.5345133e-09, + -0.19952236, + 0, + -0.01878358, + 0.99555874, + 0.092249826, + 0, + 0.19863622, + 0.09414273, + -0.9755413, + 0, + 0.36179835, + 0.56189, + -3.435693, + 1 + ] + }, + { + "date" : 810850184.430801, + "title" : "Untitled", + "transform" : [ + -0.9798933, + -2.5345133e-09, + -0.19952236, + 0, + -0.01878358, + 0.99555874, + 0.092249826, + 0, + 0.19863622, + 0.09414273, + -0.9755413, + 0, + 0.13510296, + 0.16270696, + -3.5203745, + 1 + ] + }, + { + "date" : 810849982.637009, + "title" : "Untitled", + "transform" : [ + 0.99932814, + 4.5959286e-10, + -0.036651053, + 0, + -0.013395197, + 0.93081945, + -0.3652336, + 0, + 0.034115516, + 0.36547917, + 0.9301941, + 0, + 0.6895672, + 1.1137897, + 3.2744114, + 1 + ] + }, + { + "date" : 810849979.546666, + "title" : "Untitled", + "transform" : [ + 0.99009883, + -1.6986516e-10, + -0.14037184, + 0, + -0.0054978617, + 0.99923277, + -0.03877862, + 0, + 0.14026414, + 0.039166413, + 0.9893392, + 0, + 1.0511645, + -0.03502264, + 3.3662512, + 1 + ] + }, + { + "date" : 810849977.446143, + "title" : "Untitled", + "transform" : [ + -0.14195816, + 1.6583037e-09, + 0.98987263, + 0, + 0.14174435, + 0.9896945, + 0.020327631, + 0, + -0.97967154, + 0.14319453, + -0.14049523, + 0, + -3.5112982, + 0.32232308, + 0.06862488, + 1 + ] + }, + { + "date" : 810849956.454318, + "title" : "Untitled", + "transform" : [ + 0.9772091, + 1.028965e-08, + 0.2122793, + 0, + 0.050172217, + 0.971668, + -0.23096344, + 0, + -0.20626499, + 0.23635007, + 0.94952273, + 0, + -0.17183797, + 0.6511396, + 3.4618368, + 1 + ] + } + ] + }, + "sceneViewportBackgroundColors" : { + + } +} \ No newline at end of file diff --git a/drachengas_tanks_usdz/Untitled/Package.swift b/drachengas_tanks_usdz/Untitled/Package.swift new file mode 100644 index 0000000..82b1647 --- /dev/null +++ b/drachengas_tanks_usdz/Untitled/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version:6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Untitled", + platforms: [ + .visionOS(.v26), + .macOS(.v26), + .iOS(.v26), + .tvOS(.v26) + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "Untitled", + targets: ["Untitled"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "Untitled", + dependencies: []), + ] +) \ No newline at end of file diff --git a/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.rkassets/DG-2700_mi.usdz b/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.rkassets/DG-2700_mi.usdz new file mode 100644 index 0000000..bc86c5a Binary files /dev/null and b/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.rkassets/DG-2700_mi.usdz differ diff --git a/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.rkassets/Scene.usda b/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.rkassets/Scene.usda new file mode 100644 index 0000000..14c491f --- /dev/null +++ b/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.rkassets/Scene.usda @@ -0,0 +1,14 @@ +#usda 1.0 +( + customLayerData = { + string creator = "Reality Composer Pro Version 2.0 (494.100.6)" + } + defaultPrim = "Root" + metersPerUnit = 1 + upAxis = "Y" +) + +def Xform "Root" +{ +} + diff --git a/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.swift b/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.swift new file mode 100644 index 0000000..9e85ae0 --- /dev/null +++ b/drachengas_tanks_usdz/Untitled/Sources/Untitled/Untitled.swift @@ -0,0 +1,4 @@ +import Foundation + +/// Bundle for the Untitled project +public let untitledBundle = Bundle.module \ No newline at end of file diff --git a/drachengas_tanks_usdz/drachengas-tanks-USDZ-bildlegende.jpg b/drachengas_tanks_usdz/drachengas-tanks-USDZ-bildlegende.jpg new file mode 100644 index 0000000..726ff3e Binary files /dev/null and b/drachengas_tanks_usdz/drachengas-tanks-USDZ-bildlegende.jpg differ