Add tank catalog with USDZ models and tank selection

Introduce TankDefinition/TankCatalog loaded from Tanks.json, bundle the
Drachengas USDZ tank models, add tank selection and preview views, and
update the AR experience, editor and project views to use the catalog.
Includes tests for catalog loading and model selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-26 19:12:39 +02:00
parent 992bb32025
commit b8536d4182
63 changed files with 2317 additions and 188 deletions

View File

@ -184,7 +184,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660; LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660; LastUpgradeCheck = 2700;
TargetAttributes = { TargetAttributes = {
95BEEDCC3035F1C100D17653 = { 95BEEDCC3035F1C100D17653 = {
CreatedOnToolsVersion = 26.6; CreatedOnToolsVersion = 26.6;
@ -288,6 +288,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 37Z4ZA4QU2; DEVELOPMENT_TEAM = 37Z4ZA4QU2;
@ -296,7 +297,7 @@
INFOPLIST_FILE = GasTankAR/Info.plist; INFOPLIST_FILE = GasTankAR/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; 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_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_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit; INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
@ -311,6 +312,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankAR; PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankAR;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
@ -326,6 +328,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 37Z4ZA4QU2; DEVELOPMENT_TEAM = 37Z4ZA4QU2;
@ -334,7 +337,7 @@
INFOPLIST_FILE = GasTankAR/Info.plist; INFOPLIST_FILE = GasTankAR/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; 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_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_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit; INFOPLIST_KEY_UIRequiredDeviceCapabilities = arkit;
@ -349,6 +352,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankAR; PRODUCT_BUNDLE_IDENTIFIER = dev.dennisnemec.GasTankAR;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
@ -364,6 +368,7 @@
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@ -418,6 +423,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@ -428,6 +434,7 @@
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@ -475,6 +482,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };

View File

@ -14,8 +14,8 @@
filePath = "GasTankAR/ContentView.swift" filePath = "GasTankAR/ContentView.swift"
startingColumnNumber = "9223372036854775807" startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807" endingColumnNumber = "9223372036854775807"
startingLineNumber = "20" startingLineNumber = "21"
endingLineNumber = "20" endingLineNumber = "21"
landmarkName = "unknown" landmarkName = "unknown"
landmarkType = "0"> landmarkType = "0">
</BreakpointContent> </BreakpointContent>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -31,6 +31,12 @@ final class Project {
/// 16 Floats (spaltenweise). Zusammen mit der WorldMap wird der Tank so wiederhergestellt. /// 16 Floats (spaltenweise). Zusammen mit der WorldMap wird der Tank so wiederhergestellt.
var tankTransform: [Float]? 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. /// Im AR-Modus aufgenommene Fotos. Werden beim Löschen des Projekts mitentfernt.
@Relationship(deleteRule: .cascade, inverse: \ProjectPhoto.project) @Relationship(deleteRule: .cascade, inverse: \ProjectPhoto.project)
var photos: [ProjectPhoto] = [] var photos: [ProjectPhoto] = []
@ -41,7 +47,9 @@ final class Project {
creationDate: Date, creationDate: Date,
tank: Tank = Tank(), tank: Tank = Tank(),
worldMapData: Data? = nil, worldMapData: Data? = nil,
tankTransform: [Float]? = nil tankTransform: [Float]? = nil,
measurementsData: Data? = nil,
pipeRouteData: Data? = nil
) { ) {
self.leadID = leadID self.leadID = leadID
self.customerName = customerName self.customerName = customerName
@ -49,6 +57,8 @@ final class Project {
self.tank = tank self.tank = tank
self.worldMapData = worldMapData self.worldMapData = worldMapData
self.tankTransform = tankTransform self.tankTransform = tankTransform
self.measurementsData = measurementsData
self.pipeRouteData = pipeRouteData
} }
/// Human-readable title combining the lead identifier and customer name. /// Human-readable title combining the lead identifier and customer name.

View File

@ -16,9 +16,15 @@ final class ProjectPhoto {
/// Aufnahmezeitpunkt – Grundlage für die Gruppierung in der Galerie. /// Aufnahmezeitpunkt – Grundlage für die Gruppierung in der Galerie.
var creationDate: Date var creationDate: Date
/// JPEG-kodierte Bilddaten. /// Aktuelle Bilddaten. Nach einer Bearbeitung enthält diese Property die gerenderte PNG-Datei.
@Attribute(.externalStorage) var imageData: Data @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. /// Geografische Breite des Aufnahmeorts, sofern beim Aufnehmen ein Standort vorlag.
var latitude: Double? var latitude: Double?
/// Geografische Länge des Aufnahmeorts, sofern beim Aufnehmen ein Standort vorlag. /// Geografische Länge des Aufnahmeorts, sofern beim Aufnehmen ein Standort vorlag.
@ -30,12 +36,16 @@ final class ProjectPhoto {
init( init(
creationDate: Date, creationDate: Date,
imageData: Data, imageData: Data,
originalImageData: Data? = nil,
annotatedImageData: Data? = nil,
latitude: Double? = nil, latitude: Double? = nil,
longitude: Double? = nil, longitude: Double? = nil,
project: Project? = nil project: Project? = nil
) { ) {
self.creationDate = creationDate self.creationDate = creationDate
self.imageData = imageData self.imageData = imageData
self.originalImageData = originalImageData
self.annotatedImageData = annotatedImageData
self.latitude = latitude self.latitude = latitude
self.longitude = longitude self.longitude = longitude
self.project = project self.project = project

View File

@ -7,40 +7,30 @@
import Foundation import Foundation
/// Verfügbare Tankgrößen laut Projektplan Phase 4 (Modell + korrekter Maßstab). /// Verfügbare Tankgrößen. Die Enum-Fälle sind reine, stabile Identitäten
enum TankSize: String, CaseIterable, Identifiable, Codable, Hashable { /// (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 t1_2
case t2_1 case t2_1
case t2_9 case t2_9
var id: String { rawValue } 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). /// Verfügbare Tankfarben. Welche Farben tatsächlich auswählbar sind, ergibt
enum TankColor: String, CaseIterable, Identifiable, Codable, Hashable { /// sich dynamisch aus dem `TankCatalog` (abhängig von Größe & Aufstellungsart).
nonisolated enum TankColor: String, CaseIterable, Identifiable, Codable, Hashable {
case white case white
case green case green
case olive
var id: String { rawValue } var id: String { rawValue }
var displayName: String {
switch self {
case .white: "Weiß"
case .green: "Grün"
}
}
} }
/// Aufstellungsart des Tanks. /// Aufstellungsart des Tanks. Steuert neben der Modellauswahl auch das
enum TankPlacement: String, CaseIterable, Identifiable, Codable, Hashable { /// AR-Verhalten (Grube, Occlusion, „nur Deckel").
nonisolated enum TankPlacement: String, CaseIterable, Identifiable, Codable, Hashable {
case aboveGround case aboveGround
case belowGround 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 /// Die für ein Projekt getroffene Tank-Auswahl. `Tank` ist die persistierte
/// Projektplans vorgesehenen Auswahlmöglichkeiten: Größe, Farbe und /// Identität (Größe, Farbe, Aufstellungsart); die zugehörigen Anzeigenamen und
/// Aufstellungsart sowie die optionale halbtransparente Darstellung. /// das 3D-Modell werden über den `TankCatalog` aufgelöst.
struct Tank: Hashable, Codable { nonisolated struct Tank: Hashable, Codable {
var size: TankSize = .t1_2 var size: TankSize = .t1_2
var color: TankColor = .white var color: TankColor = .white
var placement: TankPlacement = .aboveGround var placement: TankPlacement = .aboveGround
/// Optionale Darstellung, bei der nur der Deckel sichtbar ist. /// Optionale Darstellung, bei der nur der Deckel sichtbar ist.
var showLidOnly: Bool = false var showLidOnly: Bool = false
/// Kurzbeschreibung für Listen und Detailanzeigen.
var displayName: String {
"\(size.displayName) · \(color.displayName) · \(placement.displayName)"
}
} }

View File

@ -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
}
}

View File

@ -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<T: Hashable>(_ values: [T]) -> [T] {
var seen = Set<T>()
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())
}

View File

@ -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)
}
}

View File

@ -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" }
]
}

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import MapKit import MapKit
import UIKit
import CoreLocation import CoreLocation
/// Galerie der im AR-Modus aufgenommenen Fotos eines Projekts. /// Galerie der im AR-Modus aufgenommenen Fotos eines Projekts.
@ -56,14 +57,17 @@ struct GalleryView: View {
} }
} }
.fullScreenCover(item: $selectedPhoto) { photo in .fullScreenCover(item: $selectedPhoto) { photo in
PhotoViewer(photo: photo) { PhotoViewer(
photo: photo,
photos: project.photos.sorted { $0.creationDate > $1.creationDate }
) {
delete(photo) delete(photo)
} }
} }
} }
private func thumbnail(for photo: ProjectPhoto) -> some View { private func thumbnail(for photo: ProjectPhoto) -> some View {
PhotoThumbnail(imageData: photo.imageData) PhotoThumbnail(imageData: photo.annotatedImageData ?? photo.imageData)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.clipped() .clipped()
.contentShape(.rect) .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 { private struct PhotoViewer: View {
let photo: ProjectPhoto let photos: [ProjectPhoto]
let onDelete: () -> Void let onDelete: () -> Void
@Environment(\.dismiss) private var dismiss @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 { var body: some View {
NavigationStack { NavigationStack {
ScrollView { TabView(selection: $currentIndex) {
VStack(spacing: 0) { ForEach(photos.indices, id: \.self) { index in
if let image = UIImage(data: photo.imageData) { photoLayout(for: photos[index])
Image(uiImage: image) .tag(index)
.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()
}
} }
} }
.tabViewStyle(.page(indexDisplayMode: .never))
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
if isDrawing {
EmptyView()
} else {
Button("Fertig") { dismiss() } Button("Fertig") { dismiss() }
} }
}
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
if isDrawing {
Button {
saveDrawing()
} label: {
Image(systemName: "checkmark")
}
.buttonStyle(.glassProminent)
.tint(.accentColor)
.accessibilityLabel("Zeichnung speichern")
} else {
Button("Löschen", systemImage: "trash", role: .destructive) { Button("Löschen", systemImage: "trash", role: .destructive) {
onDelete() onDelete()
dismiss() dismiss()
} }
} }
} }
}
.onChange(of: currentIndex) {
isDrawing = false
strokes.removeAll()
activeStroke = nil
}
.toolbarBackground(.visible, for: .navigationBar) .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) .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 /// 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) String(format: "%.5f, %.5f", coordinate.latitude, coordinate.longitude)
} }
} }
#Preview("Galerie") { #Preview("Galerie") {
let sample = PreviewSupport.galleryProjectContainer() let sample = PreviewSupport.galleryProjectContainer()
return NavigationStack { return NavigationStack {
@ -259,5 +616,6 @@ private struct PhotoLocationCard: View {
} }
#Preview("Foto-Detail") { #Preview("Foto-Detail") {
PhotoViewer(photo: PreviewSupport.samplePhoto()) {} let photo = PreviewSupport.samplePhoto()
PhotoViewer(photo: photo, photos: [photo]) {}
} }

View File

@ -12,6 +12,8 @@ struct ProjectCreateView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let onCreated: (Project) -> Void
@State private var leadID: String = "" @State private var leadID: String = ""
@State private var customerName: String = "" @State private var customerName: String = ""
@State private var creationDate: Date = .now @State private var creationDate: Date = .now
@ -41,9 +43,7 @@ struct ProjectCreateView: View {
) )
} }
Section("Tankauswahl") { TankSelectionSection(tank: $tank)
TankEditor(tank: $tank)
}
} }
.navigationTitle("Projekt erstellen") .navigationTitle("Projekt erstellen")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -72,11 +72,12 @@ struct ProjectCreateView: View {
tank: tank tank: tank
) )
modelContext.insert(project) modelContext.insert(project)
onCreated(project)
dismiss() dismiss()
} }
} }
#Preview { #Preview {
ProjectCreateView() ProjectCreateView { _ in }
.modelContainer(for: Project.self, inMemory: true) .modelContainer(for: Project.self, inMemory: true)
} }

View File

@ -10,6 +10,7 @@ import SwiftData
struct ProjectDetailView: View { struct ProjectDetailView: View {
@Bindable var project: Project @Bindable var project: Project
@State private var isARPresented = false
var body: some View { var body: some View {
TabView { TabView {
@ -37,13 +38,11 @@ struct ProjectDetailView: View {
) )
} }
Section("Tankauswahl") { TankSelectionSection(tank: $project.tank)
TankEditor(tank: $project.tank)
}
} }
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
NavigationLink { Button {
ARExperienceView(project: project) isARPresented = true
} label: { } label: {
Label("AR-Modus starten", systemImage: "arkit") Label("AR-Modus starten", systemImage: "arkit")
.font(.headline) .font(.headline)
@ -53,6 +52,9 @@ struct ProjectDetailView: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.padding() .padding()
} }
.fullScreenCover(isPresented: $isARPresented) {
ARExperienceView(project: project)
}
} }
} }

View File

@ -62,7 +62,9 @@ struct ProjectListView: View {
.accessibilityLabel("Projekt erstellen") .accessibilityLabel("Projekt erstellen")
} }
.sheet(isPresented: $isCreatingProject) { .sheet(isPresented: $isCreatingProject) {
ProjectCreateView() ProjectCreateView { project in
selectedProject = project
}
} }
} detail: { } detail: {
NavigationStack { NavigationStack {

View File

@ -11,10 +11,12 @@ import ARKit
/// Zentraler Schlüssel für die LiDAR-Einstellung (in AppStorage/UserDefaults). /// Zentraler Schlüssel für die LiDAR-Einstellung (in AppStorage/UserDefaults).
enum SettingsKeys { enum SettingsKeys {
static let lidarFeaturesEnabled = "lidarFeaturesEnabled" static let lidarFeaturesEnabled = "lidarFeaturesEnabled"
static let arExposure = "arExposure"
} }
struct SettingsView: View { struct SettingsView: View {
@AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false @AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false
@AppStorage(SettingsKeys.arExposure) private var arExposure = 1.0
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
/// LiDAR ist verfügbar, wenn das Gerät Scene-Reconstruction (Mesh) unterstützt. /// 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.") 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") .navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View File

@ -14,6 +14,7 @@ struct ARExperienceView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false @AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false
@AppStorage(SettingsKeys.arExposure) private var arExposure = 1.0
let project: Project let project: Project
@ -41,6 +42,9 @@ struct ARExperienceView: View {
if controller.pipeRoutingPhase == .placing { if controller.pipeRoutingPhase == .placing {
pipeRoutingOverlay(controller: controller) pipeRoutingOverlay(controller: controller)
} }
if controller.selectedPipeSegmentIndex != nil {
pipeSegmentRemovalOverlay(controller: controller)
}
Color.white Color.white
.opacity(flashOpacity) .opacity(flashOpacity)
.ignoresSafeArea() .ignoresSafeArea()
@ -51,8 +55,22 @@ struct ARExperienceView: View {
} }
} }
} }
.navigationTitle(project.customerName) .overlay(alignment: .topLeading) {
.navigationBarTitleDisplayMode(.inline) 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) { .sheet(isPresented: $showMeasurements) {
if let controller { if let controller {
MeasurementListView(controller: controller) { MeasurementListView(controller: controller) {
@ -77,7 +95,11 @@ struct ARExperienceView: View {
.task { .task {
guard controller == nil else { return } guard controller == nil else { return }
let persistence = ProjectPersistenceAdapter(project: project, modelContext: modelContext) 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 // MARK: - Rohrführung-Overlay
/// Overlay für die Rohrführung: pinkes Fadenkreuz (Vollbild-Mitte), Info-Banner oben, /// Overlay für die Rohrführung: pinkes Fadenkreuz (Vollbild-Mitte), Info-Banner oben,

View File

@ -16,9 +16,13 @@ protocol ARProjectPersisting: AnyObject {
var tank: Tank { get } var tank: Tank { get }
var worldMap: ARWorldMap? { get } var worldMap: ARWorldMap? { get }
var tankTransform: [Float]? { get } var tankTransform: [Float]? { get }
var savedMeasurements: [Measurement] { get }
var savedPipeRoute: PipeRoute { get }
func saveWorldMap(_ data: Data) func saveWorldMap(_ data: Data)
func saveTankTransform(_ transform: [Float]) func saveTankTransform(_ transform: [Float])
func saveMeasurements(_ measurements: [Measurement])
func savePipeRoute(_ route: PipeRoute)
func clearTankTransform() func clearTankTransform()
func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?) func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?)
} }
@ -38,6 +42,16 @@ final class ProjectPersistenceAdapter: ARProjectPersisting {
var worldMap: ARWorldMap? { project.worldMap } var worldMap: ARWorldMap? { project.worldMap }
var tankTransform: [Float]? { project.tankTransform } 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) { func saveWorldMap(_ data: Data) {
project.worldMapData = data project.worldMapData = data
try? modelContext.save() try? modelContext.save()
@ -48,6 +62,16 @@ final class ProjectPersistenceAdapter: ARProjectPersisting {
try? modelContext.save() 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() { func clearTankTransform() {
project.tankTransform = nil project.tankTransform = nil
try? modelContext.save() try? modelContext.save()

View File

@ -32,6 +32,20 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
private(set) var measurements: [Measurement] = [] private(set) var measurements: [Measurement] = []
/// Aktuelle Phase des Messvorgangs. /// Aktuelle Phase des Messvorgangs.
private(set) var measurementPhase: MeasurementPhase = .idle private(set) var measurementPhase: MeasurementPhase = .idle
/// Vom Nutzer aktivierte, automatisch am Tank angelegte Maße.
private(set) var visibleTankDimensions: Set<TankDimension> = []
/// Transparenz des vergrabenen Tankkörpers: 0 = opak, 1 = vollständig transparent.
private(set) var undergroundTransparency: Double = 1.0 - Double(TankDepthFadeMaterial.belowAlpha)
/// Ob die Grube beim aktuell platzierten unterirdischen Tank sichtbar ist.
private(set) var showsPit: Bool
/// Unterirdische Tanks bieten zusätzliche Darstellungsoptionen.
var isUndergroundTank: Bool {
persistence.tank.placement == .belowGround
}
/// Ob der aktuell platzierte Tank halbtransparent dargestellt wird.
var isSemiTransparentTank: Bool {
isUndergroundTank && !showsPit
}
/// Live-Distanz (Meter) zwischen erstem Punkt und aktueller Bildschirmmitte – für die UI. /// Live-Distanz (Meter) zwischen erstem Punkt und aktueller Bildschirmmitte – für die UI.
private(set) var liveDistance: Float? private(set) var liveDistance: Float?
@ -47,7 +61,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
@ObservationIgnored private let persistence: ARProjectPersisting @ObservationIgnored private let persistence: ARProjectPersisting
@ObservationIgnored private let useLiDAR: Bool @ObservationIgnored private let useLiDAR: Bool
@ObservationIgnored private let configFactory: SessionConfigurationFactory @ObservationIgnored private let configFactory: SessionConfigurationFactory
@ObservationIgnored private let entityFactory = TankEntityFactory() @ObservationIgnored private let entityFactory: TankEntityFactory
@ObservationIgnored private var machine: ARFlowStateMachine @ObservationIgnored private var machine: ARFlowStateMachine
@ObservationIgnored private weak var arView: ARView? @ObservationIgnored private weak var arView: ARView?
@ -61,8 +75,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
@ObservationIgnored private var measurementAnchor: AnchorEntity? @ObservationIgnored private var measurementAnchor: AnchorEntity?
/// Container der fertigen Strecken, nach `Measurement.id`. /// Container der fertigen Strecken, nach `Measurement.id`.
@ObservationIgnored private var measurementEntities: [UUID: Entity] = [:] @ObservationIgnored private var measurementEntities: [UUID: Entity] = [:]
@ObservationIgnored private var tankDimensionEntities: [TankDimension: Entity] = [:]
@ObservationIgnored private var tankBaseExtents: SIMD3<Float>?
@ObservationIgnored private var lastTankDimensionScale: SIMD3<Float>?
/// Label-Halter für das Billboarding (Ausrichtung zur Kamera). /// Label-Halter für das Billboarding (Ausrichtung zur Kamera).
@ObservationIgnored private var measurementLabels: [Entity] = [] @ObservationIgnored private var measurementLabels: [Entity] = []
@ObservationIgnored private var tankDimensionLabels: [Entity] = []
/// Vorläufige Punkt-Kugeln während eines laufenden Messvorgangs. /// Vorläufige Punkt-Kugeln während eines laufenden Messvorgangs.
@ObservationIgnored private var previewPointEntities: [Entity] = [] @ObservationIgnored private var previewPointEntities: [Entity] = []
@ObservationIgnored private var pendingMeasurementStart: SIMD3<Float>? @ObservationIgnored private var pendingMeasurementStart: SIMD3<Float>?
@ -79,6 +97,9 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
@ObservationIgnored private var pipeAnchor: AnchorEntity? @ObservationIgnored private var pipeAnchor: AnchorEntity?
@ObservationIgnored private var pipeWaypointEntities: [Entity] = [] @ObservationIgnored private var pipeWaypointEntities: [Entity] = []
@ObservationIgnored private var pipeSegmentEntities: [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. /// Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte.
@ObservationIgnored private var pipeLiveSegment: ModelEntity? @ObservationIgnored private var pipeLiveSegment: ModelEntity?
/// Per-Frame-Callback zum Ausrichten der Labels zur Kamera. /// 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 anchorDetectionTimeout: Duration = .seconds(20)
private static let relocationTimeout: Duration = .seconds(15) 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.persistence = persistence
self.useLiDAR = useLiDAR self.useLiDAR = useLiDAR
self.showsPit = persistence.tank.placement == .belowGround && !persistence.tank.showLidOnly
self.entityFactory = TankEntityFactory(aboveGroundLightMultiplier: Float(arExposure))
self.configFactory = SessionConfigurationFactory( self.configFactory = SessionConfigurationFactory(
useLiDAR: useLiDAR, useLiDAR: useLiDAR,
referenceImages: ReferenceImageProvider.load() referenceImages: ReferenceImageProvider.load()
) )
self.machine = ARFlowStateMachine(hasStoredTransform: persistence.tankTransform != nil) self.machine = ARFlowStateMachine(hasStoredTransform: persistence.tankTransform != nil)
self.measurements = persistence.savedMeasurements
self.pipeRoute = persistence.savedPipeRoute
super.init() super.init()
locationManager.delegate = self locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
@ -145,13 +170,22 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
view.addGestureRecognizer(tap) 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). // Pro Frame die Mess-Labels zur Kamera ausrichten (Billboarding).
sceneUpdateSubscription = view.scene.subscribe(to: SceneEvents.Update.self) { [weak self] _ in sceneUpdateSubscription = view.scene.subscribe(to: SceneEvents.Update.self) { [weak self] _ in
guard let self else { return } guard let self else { return }
MainActor.assumeIsolated { self.onSceneUpdate() } MainActor.assumeIsolated { self.onSceneUpdate() }
} }
entityFactory.preload() entityFactory.preload(persistence.tank)
restorePersistedAnnotations()
startMappingPoll() startMappingPoll()
startLocationUpdates() startLocationUpdates()
send(.started) send(.started)
@ -184,6 +218,50 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
func retryRelocation() { send(.retryRelocationTapped) } func retryRelocation() { send(.retryRelocationTapped) }
func deselectTank() { isTankSelected = false } func deselectTank() { isTankSelected = false }
/// Passt ausschließlich die Transparenz des vergrabenen Tankkörpers an.
func setUndergroundTransparency(_ value: Double) {
guard isSemiTransparentTank else { return }
undergroundTransparency = min(max(value, 0), 1)
updateDepthFadeGroundY()
}
/// Schaltet die Grube eines unterirdischen Tanks direkt im AR-Modus um.
func setShowsPit(_ visible: Bool) {
guard isUndergroundTank, visible != showsPit,
let currentTank = tankEntity,
let tankAnchor else { return }
showsPit = visible
var tank = persistence.tank
tank.showLidOnly = !visible
let transform = currentTank.transform
currentTank.removeFromParent()
tankDimensionEntities.removeAll()
tankDimensionLabels.removeAll()
tankBaseExtents = nil
lastTankDimensionScale = nil
let replacement = entityFactory.makeContainer(for: tank)
replacement.transform = transform
tankAnchor.addChild(replacement)
tankEntity = replacement
installManipulationGestures(on: replacement)
updateDepthFadeGroundY()
updateSafetyZones()
updateTankDimensions()
}
/// Setzt die Tank-Skalierung auf die Originalgröße zurück.
func resetTankScale() {
guard let tankEntity else { return }
tankEntity.scale = SIMD3<Float>(repeating: 1)
updateSafetyZones()
updateTankDimensionValues()
persistTankTransform()
}
// MARK: - Tank-Feinjustierung (über das Steuer-Panel) // MARK: - Tank-Feinjustierung (über das Steuer-Panel)
private static let heightStep: Float = 0.05 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. /// Standort für die spätere Karten-Verortung – im Projekt.
func captureSnapshot() { func captureSnapshot() {
guard let arView else { return } guard let arView else { return }
let coordinate = currentLocation?.coordinate let coordinate = locationManager.location?.coordinate ?? currentLocation?.coordinate
arView.snapshot(saveToHDR: false) { [weak self] image in arView.snapshot(saveToHDR: false) { [weak self] image in
guard let image else { return } guard let image else { return }
let date = Date() let date = Date()
@ -276,6 +354,16 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
// MARK: - Ausmessen (Strecken) // 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. /// Startet einen neuen Messvorgang; der Nutzer tippt danach zwei Punkte an.
func startMeasurement() { func startMeasurement() {
clearMeasurementPreview() clearMeasurementPreview()
@ -306,6 +394,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
end: end end: end
) )
measurements.append(measurement) measurements.append(measurement)
persistence.saveMeasurements(measurements)
renderMeasurement(measurement) renderMeasurement(measurement)
clearMeasurementPreview() clearMeasurementPreview()
@ -327,6 +416,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
} }
} }
measurements.remove(atOffsets: offsets) measurements.remove(atOffsets: offsets)
persistence.saveMeasurements(measurements)
} }
/// Setzt den aktuellen Punkt aus der Bildschirmmitte (wie beim Platzieren des Tanks). /// Setzt den aktuellen Punkt aus der Bildschirmmitte (wie beim Platzieren des Tanks).
@ -375,6 +465,130 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
} }
} }
private func restorePersistedAnnotations() {
for measurement in measurements {
renderMeasurement(measurement)
}
rebuildPipeRouteEntities()
}
private func rebuildPipeRouteEntities() {
pipeWaypointEntities.forEach { $0.removeFromParent() }
pipeSegmentEntities.forEach { $0.removeFromParent() }
pipeWaypointEntities.removeAll()
pipeSegmentEntities.removeAll()
pipeSegmentIndices.removeAll()
guard !pipeRoute.waypoints.isEmpty else { return }
let anchor = ensurePipeAnchor()
for waypoint in pipeRoute.waypoints {
let entity = PipeRouteEntityFactory.makeWaypoint(at: waypoint)
anchor.addChild(entity)
pipeWaypointEntities.append(entity)
}
for (index, pair) in zip(pipeRoute.waypoints, pipeRoute.waypoints.dropFirst()).enumerated()
where !pipeRoute.isSegmentRemoved(at: index) {
let segment = PipeRouteEntityFactory.makeSegment(from: pair.0, to: pair.1)
anchor.addChild(segment)
pipeSegmentEntities.append(segment)
pipeSegmentIndices.append(index)
}
}
private func updateTankDimensions() {
tankDimensionEntities.values.forEach { $0.removeFromParent() }
tankDimensionEntities.removeAll()
tankDimensionLabels.removeAll()
guard let tankEntity,
let tankModel = tankEntity.findEntity(named: TankEntityFactory.modelEntityName) else { return }
// Nur das eigentliche Modell vermessen; Grube, Occlusion-Maske und Markierung
// dürfen die Tankmaße nicht beeinflussen.
let bounds = tankModel.visualBounds(relativeTo: tankEntity)
tankBaseExtents = bounds.extents
lastTankDimensionScale = tankEntity.scale
guard !visibleTankDimensions.isEmpty else { return }
let lower = bounds.min
let upper = bounds.max
let center = bounds.center
let dimensionGap = Swift.max(Swift.max(bounds.extents.x, bounds.extents.y), bounds.extents.z) * 0.08
let labelGap: Float = 0.02
for dimension in visibleTankDimensions {
let start: SIMD3<Float>
let end: SIMD3<Float>
let labelOffset: SIMD3<Float>
switch dimension {
case .height:
start = SIMD3(upper.x + dimensionGap, lower.y, center.z)
end = SIMD3(upper.x + dimensionGap, upper.y, center.z)
labelOffset = SIMD3(labelGap, 0, 0)
case .length:
// Die Längenlinie liegt direkt an der unteren Modellkante. Ein
// zusätzlicher Abstand nach unten würde bei unterirdischen Tanks
// unter der Grubensohle liegen und dadurch verdeckt werden.
start = SIMD3(lower.x - dimensionGap, lower.y, lower.z)
end = SIMD3(lower.x - dimensionGap, lower.y, upper.z)
labelOffset = SIMD3(-labelGap, 0, 0)
case .width:
start = SIMD3(lower.x, upper.y + dimensionGap, center.z)
end = SIMD3(upper.x, upper.y + dimensionGap, center.z)
labelOffset = SIMD3(0, labelGap, 0)
}
let entity = MeasurementEntityFactory.makeDimension(
dimension,
from: start,
to: end,
labelOffset: labelOffset
)
tankEntity.addChild(entity)
tankDimensionEntities[dimension] = entity
if let label = entity.findEntity(named: MeasurementEntityFactory.labelName) {
tankDimensionLabels.append(label)
}
}
}
private func updateTankDimensionValues() {
guard let tankEntity,
let tankBaseExtents,
!visibleTankDimensions.isEmpty else { return }
let scale = tankEntity.scale
guard lastTankDimensionScale != scale else { return }
lastTankDimensionScale = scale
let absoluteScale = SIMD3<Float>(
abs(scale.x),
abs(scale.y),
abs(scale.z)
)
let scaledExtents = tankBaseExtents * absoluteScale
for dimension in visibleTankDimensions {
guard let entity = tankDimensionEntities[dimension] else { continue }
let distance: Float
switch dimension {
case .height:
distance = scaledExtents.y
case .length:
distance = scaledExtents.z
case .width:
distance = scaledExtents.x
}
MeasurementEntityFactory.updateDimensionLabel(
entity,
dimension: dimension,
distance: distance
)
}
}
private func ensureMeasurementAnchor() -> AnchorEntity { private func ensureMeasurementAnchor() -> AnchorEntity {
if let measurementAnchor { return measurementAnchor } if let measurementAnchor { return measurementAnchor }
let anchor = AnchorEntity(world: matrix_identity_float4x4) let anchor = AnchorEntity(world: matrix_identity_float4x4)
@ -392,7 +606,6 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
private func onSceneUpdate() { private func onSceneUpdate() {
updateLivePreview() updateLivePreview()
updatePipeLivePreview() updatePipeLivePreview()
billboardLabels()
} }
/// Aktualisiert Cursor, Live-Linie und Distanz-Label anhand der aktuellen Bildschirmmitte. /// Aktualisiert Cursor, Live-Linie und Distanz-Label anhand der aktuellen Bildschirmmitte.
@ -428,7 +641,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
liveDistance = distance liveDistance = distance
if let holder = liveLabelEntity { if let holder = liveLabelEntity {
holder.children.forEach { $0.removeFromParent() } 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() { private func billboardLabels() {
guard let arView else { return } guard let arView else { return }
let camera = arView.cameraTransform.translation let camera = arView.cameraTransform.translation
var labels = measurementLabels var labels = measurementLabels + tankDimensionLabels
if let liveLabelEntity, liveLabelEntity.isEnabled { labels.append(liveLabelEntity) } if let liveLabelEntity, liveLabelEntity.isEnabled { labels.append(liveLabelEntity) }
guard !labels.isEmpty else { return } guard !labels.isEmpty else { return }
@ -494,11 +712,13 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
let segment = PipeRouteEntityFactory.makeSegment(from: previous, to: position) let segment = PipeRouteEntityFactory.makeSegment(from: previous, to: position)
anchor.addChild(segment) anchor.addChild(segment)
pipeSegmentEntities.append(segment) pipeSegmentEntities.append(segment)
pipeSegmentIndices.append(pipeRoute.waypoints.count - 1)
} }
let waypoint = PipeRouteEntityFactory.makeWaypoint(at: position) let waypoint = PipeRouteEntityFactory.makeWaypoint(at: position)
anchor.addChild(waypoint) anchor.addChild(waypoint)
pipeWaypointEntities.append(waypoint) pipeWaypointEntities.append(waypoint)
pipeRoute.add(position) pipeRoute.add(position)
persistence.savePipeRoute(pipeRoute)
} }
/// Entfernt den zuletzt gesetzten Wegpunkt (und sein Segment). /// Entfernt den zuletzt gesetzten Wegpunkt (und sein Segment).
@ -506,6 +726,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
guard pipeRoute.removeLast() != nil else { return } guard pipeRoute.removeLast() != nil else { return }
pipeWaypointEntities.popLast()?.removeFromParent() pipeWaypointEntities.popLast()?.removeFromParent()
pipeSegmentEntities.popLast()?.removeFromParent() pipeSegmentEntities.popLast()?.removeFromParent()
pipeSegmentIndices.popLast()
persistence.savePipeRoute(pipeRoute)
} }
/// Beendet das Setzen; der fertige Pfad bleibt sichtbar. /// Beendet das Setzen; der fertige Pfad bleibt sichtbar.
@ -533,8 +755,11 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
pipeSegmentEntities.forEach { $0.removeFromParent() } pipeSegmentEntities.forEach { $0.removeFromParent() }
pipeWaypointEntities.removeAll() pipeWaypointEntities.removeAll()
pipeSegmentEntities.removeAll() pipeSegmentEntities.removeAll()
pipeSegmentIndices.removeAll()
selectedPipeSegmentIndex = nil
hidePipeLivePreview() hidePipeLivePreview()
pipeRoute.clear() pipeRoute.clear()
persistence.savePipeRoute(pipeRoute)
} }
/// Zeichnet das Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte. /// Zeichnet das Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte.
@ -745,11 +970,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
updateDepthFadeGroundY() updateDepthFadeGroundY()
updateSceneOcclusion() updateSceneOcclusion()
updateSafetyZones() updateSafetyZones()
updateTankDimensions()
} }
/// Baut die Sicherheitsabstände auf, aktualisiert ihre Bodenposition oder entfernt sie. /// 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 /// 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() { private func updateSafetyZones() {
guard showSafetyZones, let tankAnchor, let tankEntity else { guard showSafetyZones, let tankAnchor, let tankEntity else {
safetyZoneEntity?.removeFromParent() safetyZoneEntity?.removeFromParent()
@ -765,7 +991,26 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
tankAnchor.addChild(entity) tankAnchor.addChild(entity)
safetyZoneEntity = entity safetyZoneEntity = entity
} }
entity.position = tankEntity.position(relativeTo: tankAnchor)
let tankPosition = tankEntity.position(relativeTo: tankAnchor)
let localDomeOffset = SIMD4<Float>(0, 0, safetyZoneDomeOffset, 0)
let tankTransform = tankEntity.transformMatrix(relativeTo: tankAnchor)
let domeOffset = tankTransform * localDomeOffset
entity.position = tankPosition + SIMD3(domeOffset.x, domeOffset.y, domeOffset.z)
}
/// Abstand vom Tankmittelpunkt zum seitlichen Domschacht entlang der lokalen
/// Tank-Längsachse (+Z), abgeleitet aus den Vorgabemaßen.
private var safetyZoneDomeOffset: Float {
switch persistence.tank.size {
case .t1_2:
return 2.460 / 2
case .t2_1:
return 4.255 / 2
case .t2_9:
return 5.500 / 2
}
} }
private func installManipulationGestures(on entity: ModelEntity) { private func installManipulationGestures(on entity: ModelEntity) {
@ -779,8 +1024,11 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
@objc private func handleManipulationChanged(_ recognizer: UIGestureRecognizer) { @objc private func handleManipulationChanged(_ recognizer: UIGestureRecognizer) {
updateSafetyZones() // Ringe live mit dem Tank mitführen. updateSafetyZones() // Ringe live mit dem Tank mitführen.
switch recognizer.state { switch recognizer.state {
case .ended, .cancelled, .failed: persistTankTransform() case .ended, .cancelled, .failed:
default: break 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() { private func removeTank() {
if let tankAnchor { arView?.scene.removeAnchor(tankAnchor) } if let tankAnchor { arView?.scene.removeAnchor(tankAnchor) }
tankAnchor = nil tankAnchor = nil
tankEntity = nil tankEntity = nil
safetyZoneEntity = nil safetyZoneEntity = nil
tankDimensionEntities.removeAll()
tankDimensionLabels.removeAll()
tankBaseExtents = nil
lastTankDimensionScale = nil
isTankSelected = false isTankSelected = false
} }
@ -819,7 +1115,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
private func updateDepthFadeGroundY() { private func updateDepthFadeGroundY() {
guard persistence.tank.placement == .belowGround, let tankEntity else { return } guard persistence.tank.placement == .belowGround, let tankEntity else { return }
let groundY = tankEntity.position(relativeTo: nil).y 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 // MARK: - Persistenz & Status

View File

@ -9,7 +9,23 @@ import Foundation
import simd import simd
/// Eine benannte Strecke zwischen zwei im Raum gewählten Punkten (Welt-Koordinaten). /// 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() let id = UUID()
var name: String var name: String
let start: SIMD3<Float> let start: SIMD3<Float>

View File

@ -20,9 +20,11 @@ enum MeasurementEntityFactory {
private static let color = UIColor.systemYellow private static let color = UIColor.systemYellow
private static let pointRadius: Float = 0.012 private static let pointRadius: Float = 0.012
private static let lineThickness: Float = 0.006 private static let lineThickness: Float = 0.02
private static let labelHeight: Float = 0.06 private static let labelHeight: Float = 0.14
private static let labelYOffset: Float = 0.03 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. /// Kleine Kugel für einen (auch vorläufigen) Messpunkt.
static func makePoint(at position: SIMD3<Float>) -> ModelEntity { static func makePoint(at position: SIMD3<Float>) -> ModelEntity {
@ -45,13 +47,90 @@ enum MeasurementEntityFactory {
configureLine(line, from: measurement.start, to: measurement.end) configureLine(line, from: measurement.start, to: measurement.end)
container.addChild(line) container.addChild(line)
let label = makeLabelHolder() let lineDirection = normalize(measurement.end - measurement.start)
label.position = labelPosition(from: measurement.start, to: measurement.end) var labelOrientation = simd_quatf(
label.addChild(makeText("\(measurement.name) (\(measurement.formattedDistance))")) 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) container.addChild(label)
return container return container
} }
/// Erzeugt eine vorgefertigte Tankmaß-Linie mit verständlicher Beschriftung.
static func makeDimension(
_ dimension: TankDimension,
from start: SIMD3<Float>,
to end: SIMD3<Float>,
labelOffset: SIMD3<Float>
) -> 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. /// Basis-Linie der Länge 1 m (entlang +Z). Für die Live-Linie wird sie via ``configureLine`` skaliert/rotiert.
static func makeLineBase() -> ModelEntity { static func makeLineBase() -> ModelEntity {
ModelEntity( ModelEntity(
@ -71,18 +150,21 @@ enum MeasurementEntityFactory {
} }
/// Leerer Label-Halter (Billboard-Anker). Der Text wird als zentriertes Kind eingehängt. /// 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() let holder = Entity()
holder.name = labelName holder.name = labelName
if usesBillboard {
holder.components.set(BillboardComponent())
}
return holder return holder
} }
/// Zentriertes Text-Entity für einen Label-Halter. /// 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( let mesh = MeshResource.generateText(
string, string,
extrusionDepth: 0.001, extrusionDepth: 0.001,
font: .systemFont(ofSize: CGFloat(labelHeight), weight: .semibold) font: .systemFont(ofSize: CGFloat(fontSize), weight: .semibold)
) )
var material = UnlitMaterial(color: .white) var material = UnlitMaterial(color: .white)
material.faceCulling = .none material.faceCulling = .none
@ -92,7 +174,11 @@ enum MeasurementEntityFactory {
} }
/// Position des Labels (Streckenmitte, leicht angehoben). /// Position des Labels (Streckenmitte, leicht angehoben).
static func labelPosition(from start: SIMD3<Float>, to end: SIMD3<Float>) -> SIMD3<Float> { static func labelPosition(
(start + end) / 2 + SIMD3(0, labelYOffset, 0) from start: SIMD3<Float>,
to end: SIMD3<Float>,
verticalOffset: Float = 0.105
) -> SIMD3<Float> {
(start + end) / 2 + SIMD3(0, verticalOffset, 0)
} }
} }

View File

@ -16,18 +16,23 @@ struct MeasurementListView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group {
if controller.measurements.isEmpty {
ContentUnavailableView {
Label("Keine Strecken", systemImage: "ruler")
} description: {
Text("Füge eine Strecke hinzu und wähle dafür zwei Punkte im Raum.")
} actions: {
Button("Strecke hinzufügen", systemImage: "plus", action: onAddStrecke)
.buttonStyle(.borderedProminent)
}
} else {
List { List {
Section("Tankmaße") {
Toggle("Alle anzeigen", isOn: allDimensionsBinding)
ForEach(TankDimension.allCases) { dimension in
Toggle(
dimension.title,
isOn: dimensionBinding(for: dimension)
)
}
}
Section("Eigene Strecken") {
if controller.measurements.isEmpty {
Text("Noch keine eigenen Strecken angelegt.")
.foregroundStyle(.secondary)
} else {
ForEach(controller.measurements) { measurement in ForEach(controller.measurements) { measurement in
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@ -56,4 +61,22 @@ struct MeasurementListView: View {
} }
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
} }
private var allDimensionsBinding: Binding<Bool> {
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<Bool> {
Binding(
get: { controller.visibleTankDimensions.contains(dimension) },
set: { controller.setTankDimension(dimension, visible: $0) }
)
}
} }

View File

@ -10,16 +10,56 @@ import simd
/// Eine Rohrführung als geordnete Folge von Wegpunkten (Welt-Koordinaten), die zu einem /// Eine Rohrführung als geordnete Folge von Wegpunkten (Welt-Koordinaten), die zu einem
/// zusammenhängenden Pfad verbunden werden – visualisiert die geplante Rohrverlegung. /// zusammenhängenden Pfad verbunden werden – visualisiert die geplante Rohrverlegung.
struct PipeRoute { struct PipeRoute: Codable {
private(set) var waypoints: [SIMD3<Float>] = [] private(set) var waypoints: [SIMD3<Float>] = []
private(set) var removedSegmentIndices: Set<Int> = []
var count: Int { waypoints.count } 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 { var totalLength: Float {
guard waypoints.count > 1 else { return 0 } guard waypoints.count > 1 else { return 0 }
return zip(waypoints, waypoints.dropFirst()) 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 { var formattedLength: String {
@ -32,11 +72,20 @@ struct PipeRoute {
@discardableResult @discardableResult
mutating func removeLast() -> SIMD3<Float>? { mutating func removeLast() -> SIMD3<Float>? {
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() { mutating func clear() {
waypoints.removeAll() waypoints.removeAll()
removedSegmentIndices.removeAll()
} }
} }

View File

@ -31,9 +31,18 @@ enum PipeRouteEntityFactory {
static func makeSegment(from start: SIMD3<Float>, to end: SIMD3<Float>) -> ModelEntity { static func makeSegment(from start: SIMD3<Float>, to end: SIMD3<Float>) -> ModelEntity {
let segment = makeSegmentBase() let segment = makeSegmentBase()
configureSegment(segment, from: start, to: end) 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 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. /// Basis-Segment der Länge 1 m (entlang +Z) – für die Live-Vorschau wiederverwendbar.
static func makeSegmentBase() -> ModelEntity { static func makeSegmentBase() -> ModelEntity {
ModelEntity( ModelEntity(

View File

@ -91,11 +91,24 @@ enum SafetyZoneFactory {
let bounds = text.visualBounds(relativeTo: nil) let bounds = text.visualBounds(relativeTo: nil)
text.position = -bounds.center text.position = -bounds.center
let radialDirection = SIMD3(sin(angle), 0, cos(angle))
let tangentDirection = SIMD3(cos(angle), 0, -sin(angle))
let holder = Entity() let holder = Entity()
holder.addChild(text) 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)) // Wie bei der Längenbeschriftung: in die Bodenebene drehen und tangential
* simd_quatf(angle: -.pi / 2, axis: SIMD3(1, 0, 0)) // 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 return holder
} }

View File

@ -7,6 +7,7 @@
import RealityKit import RealityKit
import Metal import Metal
import UIKit
/// Kapselt das Höhen-Fade-CustomMaterial für den unterirdischen Tank: /// Kapselt das Höhen-Fade-CustomMaterial für den unterirdischen Tank:
/// verblasst die Opacity anhand der Welt-Höhe (Kopf opak, vergrabener Teil transparent). /// 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) 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) { static func apply(to entity: Entity) {
guard let shader else { return } guard let shader else { return }
forEachModelEntity(entity) { modelEntity in forEachModelEntity(entity) { modelEntity in
@ -36,16 +39,26 @@ enum TankDepthFadeMaterial {
return custom return custom
} }
modelEntity.model = component 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. /// 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 forEachModelEntity(entity) { modelEntity in
guard var component = modelEntity.model else { return } guard var component = modelEntity.model else { return }
component.materials = component.materials.map { material in component.materials = component.materials.map { material in
guard var custom = material as? CustomMaterial else { return material } guard var custom = material as? CustomMaterial else { return material }
custom.custom.value = SIMD4<Float>(groundY, band, belowAlpha, 0) var customValue = custom.custom.value
customValue.x = groundY
customValue.y = band
customValue.z = belowAlpha
custom.custom.value = customValue
return custom return custom
} }
modelEntity.model = component modelEntity.model = component

View File

@ -8,13 +8,14 @@
import RealityKit import RealityKit
import UIKit import UIKit
/// Baut die darzustellende Tank-Entity (aus `GasTank.usdz`): lädt/cached das Modell, /// Baut die darzustellende Tank-Entity: löst das zum Tank passende 3D-Modell über
/// richtet es je nach Aufstellungsart aus, erzeugt die Kollisionsform und – bei /// den `TankCatalog` auf, lädt/cached es, richtet es je nach Aufstellungsart aus,
/// unterirdischer Aufstellung – den Höhen-Transparenz-Effekt. /// erzeugt die Kollisionsform und – bei unterirdischer Aufstellung – die Grube.
@MainActor @MainActor
final class TankEntityFactory { final class TankEntityFactory {
/// Name des USDZ-Assets im Bundle. /// Stabiler Name, damit spätere Berechnungen ausschließlich die Modell-Entity vermessen.
private static let modelName = "GasTank" static let modelEntityName = "TankModel"
/// Anteil der Modellhöhe, der bei unterirdischer Aufstellung oben herausragt („Kopf"). /// Anteil der Modellhöhe, der bei unterirdischer Aufstellung oben herausragt („Kopf").
private static let belowGroundExposedFraction: Float = 0.25 private static let belowGroundExposedFraction: Float = 0.25
/// Zusätzlicher Abstand zwischen Tank-Umkreis und Grubenwand (in Metern) – großzügig, /// 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 private static let pitMargin: Float = 0.4
/// Zusätzliche Tiefe der Grubensohle unter der Tankunterkante (in Metern). /// Zusätzliche Tiefe der Grubensohle unter der Tankunterkante (in Metern).
private static let pitFloorClearance: Float = 0.1 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. /// Katalog zur Auflösung der Auswahl auf ein konkretes Modell.
private var loadedModel: Entity? private let catalog: any TankCatalogProviding
/// Lädt das USDZ asynchron vor (blockiert den Main-Actor nicht). /// Vorab geladene Modelle, zwischengespeichert pro Ressourcenname; werden pro
func preload() { /// Platzierung geklont.
guard loadedModel == nil else { return } 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 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). /// Erzeugt den manipulierbaren Container (mit Kollision fürs Gesten-Hit-Testing).
func makeContainer(for tank: Tank) -> ModelEntity { 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 bounds = model.visualBounds(relativeTo: nil)
let container = ModelEntity() let container = ModelEntity()
switch tank.placement { switch tank.placement {
case .aboveGround: case .aboveGround:
model.position.y -= bounds.min.y // Unterkante auf die Fläche model.position.y -= bounds.min.y // Unterkante auf die Fläche
addAboveGroundFillLight(to: container)
case .belowGround: case .belowGround:
// Modell absenken, sodass nur der obere Teil („Kopf") herausragt … // Modell absenken, sodass nur der obere Teil („Kopf") herausragt …
@ -51,6 +72,11 @@ final class TankEntityFactory {
model.position.y -= (bounds.max.y - exposed) model.position.y -= (bounds.max.y - exposed)
// … und den Boden „aufreißen": eine runde Grube ausheben, in welcher der Tank steht. // … 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. // Radius = Umkreis der Grundfläche (halbe Diagonale) + großzügiger Rand.
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() let footprintRadius = 0.5 * (bounds.extents.x * bounds.extents.x + bounds.extents.z * bounds.extents.z).squareRoot()
container.addChild(TankPitFactory.makePit( container.addChild(TankPitFactory.makePit(
radius: footprintRadius + Self.pitMargin, radius: footprintRadius + Self.pitMargin,
@ -58,6 +84,7 @@ final class TankEntityFactory {
center: bounds.center center: bounds.center
)) ))
} }
}
container.addChild(model) container.addChild(model)
@ -76,16 +103,36 @@ final class TankEntityFactory {
return container return container
} }
/// Liefert einen Klon des vorab geladenen Modells. Fällt auf synchrones Laden /// Fügt eine neutrale gerichtete Füllbeleuchtung hinzu, damit oberirdische
/// bzw. – wenn das USDZ fehlt – auf einen einfachen Cube zurück. /// Modelle bei dunkler Umgebung nicht unnötig grau dargestellt werden.
private func loadModelClone() -> Entity { private func addAboveGroundFillLight(to container: Entity) {
if loadedModel == nil { let light = Entity(components: DirectionalLightComponent(
loadedModel = try? Entity.load(named: Self.modelName) color: .white,
intensity: Self.aboveGroundLightIntensity * aboveGroundLightMultiplier
))
light.orientation = simd_quatf(angle: -.pi / 4, axis: [1, 0, 0])
container.addChild(light)
} }
if let loadedModel {
return loadedModel.clone(recursive: true) /// 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()
} }
return ModelEntity( if loadedModels[name] == nil {
loadedModels[name] = try? Entity.load(named: name)
}
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), mesh: .generateBox(size: 0.3),
materials: [SimpleMaterial(color: .white, isMetallic: false)] materials: [SimpleMaterial(color: .white, isMetallic: false)]
) )

View File

@ -14,21 +14,22 @@ struct TankManipulationPanel: View {
let controller: ARSessionController let controller: ARSessionController
var body: some View { var body: some View {
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) { VStack(spacing: 14) {
HStack { dismissHandle
Text("Tank anpassen") panelTitle
.font(.headline)
Spacer()
Button {
controller.deselectTank()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Schließen")
}
HStack(alignment: .center, spacing: 24) { HStack(alignment: .center, spacing: 24) {
heightGroup heightGroup
@ -36,13 +37,111 @@ struct TankManipulationPanel: View {
rotateGroup 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") { Button("Neu platzieren", systemImage: "arrow.up.and.down.and.arrow.left.and.right") {
controller.reposition() controller.reposition()
} }
.buttonStyle(.bordered) .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 // MARK: - Gruppen
@ -79,7 +178,7 @@ struct TankManipulationPanel: View {
} }
private func controlGroup<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View { private func controlGroup<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(spacing: 8) { VStack(spacing: 10) {
content() content()
Text(title) Text(title)
.font(.caption) .font(.caption)
@ -113,7 +212,7 @@ private struct HoldRepeatButton: View {
var body: some View { var body: some View {
Image(systemName: systemImage) Image(systemName: systemImage)
.font(.title3) .font(.title3)
.frame(width: 48, height: 48) .frame(width: 54, height: 54)
.background(.ultraThinMaterial, in: .circle) .background(.ultraThinMaterial, in: .circle)
.contentShape(.circle) .contentShape(.circle)
.gesture( .gesture(

View File

@ -27,16 +27,13 @@ void tankDepthFadeSurface(realitykit::surface_parameters params)
auto constants = params.material_constants(); auto constants = params.material_constants();
float2 uv = params.geometry().uv0(); float2 uv = params.geometry().uv0();
// Modelle aus USDZ nutzen eine gespiegelte Y-UV.
uv.y = 1.0 - uv.y; uv.y = 1.0 - uv.y;
// Basisfarbe = Tint * Textur. // Originale USDZ-Farbe und Textur beibehalten.
half3 tint = (half3)constants.base_color_tint(); half3 tint = (half3)constants.base_color_tint();
half3 color = (half3)tex.base_color().sample(textureSampler, uv).rgb; half3 color = (half3)tex.base_color().sample(textureSampler, uv).rgb;
color *= tint; params.surface().set_base_color(color * tint);
params.surface().set_base_color(color);
// Roughness / Metallic durchreichen (wie PhysicallyBasedMaterial).
half roughness = tex.roughness().sample(textureSampler, uv).r * constants.roughness_scale(); half roughness = tex.roughness().sample(textureSampler, uv).r * constants.roughness_scale();
params.surface().set_roughness(roughness); params.surface().set_roughness(roughness);
@ -50,6 +47,13 @@ void tankDepthFadeSurface(realitykit::surface_parameters params)
float band = max(custom.y, 1e-3); float band = max(custom.y, 1e-3);
float belowAlpha = custom.z; 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 t = clamp((groundY - worldPosition.y) / band, 0.0, 1.0);
float alpha = mix(1.0, belowAlpha, t); float alpha = mix(1.0, belowAlpha, t);
params.surface().set_opacity(half(alpha)); params.surface().set_opacity(half(alpha));

View File

@ -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)
}
}

View File

@ -7,21 +7,33 @@
import SwiftUI import SwiftUI
/// Wiederverwendbare Auswahl für die Tank-Eigenschaften (Größe, Farbe, /// Wiederverwendbare Auswahl für die Tank-Eigenschaften (Größe, Farbe
/// Aufstellungsart und halbtransparente Darstellung). /// und Aufstellungsart). Die verfügbaren Optionen
/// stammen dynamisch aus dem `TankCatalog`.
struct TankEditor: View { struct TankEditor: View {
@Binding var tank: Tank @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 { var body: some View {
Picker("Größe", selection: $tank.size) { Picker("Größe", selection: $tank.size) {
ForEach(TankSize.allCases) { size in ForEach(availableSizes) { size in
Text(size.displayName).tag(size) Text(catalog.displayName(for: size)).tag(size)
} }
} }
Picker("Farbe", selection: $tank.color) { Picker("Farbe", selection: $tank.color) {
ForEach(TankColor.allCases) { color in ForEach(availableColors) { color in
Text(color.displayName).tag(color) Text(catalog.displayName(for: color)).tag(color)
} }
} }
@ -29,7 +41,7 @@ struct TankEditor: View {
Text("Aufstellungsart") Text("Aufstellungsart")
HStack(spacing: 12) { HStack(spacing: 12) {
ForEach(TankPlacement.allCases) { placement in ForEach(catalog.placements) { placement in
PlacementOption( PlacementOption(
placement: placement, placement: placement,
isSelected: tank.placement == placement isSelected: tank.placement == placement
@ -41,13 +53,23 @@ struct TankEditor: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
Toggle("Halbtransparent (nur Deckel)", isOn: $tank.showLidOnly) // Auswahl bei jeder Änderung auf eine gültige Katalog-Variante bringen …
.disabled(tank.placement != .belowGround) .onChange(of: tank.placement) { _, _ in normalizeSelection() }
.onChange(of: tank.placement) { _, newPlacement in .onChange(of: tank.size) { _, _ in normalizeSelection() }
// Option nur für unterirdische Tanks sinnvoll – sonst zurücksetzen. // … und Altdaten beim Öffnen selbstheilen.
if newPlacement != .belowGround { .onAppear { normalizeSelection() }
tank.showLidOnly = false
} }
/// 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 lineWidth: isSelected ? 2 : 1
) )
} }
.contentShape(.rect(cornerRadius: 12))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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.")
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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
}
}

View File

@ -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
}
]
}
}

View File

@ -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",
"°" : "°"
}
}

View File

@ -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" : {
}
}

View File

@ -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: []),
]
)

View File

@ -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"
{
}

View File

@ -0,0 +1,4 @@
import Foundation
/// Bundle for the Untitled project
public let untitledBundle = Bundle.module

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB