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())
|
||||
}
|
||||
Reference in New Issue
Block a user