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:
137
GasTankAR/Services/TankCatalog.swift
Normal file
137
GasTankAR/Services/TankCatalog.swift
Normal 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())
|
||||
}
|
||||
58
GasTankAR/Services/TankCatalogLoader.swift
Normal file
58
GasTankAR/Services/TankCatalogLoader.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user