// // 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)] ) } }