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>
185 lines
6.8 KiB
Swift
185 lines
6.8 KiB
Swift
//
|
||
// MeasurementEntityFactory.swift
|
||
// GasTankAR
|
||
//
|
||
// Created by Dennis Nemec on 28.08.26.
|
||
//
|
||
|
||
import RealityKit
|
||
import UIKit
|
||
|
||
/// Baut die AR-Visualisierung einer Strecke: zwei Endpunkt-Kugeln, die verbindende Linie
|
||
/// sowie ein Text-Label (Bezeichnung + Länge) am Mittelpunkt der Strecke.
|
||
///
|
||
/// Enthält außerdem wiederverwendbare Bausteine für die *Live*-Vorschau während des Messens
|
||
/// (Cursor-Kugel, skalierbare Linie, aktualisierbares Label).
|
||
@MainActor
|
||
enum MeasurementEntityFactory {
|
||
/// Name des Label-Halters – wird zum Wiederfinden fürs Billboarding genutzt.
|
||
static let labelName = "MeasurementLabel"
|
||
|
||
private static let color = UIColor.systemYellow
|
||
private static let pointRadius: Float = 0.012
|
||
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 {
|
||
let sphere = ModelEntity(
|
||
mesh: .generateSphere(radius: pointRadius),
|
||
materials: [UnlitMaterial(color: color)]
|
||
)
|
||
sphere.position = position
|
||
return sphere
|
||
}
|
||
|
||
/// Vollständige Strecken-Visualisierung (Punkte + Linie + Label).
|
||
static func makeMeasurement(_ measurement: Measurement) -> Entity {
|
||
let container = Entity()
|
||
container.name = "Measurement-\(measurement.id.uuidString)"
|
||
container.addChild(makePoint(at: measurement.start))
|
||
container.addChild(makePoint(at: measurement.end))
|
||
|
||
let line = makeLineBase()
|
||
configureLine(line, from: measurement.start, to: measurement.end)
|
||
container.addChild(line)
|
||
|
||
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(
|
||
mesh: .generateBox(size: SIMD3(lineThickness, lineThickness, 1)),
|
||
materials: [UnlitMaterial(color: color)]
|
||
)
|
||
}
|
||
|
||
/// Richtet eine per ``makeLineBase`` erzeugte Linie zwischen zwei Punkten aus (Länge über Z-Skalierung).
|
||
static func configureLine(_ line: Entity, from start: SIMD3<Float>, to end: SIMD3<Float>) {
|
||
let length = max(simd_distance(start, end), 0.0001)
|
||
line.transform = Transform(
|
||
scale: SIMD3(1, 1, length),
|
||
rotation: simd_quatf(from: SIMD3(0, 0, 1), to: normalize(end - start)),
|
||
translation: (start + end) / 2
|
||
)
|
||
}
|
||
|
||
/// Leerer Label-Halter (Billboard-Anker). Der Text wird als zentriertes Kind eingehängt.
|
||
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, fontSize: Float = 0.14) -> ModelEntity {
|
||
let mesh = MeshResource.generateText(
|
||
string,
|
||
extrusionDepth: 0.001,
|
||
font: .systemFont(ofSize: CGFloat(fontSize), weight: .semibold)
|
||
)
|
||
var material = UnlitMaterial(color: .white)
|
||
material.faceCulling = .none
|
||
let text = ModelEntity(mesh: mesh, materials: [material])
|
||
text.position = -text.visualBounds(relativeTo: nil).center
|
||
return text
|
||
}
|
||
|
||
/// Position des Labels (Streckenmitte, leicht angehoben).
|
||
static func labelPosition(
|
||
from start: SIMD3<Float>,
|
||
to end: SIMD3<Float>,
|
||
verticalOffset: Float = 0.105
|
||
) -> SIMD3<Float> {
|
||
(start + end) / 2 + SIMD3(0, verticalOffset, 0)
|
||
}
|
||
}
|