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

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