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