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>
141 lines
5.8 KiB
Swift
141 lines
5.8 KiB
Swift
//
|
||
// TankEntityFactory.swift
|
||
// GasTankAR
|
||
//
|
||
// Created by Dennis Nemec on 26.08.26.
|
||
//
|
||
|
||
import RealityKit
|
||
import UIKit
|
||
|
||
/// 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 {
|
||
/// 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,
|
||
/// damit die Grube nicht gequetscht wirkt.
|
||
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
|
||
|
||
/// Katalog zur Auflösung der Auswahl auf ein konkretes Modell.
|
||
private let catalog: any TankCatalogProviding
|
||
|
||
/// 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
|
||
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(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 …
|
||
let exposed = bounds.extents.y * Self.belowGroundExposedFraction
|
||
let buried = bounds.extents.y - exposed
|
||
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.
|
||
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)
|
||
|
||
let colliderBounds = model.visualBounds(relativeTo: container)
|
||
container.collision = CollisionComponent(shapes: [
|
||
ShapeResource
|
||
.generateBox(size: colliderBounds.extents)
|
||
.offsetBy(translation: colliderBounds.center)
|
||
])
|
||
|
||
// Auswahl-Markierung (Eck-Winkel) – zunächst versteckt, wird bei Auswahl eingeblendet.
|
||
let highlight = TankHighlightFactory.makeHighlight(for: colliderBounds)
|
||
highlight.isEnabled = false
|
||
container.addChild(highlight)
|
||
|
||
return container
|
||
}
|
||
|
||
/// 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 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),
|
||
materials: [SimpleMaterial(color: .white, isMetallic: false)]
|
||
)
|
||
}
|
||
}
|