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:
Dennis Nemec
2026-09-26 19:12:39 +02:00
parent 992bb32025
commit b8536d4182
63 changed files with 2317 additions and 188 deletions

View File

@ -20,9 +20,11 @@ enum MeasurementEntityFactory {
private static let color = UIColor.systemYellow
private static let pointRadius: Float = 0.012
private static let lineThickness: Float = 0.006
private static let labelHeight: Float = 0.06
private static let labelYOffset: Float = 0.03
private static let lineThickness: Float = 0.02
private static let labelHeight: Float = 0.14
private static let measurementLabelHeight: Float = 0.09
private static let labelBaseGap: Float = 0.02
private static let textClearance: Float = 0.015
/// Kleine Kugel für einen (auch vorläufigen) Messpunkt.
static func makePoint(at position: SIMD3<Float>) -> ModelEntity {
@ -45,13 +47,90 @@ enum MeasurementEntityFactory {
configureLine(line, from: measurement.start, to: measurement.end)
container.addChild(line)
let label = makeLabelHolder()
label.position = labelPosition(from: measurement.start, to: measurement.end)
label.addChild(makeText("\(measurement.name) (\(measurement.formattedDistance))"))
let lineDirection = normalize(measurement.end - measurement.start)
var labelOrientation = simd_quatf(
from: SIMD3(1, 0, 0),
to: lineDirection
)
labelOrientation = simd_quatf(
angle: -.pi / 2,
axis: lineDirection
) * labelOrientation
let text = makeText(
"\(measurement.name) (\(measurement.formattedDistance))",
fontSize: measurementLabelHeight
)
let textBounds = text.visualBounds(relativeTo: nil)
let label = makeLabelHolder(usesBillboard: false)
label.orientation = labelOrientation
label.position = labelPosition(
from: measurement.start,
to: measurement.end,
verticalOffset: textBounds.extents.y / 2 + labelBaseGap + textClearance
)
label.addChild(text)
container.addChild(label)
return container
}
/// Erzeugt eine vorgefertigte Tankmaß-Linie mit verständlicher Beschriftung.
static func makeDimension(
_ dimension: TankDimension,
from start: SIMD3<Float>,
to end: SIMD3<Float>,
labelOffset: SIMD3<Float>
) -> Entity {
let container = Entity()
let line = makeLineBase()
configureLine(line, from: start, to: end)
container.addChild(line)
let label = makeLabelHolder(usesBillboard: false)
let lineDirection = normalize(end - start)
var labelOrientation = simd_quatf(
from: SIMD3(1, 0, 0),
to: lineDirection
)
if dimension == .length {
labelOrientation = simd_quatf(
angle: -.pi / 2,
axis: lineDirection
) * labelOrientation
}
label.orientation = labelOrientation
let distance = simd_distance(start, end)
let text = makeText("\(dimension.title): \(String(format: "%.2f m", distance))")
let textBounds = text.visualBounds(relativeTo: nil)
var safeLabelOffset = labelOffset
if abs(labelOffset.x) > abs(labelOffset.y) {
safeLabelOffset.x += labelOffset.x >= 0
? textBounds.extents.y / 2 + textClearance
: -(textBounds.extents.y / 2 + textClearance)
} else {
safeLabelOffset.y += labelOffset.y >= 0
? textBounds.extents.y / 2 + textClearance
: -(textBounds.extents.y / 2 + textClearance)
}
label.position = (start + end) / 2 + safeLabelOffset
label.addChild(text)
container.addChild(label)
return container
}
/// Aktualisiert die Beschriftung einer vorhandenen Tankmaß-Linie.
static func updateDimensionLabel(
_ entity: Entity,
dimension: TankDimension,
distance: Float
) {
guard let label = entity.findEntity(named: labelName) else { return }
label.children.forEach { $0.removeFromParent() }
label.addChild(makeText("\(dimension.title): \(String(format: "%.2f m", distance))"))
}
/// Basis-Linie der Länge 1 m (entlang +Z). Für die Live-Linie wird sie via ``configureLine`` skaliert/rotiert.
static func makeLineBase() -> ModelEntity {
ModelEntity(
@ -71,18 +150,21 @@ enum MeasurementEntityFactory {
}
/// Leerer Label-Halter (Billboard-Anker). Der Text wird als zentriertes Kind eingehängt.
static func makeLabelHolder() -> Entity {
static func makeLabelHolder(usesBillboard: Bool = true) -> Entity {
let holder = Entity()
holder.name = labelName
if usesBillboard {
holder.components.set(BillboardComponent())
}
return holder
}
/// Zentriertes Text-Entity für einen Label-Halter.
static func makeText(_ string: String) -> ModelEntity {
static func makeText(_ string: String, fontSize: Float = 0.14) -> ModelEntity {
let mesh = MeshResource.generateText(
string,
extrusionDepth: 0.001,
font: .systemFont(ofSize: CGFloat(labelHeight), weight: .semibold)
font: .systemFont(ofSize: CGFloat(fontSize), weight: .semibold)
)
var material = UnlitMaterial(color: .white)
material.faceCulling = .none
@ -92,7 +174,11 @@ enum MeasurementEntityFactory {
}
/// Position des Labels (Streckenmitte, leicht angehoben).
static func labelPosition(from start: SIMD3<Float>, to end: SIMD3<Float>) -> SIMD3<Float> {
(start + end) / 2 + SIMD3(0, labelYOffset, 0)
static func labelPosition(
from start: SIMD3<Float>,
to end: SIMD3<Float>,
verticalOffset: Float = 0.105
) -> SIMD3<Float> {
(start + end) / 2 + SIMD3(0, verticalOffset, 0)
}
}