147 lines
6.1 KiB
Swift
147 lines
6.1 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
|
||
}
|
||
|
||
/// Domschachtposition der tatsächlich geladenen Variante, gemessen ab dem
|
||
/// Ursprung des USDZ-Modells. Bei fehlendem Modell bleibt der Ersatzkörper zentriert.
|
||
func domeCenterX(for tank: Tank) -> Float {
|
||
catalog.resolvedDefinition(for: tank)?.domeCenterX ?? 0
|
||
}
|
||
|
||
/// 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)]
|
||
)
|
||
}
|
||
}
|