// // 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) -> 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, to end: SIMD3, labelOffset: SIMD3 ) -> 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, to end: SIMD3) { 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, to end: SIMD3, verticalOffset: Float = 0.105 ) -> SIMD3 { (start + end) / 2 + SIMD3(0, verticalOffset, 0) } }