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>
142 lines
5.5 KiB
Swift
142 lines
5.5 KiB
Swift
//
|
||
// SafetyZone.swift
|
||
// GasTankAR
|
||
//
|
||
// Created by Dennis Nemec on 27.08.26.
|
||
//
|
||
|
||
import RealityKit
|
||
import UIKit
|
||
|
||
/// Ein Sicherheitsabstand rund um den Tank: ein Kreis mit gegebenem Radius und Farbe,
|
||
/// dessen Mittelpunkt der Tank ist.
|
||
///
|
||
/// Modular gehalten: Ein weiterer Sicherheitsabstand wird schlicht durch Hinzufügen
|
||
/// eines Eintrags zu ``SafetyZone/all`` ergänzt – die Darstellung folgt automatisch.
|
||
struct SafetyZone: Identifiable {
|
||
let id = UUID()
|
||
/// Radius des Kreises in Metern (Abstand vom Tank-Mittelpunkt).
|
||
let radius: Float
|
||
/// Farbe des Rings.
|
||
let color: UIColor
|
||
/// Anzeigename, z. B. für Barrierefreiheit oder eine spätere Legende.
|
||
let label: String
|
||
|
||
/// Alle darzustellenden Sicherheitsabstände. Zum Ergänzen genügt ein weiterer Eintrag.
|
||
static let all: [SafetyZone] = [
|
||
SafetyZone(radius: 3, color: .systemRed, label: "3 m"),
|
||
SafetyZone(radius: 5, color: .systemOrange, label: "5 m"),
|
||
]
|
||
}
|
||
|
||
/// Baut die flachen, auf dem Boden liegenden Ringe für die Sicherheitsabstände.
|
||
@MainActor
|
||
enum SafetyZoneFactory {
|
||
/// Halbe Linienbreite des Rings in Metern (Ring = Radius ± Halbbreite).
|
||
private static let lineHalfWidth: Float = 0.03
|
||
/// Anzahl der Segmente pro Ring (höher = runder).
|
||
private static let segments = 96
|
||
/// Deckkraft der Ringe.
|
||
private static let opacity: Float = 0.85
|
||
/// Texthöhe der Meter-Beschriftung in Metern.
|
||
private static let labelHeight: Float = 0.25
|
||
/// Winkelpositionen (im Bogenmaß), an denen die Beschriftung auf dem Ring liegt.
|
||
/// Mehrere Positionen sorgen dafür, dass die Meterangabe aus jeder Richtung lesbar ist.
|
||
private static let labelAngles: [Float] = [0, .pi / 2, .pi, 3 * .pi / 2]
|
||
|
||
/// Erzeugt eine Entity mit je einem Ring pro Sicherheitsabstand, zentriert im Ursprung
|
||
/// und flach in der XZ-Ebene (y = 0) liegend.
|
||
static func makeEntity(for zones: [SafetyZone] = SafetyZone.all) -> Entity {
|
||
let root = Entity()
|
||
root.name = "SafetyZones"
|
||
for zone in zones {
|
||
root.addChild(makeRing(for: zone))
|
||
}
|
||
return root
|
||
}
|
||
|
||
private static func makeRing(for zone: SafetyZone) -> Entity {
|
||
let container = Entity()
|
||
container.name = "SafetyZone-\(zone.label)"
|
||
|
||
let mesh = annulusMesh(radius: zone.radius, halfWidth: lineHalfWidth, segments: segments)
|
||
|
||
var material = UnlitMaterial(color: zone.color.withAlphaComponent(CGFloat(opacity)))
|
||
material.blending = .transparent(opacity: .init(floatLiteral: opacity))
|
||
// Beidseitig sichtbar – unabhängig von der Blickrichtung auf den Boden.
|
||
material.faceCulling = .none
|
||
|
||
container.addChild(ModelEntity(mesh: mesh, materials: [material]))
|
||
|
||
for angle in labelAngles {
|
||
container.addChild(makeLabel(for: zone, at: angle))
|
||
}
|
||
return container
|
||
}
|
||
|
||
/// Erzeugt eine flach auf dem Boden liegende Meter-Beschriftung, tangential zum Ring
|
||
/// ausgerichtet (Oberkante zeigt zur Mitte, damit sie von außen aufrecht lesbar ist).
|
||
private static func makeLabel(for zone: SafetyZone, at angle: Float) -> Entity {
|
||
let mesh = MeshResource.generateText(
|
||
zone.label,
|
||
extrusionDepth: 0.002,
|
||
font: .systemFont(ofSize: CGFloat(labelHeight), weight: .bold)
|
||
)
|
||
|
||
var material = UnlitMaterial(color: zone.color)
|
||
material.faceCulling = .none
|
||
|
||
let text = ModelEntity(mesh: mesh, materials: [material])
|
||
// Auf den eigenen Ursprung zentrieren.
|
||
let bounds = text.visualBounds(relativeTo: nil)
|
||
text.position = -bounds.center
|
||
|
||
let radialDirection = SIMD3(sin(angle), 0, cos(angle))
|
||
let tangentDirection = SIMD3(cos(angle), 0, -sin(angle))
|
||
let holder = Entity()
|
||
holder.addChild(text)
|
||
|
||
// Wie bei der Längenbeschriftung: in die Bodenebene drehen und tangential
|
||
// zum Kreis ausrichten. Der Text steht radial außerhalb des Rings.
|
||
let textClearance: Float = 0.015
|
||
let radialOffset = bounds.extents.y / 2 + textClearance
|
||
holder.position = radialDirection * (zone.radius + radialOffset)
|
||
holder.position.y = 0.01
|
||
holder.orientation = simd_quatf(
|
||
from: SIMD3(1, 0, 0),
|
||
to: tangentDirection
|
||
) * simd_quatf(
|
||
angle: -.pi / 2,
|
||
axis: tangentDirection
|
||
)
|
||
return holder
|
||
}
|
||
|
||
/// Erzeugt einen flachen Kreisring (Annulus) in der XZ-Ebene.
|
||
private static func annulusMesh(radius: Float, halfWidth: Float, segments: Int) -> MeshResource {
|
||
var positions: [SIMD3<Float>] = []
|
||
var indices: [UInt32] = []
|
||
let inner = max(0, radius - halfWidth)
|
||
let outer = radius + halfWidth
|
||
|
||
for i in 0...segments {
|
||
let angle = Float(i) / Float(segments) * 2 * .pi
|
||
let cosA = cos(angle)
|
||
let sinA = sin(angle)
|
||
positions.append(SIMD3(inner * cosA, 0, inner * sinA))
|
||
positions.append(SIMD3(outer * cosA, 0, outer * sinA))
|
||
}
|
||
|
||
for i in 0..<segments {
|
||
let base = UInt32(i * 2)
|
||
indices.append(contentsOf: [base, base + 1, base + 2])
|
||
indices.append(contentsOf: [base + 2, base + 1, base + 3])
|
||
}
|
||
|
||
var descriptor = MeshDescriptor(name: "safetyRing")
|
||
descriptor.positions = MeshBuffers.Positions(positions)
|
||
descriptor.primitives = .triangles(indices)
|
||
return (try? MeshResource.generate(from: [descriptor])) ?? .generatePlane(width: 0, depth: 0)
|
||
}
|
||
}
|