Files
Gas-Tank-AR-App/GasTankAR/Views/TankAR/TankEntityFactory.swift
Dennis Nemec b8536d4182 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>
2026-09-26 19:12:39 +02:00

141 lines
5.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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