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:
@ -14,6 +14,7 @@ struct ARExperienceView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(SettingsKeys.lidarFeaturesEnabled) private var lidarEnabled = false
|
||||
@AppStorage(SettingsKeys.arExposure) private var arExposure = 1.0
|
||||
|
||||
let project: Project
|
||||
|
||||
@ -41,6 +42,9 @@ struct ARExperienceView: View {
|
||||
if controller.pipeRoutingPhase == .placing {
|
||||
pipeRoutingOverlay(controller: controller)
|
||||
}
|
||||
if controller.selectedPipeSegmentIndex != nil {
|
||||
pipeSegmentRemovalOverlay(controller: controller)
|
||||
}
|
||||
Color.white
|
||||
.opacity(flashOpacity)
|
||||
.ignoresSafeArea()
|
||||
@ -51,8 +55,22 @@ struct ARExperienceView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(project.customerName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay(alignment: .topLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "arrow.left")
|
||||
.font(.title2.weight(.semibold))
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white)
|
||||
.shadow(radius: 3)
|
||||
.accessibilityLabel("Zurück")
|
||||
.padding(.leading, 12)
|
||||
.safeAreaPadding(.top)
|
||||
}
|
||||
.sheet(isPresented: $showMeasurements) {
|
||||
if let controller {
|
||||
MeasurementListView(controller: controller) {
|
||||
@ -77,7 +95,11 @@ struct ARExperienceView: View {
|
||||
.task {
|
||||
guard controller == nil else { return }
|
||||
let persistence = ProjectPersistenceAdapter(project: project, modelContext: modelContext)
|
||||
controller = ARSessionController(persistence: persistence, useLiDAR: lidarEnabled)
|
||||
controller = ARSessionController(
|
||||
persistence: persistence,
|
||||
useLiDAR: lidarEnabled,
|
||||
arExposure: arExposure
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -185,6 +207,32 @@ struct ARExperienceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func pipeSegmentRemovalOverlay(controller: ARSessionController) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text("Verbindung entfernen?")
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Button("Abbrechen") {
|
||||
controller.cancelPipeSegmentRemoval()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Entfernen", role: .destructive) {
|
||||
controller.confirmPipeSegmentRemoval()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial, in: .capsule)
|
||||
}
|
||||
.padding()
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// MARK: - Rohrführung-Overlay
|
||||
|
||||
/// Overlay für die Rohrführung: pinkes Fadenkreuz (Vollbild-Mitte), Info-Banner oben,
|
||||
|
||||
@ -16,9 +16,13 @@ protocol ARProjectPersisting: AnyObject {
|
||||
var tank: Tank { get }
|
||||
var worldMap: ARWorldMap? { get }
|
||||
var tankTransform: [Float]? { get }
|
||||
var savedMeasurements: [Measurement] { get }
|
||||
var savedPipeRoute: PipeRoute { get }
|
||||
|
||||
func saveWorldMap(_ data: Data)
|
||||
func saveTankTransform(_ transform: [Float])
|
||||
func saveMeasurements(_ measurements: [Measurement])
|
||||
func savePipeRoute(_ route: PipeRoute)
|
||||
func clearTankTransform()
|
||||
func savePhoto(_ imageData: Data, date: Date, coordinate: CLLocationCoordinate2D?)
|
||||
}
|
||||
@ -38,6 +42,16 @@ final class ProjectPersistenceAdapter: ARProjectPersisting {
|
||||
var worldMap: ARWorldMap? { project.worldMap }
|
||||
var tankTransform: [Float]? { project.tankTransform }
|
||||
|
||||
var savedMeasurements: [Measurement] {
|
||||
guard let data = project.measurementsData else { return [] }
|
||||
return (try? JSONDecoder().decode([Measurement].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
var savedPipeRoute: PipeRoute {
|
||||
guard let data = project.pipeRouteData else { return PipeRoute() }
|
||||
return (try? JSONDecoder().decode(PipeRoute.self, from: data)) ?? PipeRoute()
|
||||
}
|
||||
|
||||
func saveWorldMap(_ data: Data) {
|
||||
project.worldMapData = data
|
||||
try? modelContext.save()
|
||||
@ -48,6 +62,16 @@ final class ProjectPersistenceAdapter: ARProjectPersisting {
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
func saveMeasurements(_ measurements: [Measurement]) {
|
||||
project.measurementsData = try? JSONEncoder().encode(measurements)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
func savePipeRoute(_ route: PipeRoute) {
|
||||
project.pipeRouteData = try? JSONEncoder().encode(route)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
func clearTankTransform() {
|
||||
project.tankTransform = nil
|
||||
try? modelContext.save()
|
||||
|
||||
@ -32,6 +32,20 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
private(set) var measurements: [Measurement] = []
|
||||
/// Aktuelle Phase des Messvorgangs.
|
||||
private(set) var measurementPhase: MeasurementPhase = .idle
|
||||
/// Vom Nutzer aktivierte, automatisch am Tank angelegte Maße.
|
||||
private(set) var visibleTankDimensions: Set<TankDimension> = []
|
||||
/// Transparenz des vergrabenen Tankkörpers: 0 = opak, 1 = vollständig transparent.
|
||||
private(set) var undergroundTransparency: Double = 1.0 - Double(TankDepthFadeMaterial.belowAlpha)
|
||||
/// Ob die Grube beim aktuell platzierten unterirdischen Tank sichtbar ist.
|
||||
private(set) var showsPit: Bool
|
||||
/// Unterirdische Tanks bieten zusätzliche Darstellungsoptionen.
|
||||
var isUndergroundTank: Bool {
|
||||
persistence.tank.placement == .belowGround
|
||||
}
|
||||
/// Ob der aktuell platzierte Tank halbtransparent dargestellt wird.
|
||||
var isSemiTransparentTank: Bool {
|
||||
isUndergroundTank && !showsPit
|
||||
}
|
||||
/// Live-Distanz (Meter) zwischen erstem Punkt und aktueller Bildschirmmitte – für die UI.
|
||||
private(set) var liveDistance: Float?
|
||||
|
||||
@ -47,7 +61,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
@ObservationIgnored private let persistence: ARProjectPersisting
|
||||
@ObservationIgnored private let useLiDAR: Bool
|
||||
@ObservationIgnored private let configFactory: SessionConfigurationFactory
|
||||
@ObservationIgnored private let entityFactory = TankEntityFactory()
|
||||
@ObservationIgnored private let entityFactory: TankEntityFactory
|
||||
@ObservationIgnored private var machine: ARFlowStateMachine
|
||||
|
||||
@ObservationIgnored private weak var arView: ARView?
|
||||
@ -61,8 +75,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
@ObservationIgnored private var measurementAnchor: AnchorEntity?
|
||||
/// Container der fertigen Strecken, nach `Measurement.id`.
|
||||
@ObservationIgnored private var measurementEntities: [UUID: Entity] = [:]
|
||||
@ObservationIgnored private var tankDimensionEntities: [TankDimension: Entity] = [:]
|
||||
@ObservationIgnored private var tankBaseExtents: SIMD3<Float>?
|
||||
@ObservationIgnored private var lastTankDimensionScale: SIMD3<Float>?
|
||||
/// Label-Halter für das Billboarding (Ausrichtung zur Kamera).
|
||||
@ObservationIgnored private var measurementLabels: [Entity] = []
|
||||
@ObservationIgnored private var tankDimensionLabels: [Entity] = []
|
||||
/// Vorläufige Punkt-Kugeln während eines laufenden Messvorgangs.
|
||||
@ObservationIgnored private var previewPointEntities: [Entity] = []
|
||||
@ObservationIgnored private var pendingMeasurementStart: SIMD3<Float>?
|
||||
@ -79,6 +97,9 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
@ObservationIgnored private var pipeAnchor: AnchorEntity?
|
||||
@ObservationIgnored private var pipeWaypointEntities: [Entity] = []
|
||||
@ObservationIgnored private var pipeSegmentEntities: [Entity] = []
|
||||
@ObservationIgnored private var pipeSegmentIndices: [Int] = []
|
||||
/// Index der Verbindung, die nach Doppeltipp bestätigt entfernt werden kann.
|
||||
private(set) var selectedPipeSegmentIndex: Int?
|
||||
/// Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte.
|
||||
@ObservationIgnored private var pipeLiveSegment: ModelEntity?
|
||||
/// Per-Frame-Callback zum Ausrichten der Labels zur Kamera.
|
||||
@ -106,14 +127,18 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
private static let anchorDetectionTimeout: Duration = .seconds(20)
|
||||
private static let relocationTimeout: Duration = .seconds(15)
|
||||
|
||||
init(persistence: ARProjectPersisting, useLiDAR: Bool) {
|
||||
init(persistence: ARProjectPersisting, useLiDAR: Bool, arExposure: Double = 1.0) {
|
||||
self.persistence = persistence
|
||||
self.useLiDAR = useLiDAR
|
||||
self.showsPit = persistence.tank.placement == .belowGround && !persistence.tank.showLidOnly
|
||||
self.entityFactory = TankEntityFactory(aboveGroundLightMultiplier: Float(arExposure))
|
||||
self.configFactory = SessionConfigurationFactory(
|
||||
useLiDAR: useLiDAR,
|
||||
referenceImages: ReferenceImageProvider.load()
|
||||
)
|
||||
self.machine = ARFlowStateMachine(hasStoredTransform: persistence.tankTransform != nil)
|
||||
self.measurements = persistence.savedMeasurements
|
||||
self.pipeRoute = persistence.savedPipeRoute
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
@ -145,13 +170,22 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
view.addGestureRecognizer(tap)
|
||||
|
||||
let doubleTap = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handlePipeSegmentDoubleTap(_:))
|
||||
)
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
view.addGestureRecognizer(doubleTap)
|
||||
tap.require(toFail: doubleTap)
|
||||
|
||||
// Pro Frame die Mess-Labels zur Kamera ausrichten (Billboarding).
|
||||
sceneUpdateSubscription = view.scene.subscribe(to: SceneEvents.Update.self) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
MainActor.assumeIsolated { self.onSceneUpdate() }
|
||||
}
|
||||
|
||||
entityFactory.preload()
|
||||
entityFactory.preload(persistence.tank)
|
||||
restorePersistedAnnotations()
|
||||
startMappingPoll()
|
||||
startLocationUpdates()
|
||||
send(.started)
|
||||
@ -184,6 +218,50 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
func retryRelocation() { send(.retryRelocationTapped) }
|
||||
func deselectTank() { isTankSelected = false }
|
||||
|
||||
/// Passt ausschließlich die Transparenz des vergrabenen Tankkörpers an.
|
||||
func setUndergroundTransparency(_ value: Double) {
|
||||
guard isSemiTransparentTank else { return }
|
||||
undergroundTransparency = min(max(value, 0), 1)
|
||||
updateDepthFadeGroundY()
|
||||
}
|
||||
|
||||
/// Schaltet die Grube eines unterirdischen Tanks direkt im AR-Modus um.
|
||||
func setShowsPit(_ visible: Bool) {
|
||||
guard isUndergroundTank, visible != showsPit,
|
||||
let currentTank = tankEntity,
|
||||
let tankAnchor else { return }
|
||||
|
||||
showsPit = visible
|
||||
var tank = persistence.tank
|
||||
tank.showLidOnly = !visible
|
||||
let transform = currentTank.transform
|
||||
|
||||
currentTank.removeFromParent()
|
||||
tankDimensionEntities.removeAll()
|
||||
tankDimensionLabels.removeAll()
|
||||
tankBaseExtents = nil
|
||||
lastTankDimensionScale = nil
|
||||
|
||||
let replacement = entityFactory.makeContainer(for: tank)
|
||||
replacement.transform = transform
|
||||
tankAnchor.addChild(replacement)
|
||||
tankEntity = replacement
|
||||
installManipulationGestures(on: replacement)
|
||||
|
||||
updateDepthFadeGroundY()
|
||||
updateSafetyZones()
|
||||
updateTankDimensions()
|
||||
}
|
||||
|
||||
/// Setzt die Tank-Skalierung auf die Originalgröße zurück.
|
||||
func resetTankScale() {
|
||||
guard let tankEntity else { return }
|
||||
tankEntity.scale = SIMD3<Float>(repeating: 1)
|
||||
updateSafetyZones()
|
||||
updateTankDimensionValues()
|
||||
persistTankTransform()
|
||||
}
|
||||
|
||||
// MARK: - Tank-Feinjustierung (über das Steuer-Panel)
|
||||
|
||||
private static let heightStep: Float = 0.05
|
||||
@ -261,7 +339,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
/// Standort für die spätere Karten-Verortung – im Projekt.
|
||||
func captureSnapshot() {
|
||||
guard let arView else { return }
|
||||
let coordinate = currentLocation?.coordinate
|
||||
let coordinate = locationManager.location?.coordinate ?? currentLocation?.coordinate
|
||||
arView.snapshot(saveToHDR: false) { [weak self] image in
|
||||
guard let image else { return }
|
||||
let date = Date()
|
||||
@ -276,6 +354,16 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
|
||||
// MARK: - Ausmessen (Strecken)
|
||||
|
||||
/// Schaltet ein automatisch am Tank angelegtes Maß ein oder aus.
|
||||
func setTankDimension(_ dimension: TankDimension, visible: Bool) {
|
||||
if visible {
|
||||
visibleTankDimensions.insert(dimension)
|
||||
} else {
|
||||
visibleTankDimensions.remove(dimension)
|
||||
}
|
||||
updateTankDimensions()
|
||||
}
|
||||
|
||||
/// Startet einen neuen Messvorgang; der Nutzer tippt danach zwei Punkte an.
|
||||
func startMeasurement() {
|
||||
clearMeasurementPreview()
|
||||
@ -306,6 +394,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
end: end
|
||||
)
|
||||
measurements.append(measurement)
|
||||
persistence.saveMeasurements(measurements)
|
||||
renderMeasurement(measurement)
|
||||
|
||||
clearMeasurementPreview()
|
||||
@ -327,6 +416,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
}
|
||||
}
|
||||
measurements.remove(atOffsets: offsets)
|
||||
persistence.saveMeasurements(measurements)
|
||||
}
|
||||
|
||||
/// Setzt den aktuellen Punkt aus der Bildschirmmitte (wie beim Platzieren des Tanks).
|
||||
@ -375,6 +465,130 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
}
|
||||
}
|
||||
|
||||
private func restorePersistedAnnotations() {
|
||||
for measurement in measurements {
|
||||
renderMeasurement(measurement)
|
||||
}
|
||||
|
||||
rebuildPipeRouteEntities()
|
||||
}
|
||||
|
||||
private func rebuildPipeRouteEntities() {
|
||||
pipeWaypointEntities.forEach { $0.removeFromParent() }
|
||||
pipeSegmentEntities.forEach { $0.removeFromParent() }
|
||||
pipeWaypointEntities.removeAll()
|
||||
pipeSegmentEntities.removeAll()
|
||||
pipeSegmentIndices.removeAll()
|
||||
|
||||
guard !pipeRoute.waypoints.isEmpty else { return }
|
||||
let anchor = ensurePipeAnchor()
|
||||
|
||||
for waypoint in pipeRoute.waypoints {
|
||||
let entity = PipeRouteEntityFactory.makeWaypoint(at: waypoint)
|
||||
anchor.addChild(entity)
|
||||
pipeWaypointEntities.append(entity)
|
||||
}
|
||||
|
||||
for (index, pair) in zip(pipeRoute.waypoints, pipeRoute.waypoints.dropFirst()).enumerated()
|
||||
where !pipeRoute.isSegmentRemoved(at: index) {
|
||||
let segment = PipeRouteEntityFactory.makeSegment(from: pair.0, to: pair.1)
|
||||
anchor.addChild(segment)
|
||||
pipeSegmentEntities.append(segment)
|
||||
pipeSegmentIndices.append(index)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTankDimensions() {
|
||||
tankDimensionEntities.values.forEach { $0.removeFromParent() }
|
||||
tankDimensionEntities.removeAll()
|
||||
tankDimensionLabels.removeAll()
|
||||
|
||||
guard let tankEntity,
|
||||
let tankModel = tankEntity.findEntity(named: TankEntityFactory.modelEntityName) else { return }
|
||||
// Nur das eigentliche Modell vermessen; Grube, Occlusion-Maske und Markierung
|
||||
// dürfen die Tankmaße nicht beeinflussen.
|
||||
let bounds = tankModel.visualBounds(relativeTo: tankEntity)
|
||||
tankBaseExtents = bounds.extents
|
||||
lastTankDimensionScale = tankEntity.scale
|
||||
guard !visibleTankDimensions.isEmpty else { return }
|
||||
let lower = bounds.min
|
||||
let upper = bounds.max
|
||||
let center = bounds.center
|
||||
let dimensionGap = Swift.max(Swift.max(bounds.extents.x, bounds.extents.y), bounds.extents.z) * 0.08
|
||||
let labelGap: Float = 0.02
|
||||
|
||||
for dimension in visibleTankDimensions {
|
||||
let start: SIMD3<Float>
|
||||
let end: SIMD3<Float>
|
||||
let labelOffset: SIMD3<Float>
|
||||
|
||||
switch dimension {
|
||||
case .height:
|
||||
start = SIMD3(upper.x + dimensionGap, lower.y, center.z)
|
||||
end = SIMD3(upper.x + dimensionGap, upper.y, center.z)
|
||||
labelOffset = SIMD3(labelGap, 0, 0)
|
||||
case .length:
|
||||
// Die Längenlinie liegt direkt an der unteren Modellkante. Ein
|
||||
// zusätzlicher Abstand nach unten würde bei unterirdischen Tanks
|
||||
// unter der Grubensohle liegen und dadurch verdeckt werden.
|
||||
start = SIMD3(lower.x - dimensionGap, lower.y, lower.z)
|
||||
end = SIMD3(lower.x - dimensionGap, lower.y, upper.z)
|
||||
labelOffset = SIMD3(-labelGap, 0, 0)
|
||||
case .width:
|
||||
start = SIMD3(lower.x, upper.y + dimensionGap, center.z)
|
||||
end = SIMD3(upper.x, upper.y + dimensionGap, center.z)
|
||||
labelOffset = SIMD3(0, labelGap, 0)
|
||||
}
|
||||
|
||||
let entity = MeasurementEntityFactory.makeDimension(
|
||||
dimension,
|
||||
from: start,
|
||||
to: end,
|
||||
labelOffset: labelOffset
|
||||
)
|
||||
tankEntity.addChild(entity)
|
||||
tankDimensionEntities[dimension] = entity
|
||||
if let label = entity.findEntity(named: MeasurementEntityFactory.labelName) {
|
||||
tankDimensionLabels.append(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTankDimensionValues() {
|
||||
guard let tankEntity,
|
||||
let tankBaseExtents,
|
||||
!visibleTankDimensions.isEmpty else { return }
|
||||
|
||||
let scale = tankEntity.scale
|
||||
guard lastTankDimensionScale != scale else { return }
|
||||
lastTankDimensionScale = scale
|
||||
|
||||
let absoluteScale = SIMD3<Float>(
|
||||
abs(scale.x),
|
||||
abs(scale.y),
|
||||
abs(scale.z)
|
||||
)
|
||||
let scaledExtents = tankBaseExtents * absoluteScale
|
||||
|
||||
for dimension in visibleTankDimensions {
|
||||
guard let entity = tankDimensionEntities[dimension] else { continue }
|
||||
let distance: Float
|
||||
switch dimension {
|
||||
case .height:
|
||||
distance = scaledExtents.y
|
||||
case .length:
|
||||
distance = scaledExtents.z
|
||||
case .width:
|
||||
distance = scaledExtents.x
|
||||
}
|
||||
MeasurementEntityFactory.updateDimensionLabel(
|
||||
entity,
|
||||
dimension: dimension,
|
||||
distance: distance
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureMeasurementAnchor() -> AnchorEntity {
|
||||
if let measurementAnchor { return measurementAnchor }
|
||||
let anchor = AnchorEntity(world: matrix_identity_float4x4)
|
||||
@ -392,7 +606,6 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
private func onSceneUpdate() {
|
||||
updateLivePreview()
|
||||
updatePipeLivePreview()
|
||||
billboardLabels()
|
||||
}
|
||||
|
||||
/// Aktualisiert Cursor, Live-Linie und Distanz-Label anhand der aktuellen Bildschirmmitte.
|
||||
@ -428,7 +641,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
liveDistance = distance
|
||||
if let holder = liveLabelEntity {
|
||||
holder.children.forEach { $0.removeFromParent() }
|
||||
holder.addChild(MeasurementEntityFactory.makeText(String(format: "%.2f m", distance)))
|
||||
holder.addChild(
|
||||
MeasurementEntityFactory.makeText(
|
||||
String(format: "%.2f m", distance),
|
||||
fontSize: 0.09
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -460,7 +678,7 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
private func billboardLabels() {
|
||||
guard let arView else { return }
|
||||
let camera = arView.cameraTransform.translation
|
||||
var labels = measurementLabels
|
||||
var labels = measurementLabels + tankDimensionLabels
|
||||
if let liveLabelEntity, liveLabelEntity.isEnabled { labels.append(liveLabelEntity) }
|
||||
guard !labels.isEmpty else { return }
|
||||
|
||||
@ -494,11 +712,13 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
let segment = PipeRouteEntityFactory.makeSegment(from: previous, to: position)
|
||||
anchor.addChild(segment)
|
||||
pipeSegmentEntities.append(segment)
|
||||
pipeSegmentIndices.append(pipeRoute.waypoints.count - 1)
|
||||
}
|
||||
let waypoint = PipeRouteEntityFactory.makeWaypoint(at: position)
|
||||
anchor.addChild(waypoint)
|
||||
pipeWaypointEntities.append(waypoint)
|
||||
pipeRoute.add(position)
|
||||
persistence.savePipeRoute(pipeRoute)
|
||||
}
|
||||
|
||||
/// Entfernt den zuletzt gesetzten Wegpunkt (und sein Segment).
|
||||
@ -506,6 +726,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
guard pipeRoute.removeLast() != nil else { return }
|
||||
pipeWaypointEntities.popLast()?.removeFromParent()
|
||||
pipeSegmentEntities.popLast()?.removeFromParent()
|
||||
pipeSegmentIndices.popLast()
|
||||
persistence.savePipeRoute(pipeRoute)
|
||||
}
|
||||
|
||||
/// Beendet das Setzen; der fertige Pfad bleibt sichtbar.
|
||||
@ -533,8 +755,11 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
pipeSegmentEntities.forEach { $0.removeFromParent() }
|
||||
pipeWaypointEntities.removeAll()
|
||||
pipeSegmentEntities.removeAll()
|
||||
pipeSegmentIndices.removeAll()
|
||||
selectedPipeSegmentIndex = nil
|
||||
hidePipeLivePreview()
|
||||
pipeRoute.clear()
|
||||
persistence.savePipeRoute(pipeRoute)
|
||||
}
|
||||
|
||||
/// Zeichnet das Live-Segment vom letzten Wegpunkt zur aktuellen Bildschirmmitte.
|
||||
@ -745,11 +970,12 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
updateDepthFadeGroundY()
|
||||
updateSceneOcclusion()
|
||||
updateSafetyZones()
|
||||
updateTankDimensions()
|
||||
}
|
||||
|
||||
/// Baut die Sicherheitsabstände auf, aktualisiert ihre Bodenposition oder entfernt sie.
|
||||
/// Die Ringe hängen am Tank-Anker (nicht am Tank selbst), damit Skalierung/Rotation
|
||||
/// des Tanks sie nicht verzerrt; sie werden nur auf die Bodenmitte des Tanks gesetzt.
|
||||
/// des Tanks sie nicht verzerrt. Ihr Mittelpunkt liegt am seitlichen Domschacht.
|
||||
private func updateSafetyZones() {
|
||||
guard showSafetyZones, let tankAnchor, let tankEntity else {
|
||||
safetyZoneEntity?.removeFromParent()
|
||||
@ -765,7 +991,26 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
tankAnchor.addChild(entity)
|
||||
safetyZoneEntity = entity
|
||||
}
|
||||
entity.position = tankEntity.position(relativeTo: tankAnchor)
|
||||
|
||||
let tankPosition = tankEntity.position(relativeTo: tankAnchor)
|
||||
let localDomeOffset = SIMD4<Float>(0, 0, safetyZoneDomeOffset, 0)
|
||||
let tankTransform = tankEntity.transformMatrix(relativeTo: tankAnchor)
|
||||
let domeOffset = tankTransform * localDomeOffset
|
||||
|
||||
entity.position = tankPosition + SIMD3(domeOffset.x, domeOffset.y, domeOffset.z)
|
||||
}
|
||||
|
||||
/// Abstand vom Tankmittelpunkt zum seitlichen Domschacht entlang der lokalen
|
||||
/// Tank-Längsachse (+Z), abgeleitet aus den Vorgabemaßen.
|
||||
private var safetyZoneDomeOffset: Float {
|
||||
switch persistence.tank.size {
|
||||
case .t1_2:
|
||||
return 2.460 / 2
|
||||
case .t2_1:
|
||||
return 4.255 / 2
|
||||
case .t2_9:
|
||||
return 5.500 / 2
|
||||
}
|
||||
}
|
||||
|
||||
private func installManipulationGestures(on entity: ModelEntity) {
|
||||
@ -779,8 +1024,11 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
@objc private func handleManipulationChanged(_ recognizer: UIGestureRecognizer) {
|
||||
updateSafetyZones() // Ringe live mit dem Tank mitführen.
|
||||
switch recognizer.state {
|
||||
case .ended, .cancelled, .failed: persistTankTransform()
|
||||
default: break
|
||||
case .ended, .cancelled, .failed:
|
||||
updateTankDimensionValues()
|
||||
persistTankTransform()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -800,11 +1048,59 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePipeSegmentDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
guard state == .immersive,
|
||||
measurementPhase == .idle,
|
||||
pipeRoutingPhase == .idle,
|
||||
let arView else { return }
|
||||
|
||||
let point = gesture.location(in: arView)
|
||||
guard let hit = arView.entity(at: point) else { return }
|
||||
|
||||
for (entityIndex, segment) in pipeSegmentEntities.enumerated() {
|
||||
if Self.entity(hit, isPartOf: segment) {
|
||||
if let selectedPipeSegmentIndex {
|
||||
setPipeSegmentHighlight(selectedPipeSegmentIndex, highlighted: false)
|
||||
}
|
||||
selectedPipeSegmentIndex = pipeSegmentIndices[entityIndex]
|
||||
setPipeSegmentHighlight(pipeSegmentIndices[entityIndex], highlighted: true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelPipeSegmentRemoval() {
|
||||
if let selectedPipeSegmentIndex {
|
||||
setPipeSegmentHighlight(selectedPipeSegmentIndex, highlighted: false)
|
||||
}
|
||||
selectedPipeSegmentIndex = nil
|
||||
}
|
||||
|
||||
func confirmPipeSegmentRemoval() {
|
||||
guard selectedPipeSegmentIndex != nil else { return }
|
||||
pipeRoute.removeSegmentAndOrphanedWaypoints(at: selectedPipeSegmentIndex!)
|
||||
selectedPipeSegmentIndex = nil
|
||||
rebuildPipeRouteEntities()
|
||||
persistence.savePipeRoute(pipeRoute)
|
||||
}
|
||||
|
||||
private func setPipeSegmentHighlight(_ index: Int, highlighted: Bool) {
|
||||
guard let entityIndex = pipeSegmentIndices.firstIndex(of: index) else { return }
|
||||
PipeRouteEntityFactory.setSegmentColor(
|
||||
pipeSegmentEntities[entityIndex],
|
||||
color: highlighted ? .systemYellow : .systemPink
|
||||
)
|
||||
}
|
||||
|
||||
private func removeTank() {
|
||||
if let tankAnchor { arView?.scene.removeAnchor(tankAnchor) }
|
||||
tankAnchor = nil
|
||||
tankEntity = nil
|
||||
safetyZoneEntity = nil
|
||||
tankDimensionEntities.removeAll()
|
||||
tankDimensionLabels.removeAll()
|
||||
tankBaseExtents = nil
|
||||
lastTankDimensionScale = nil
|
||||
isTankSelected = false
|
||||
}
|
||||
|
||||
@ -819,7 +1115,8 @@ final class ARSessionController: NSObject, ARSessionDelegate, ARCoachingOverlayV
|
||||
private func updateDepthFadeGroundY() {
|
||||
guard persistence.tank.placement == .belowGround, let tankEntity else { return }
|
||||
let groundY = tankEntity.position(relativeTo: nil).y
|
||||
TankDepthFadeMaterial.updateGroundY(groundY, on: tankEntity)
|
||||
let belowAlpha = 1.0 - Float(undergroundTransparency)
|
||||
TankDepthFadeMaterial.updateGroundY(groundY, belowAlpha: belowAlpha, on: tankEntity)
|
||||
}
|
||||
|
||||
// MARK: - Persistenz & Status
|
||||
|
||||
@ -9,7 +9,23 @@ import Foundation
|
||||
import simd
|
||||
|
||||
/// Eine benannte Strecke zwischen zwei im Raum gewählten Punkten (Welt-Koordinaten).
|
||||
struct Measurement: Identifiable {
|
||||
enum TankDimension: String, CaseIterable, Hashable, Identifiable {
|
||||
case height
|
||||
case length
|
||||
case width
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .height: "Höhe"
|
||||
case .length: "Länge"
|
||||
case .width: "Breite"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Measurement: Identifiable, Codable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
let start: SIMD3<Float>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,18 +16,23 @@ struct MeasurementListView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if controller.measurements.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Keine Strecken", systemImage: "ruler")
|
||||
} description: {
|
||||
Text("Füge eine Strecke hinzu und wähle dafür zwei Punkte im Raum.")
|
||||
} actions: {
|
||||
Button("Strecke hinzufügen", systemImage: "plus", action: onAddStrecke)
|
||||
.buttonStyle(.borderedProminent)
|
||||
List {
|
||||
Section("Tankmaße") {
|
||||
Toggle("Alle anzeigen", isOn: allDimensionsBinding)
|
||||
|
||||
ForEach(TankDimension.allCases) { dimension in
|
||||
Toggle(
|
||||
dimension.title,
|
||||
isOn: dimensionBinding(for: dimension)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
}
|
||||
|
||||
Section("Eigene Strecken") {
|
||||
if controller.measurements.isEmpty {
|
||||
Text("Noch keine eigenen Strecken angelegt.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(controller.measurements) { measurement in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@ -56,4 +61,22 @@ struct MeasurementListView: View {
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private var allDimensionsBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { TankDimension.allCases.allSatisfy { controller.visibleTankDimensions.contains($0) } },
|
||||
set: { isVisible in
|
||||
for dimension in TankDimension.allCases {
|
||||
controller.setTankDimension(dimension, visible: isVisible)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func dimensionBinding(for dimension: TankDimension) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { controller.visibleTankDimensions.contains(dimension) },
|
||||
set: { controller.setTankDimension(dimension, visible: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,16 +10,56 @@ import simd
|
||||
|
||||
/// Eine Rohrführung als geordnete Folge von Wegpunkten (Welt-Koordinaten), die zu einem
|
||||
/// zusammenhängenden Pfad verbunden werden – visualisiert die geplante Rohrverlegung.
|
||||
struct PipeRoute {
|
||||
struct PipeRoute: Codable {
|
||||
private(set) var waypoints: [SIMD3<Float>] = []
|
||||
private(set) var removedSegmentIndices: Set<Int> = []
|
||||
|
||||
var count: Int { waypoints.count }
|
||||
|
||||
/// Gesamtlänge des Pfads über alle Segmente in Metern.
|
||||
/// Gesamtlänge des Pfads über alle aktiven Segmente in Metern.
|
||||
var totalLength: Float {
|
||||
guard waypoints.count > 1 else { return 0 }
|
||||
return zip(waypoints, waypoints.dropFirst())
|
||||
.reduce(0) { $0 + simd_distance($1.0, $1.1) }
|
||||
.enumerated()
|
||||
.filter { !removedSegmentIndices.contains($0.offset) }
|
||||
.reduce(0) { $0 + simd_distance($1.element.0, $1.element.1) }
|
||||
}
|
||||
|
||||
func isSegmentRemoved(at index: Int) -> Bool {
|
||||
removedSegmentIndices.contains(index)
|
||||
}
|
||||
|
||||
mutating func removeSegmentAndOrphanedWaypoints(at index: Int) {
|
||||
guard index >= 0, index < waypoints.count - 1 else { return }
|
||||
removedSegmentIndices.insert(index)
|
||||
|
||||
var connected = Array(repeating: false, count: waypoints.count)
|
||||
for segmentIndex in 0..<(waypoints.count - 1)
|
||||
where !removedSegmentIndices.contains(segmentIndex) {
|
||||
connected[segmentIndex] = true
|
||||
connected[segmentIndex + 1] = true
|
||||
}
|
||||
|
||||
let oldToNew = connected.enumerated().reduce(into: [Int: Int]()) { result, item in
|
||||
if item.element {
|
||||
result[item.offset] = result.count
|
||||
}
|
||||
}
|
||||
|
||||
let oldRemovedSegments = removedSegmentIndices
|
||||
waypoints = waypoints.enumerated()
|
||||
.filter { connected[$0.offset] }
|
||||
.map(\.element)
|
||||
|
||||
removedSegmentIndices = Set(
|
||||
oldRemovedSegments.compactMap { oldIndex in
|
||||
guard let newIndex = oldToNew[oldIndex],
|
||||
oldToNew[oldIndex + 1] != nil else {
|
||||
return nil
|
||||
}
|
||||
return newIndex
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var formattedLength: String {
|
||||
@ -32,11 +72,20 @@ struct PipeRoute {
|
||||
|
||||
@discardableResult
|
||||
mutating func removeLast() -> SIMD3<Float>? {
|
||||
waypoints.isEmpty ? nil : waypoints.removeLast()
|
||||
guard !waypoints.isEmpty else { return nil }
|
||||
let removedIndex = waypoints.count - 2
|
||||
removedSegmentIndices.remove(removedIndex)
|
||||
removedSegmentIndices = Set(
|
||||
removedSegmentIndices.map { index in
|
||||
index > removedIndex ? index - 1 : index
|
||||
}
|
||||
)
|
||||
return waypoints.removeLast()
|
||||
}
|
||||
|
||||
mutating func clear() {
|
||||
waypoints.removeAll()
|
||||
removedSegmentIndices.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,9 +31,18 @@ enum PipeRouteEntityFactory {
|
||||
static func makeSegment(from start: SIMD3<Float>, to end: SIMD3<Float>) -> ModelEntity {
|
||||
let segment = makeSegmentBase()
|
||||
configureSegment(segment, from: start, to: end)
|
||||
segment.collision = CollisionComponent(shapes: [
|
||||
ShapeResource.generateBox(size: SIMD3(0.08, 0.08, 1))
|
||||
])
|
||||
segment.components.set(InputTargetComponent())
|
||||
return segment
|
||||
}
|
||||
|
||||
static func setSegmentColor(_ segment: Entity, color: UIColor) {
|
||||
guard let model = segment as? ModelEntity else { return }
|
||||
model.model?.materials = [UnlitMaterial(color: color)]
|
||||
}
|
||||
|
||||
/// Basis-Segment der Länge 1 m (entlang +Z) – für die Live-Vorschau wiederverwendbar.
|
||||
static func makeSegmentBase() -> ModelEntity {
|
||||
ModelEntity(
|
||||
|
||||
@ -91,11 +91,24 @@ enum SafetyZoneFactory {
|
||||
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)
|
||||
holder.position = SIMD3(sin(angle) * zone.radius, 0.01, cos(angle) * zone.radius)
|
||||
holder.orientation = simd_quatf(angle: angle, axis: SIMD3(0, 1, 0))
|
||||
* simd_quatf(angle: -.pi / 2, axis: SIMD3(1, 0, 0))
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import RealityKit
|
||||
import Metal
|
||||
import UIKit
|
||||
|
||||
/// Kapselt das Höhen-Fade-CustomMaterial für den unterirdischen Tank:
|
||||
/// verblasst die Opacity anhand der Welt-Höhe (Kopf opak, vergrabener Teil transparent).
|
||||
@ -22,7 +23,9 @@ enum TankDepthFadeMaterial {
|
||||
return CustomMaterial.SurfaceShader(named: "tankDepthFadeSurface", in: library)
|
||||
}()
|
||||
|
||||
/// Ersetzt die Materialien der Entity (rekursiv) durch das Fade-CustomMaterial.
|
||||
/// Ersetzt die Originalmaterialien rekursiv durch CustomMaterial-Versionen,
|
||||
/// wobei Texturen und Farben des USDZ erhalten bleiben. Schatten werden für
|
||||
/// transparente Model-Entities separat deaktiviert.
|
||||
static func apply(to entity: Entity) {
|
||||
guard let shader else { return }
|
||||
forEachModelEntity(entity) { modelEntity in
|
||||
@ -36,16 +39,26 @@ enum TankDepthFadeMaterial {
|
||||
return custom
|
||||
}
|
||||
modelEntity.model = component
|
||||
modelEntity.components.set(GroundingShadowComponent(castsShadow: false))
|
||||
modelEntity.components.set(DynamicLightShadowComponent(castsShadow: false))
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktualisiert die Boden-Höhe (Welt-Y) in bereits angewandten Fade-Materialien.
|
||||
static func updateGroundY(_ groundY: Float, on entity: Entity) {
|
||||
static func updateGroundY(
|
||||
_ groundY: Float,
|
||||
belowAlpha: Float = 0.3,
|
||||
on entity: Entity
|
||||
) {
|
||||
forEachModelEntity(entity) { modelEntity in
|
||||
guard var component = modelEntity.model else { return }
|
||||
component.materials = component.materials.map { material in
|
||||
guard var custom = material as? CustomMaterial else { return material }
|
||||
custom.custom.value = SIMD4<Float>(groundY, band, belowAlpha, 0)
|
||||
var customValue = custom.custom.value
|
||||
customValue.x = groundY
|
||||
customValue.y = band
|
||||
customValue.z = belowAlpha
|
||||
custom.custom.value = customValue
|
||||
return custom
|
||||
}
|
||||
modelEntity.model = component
|
||||
|
||||
@ -8,13 +8,14 @@
|
||||
import RealityKit
|
||||
import UIKit
|
||||
|
||||
/// Baut die darzustellende Tank-Entity (aus `GasTank.usdz`): lädt/cached das Modell,
|
||||
/// richtet es je nach Aufstellungsart aus, erzeugt die Kollisionsform und – bei
|
||||
/// unterirdischer Aufstellung – den Höhen-Transparenz-Effekt.
|
||||
/// 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 {
|
||||
/// Name des USDZ-Assets im Bundle.
|
||||
private static let modelName = "GasTank"
|
||||
/// 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,
|
||||
@ -22,27 +23,47 @@ final class TankEntityFactory {
|
||||
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
|
||||
|
||||
/// Vorab geladenes Modell; wird pro Platzierung geklont.
|
||||
private var loadedModel: Entity?
|
||||
/// Katalog zur Auflösung der Auswahl auf ein konkretes Modell.
|
||||
private let catalog: any TankCatalogProviding
|
||||
|
||||
/// Lädt das USDZ asynchron vor (blockiert den Main-Actor nicht).
|
||||
func preload() {
|
||||
guard loadedModel == nil else { return }
|
||||
/// 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
|
||||
self.loadedModel = try? await Entity(named: Self.modelName)
|
||||
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()
|
||||
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 …
|
||||
@ -51,12 +72,18 @@ final class TankEntityFactory {
|
||||
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.
|
||||
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
|
||||
))
|
||||
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)
|
||||
@ -76,16 +103,36 @@ final class TankEntityFactory {
|
||||
return container
|
||||
}
|
||||
|
||||
/// Liefert einen Klon des vorab geladenen Modells. Fällt auf synchrones Laden
|
||||
/// bzw. – wenn das USDZ fehlt – auf einen einfachen Cube zurück.
|
||||
private func loadModelClone() -> Entity {
|
||||
if loadedModel == nil {
|
||||
loadedModel = try? Entity.load(named: Self.modelName)
|
||||
/// 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 let loadedModel {
|
||||
return loadedModel.clone(recursive: true)
|
||||
if loadedModels[name] == nil {
|
||||
loadedModels[name] = try? Entity.load(named: name)
|
||||
}
|
||||
return ModelEntity(
|
||||
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)]
|
||||
)
|
||||
|
||||
@ -14,21 +14,22 @@ struct TankManipulationPanel: View {
|
||||
let controller: ARSessionController
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
HStack {
|
||||
Text("Tank anpassen")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
controller.deselectTank()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Schließen")
|
||||
Group {
|
||||
if controller.isUndergroundTank {
|
||||
splitPanel
|
||||
} else {
|
||||
standardPanel
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private var standardPanel: some View {
|
||||
VStack(spacing: 14) {
|
||||
dismissHandle
|
||||
panelTitle
|
||||
|
||||
HStack(alignment: .center, spacing: 24) {
|
||||
heightGroup
|
||||
@ -36,13 +37,111 @@ struct TankManipulationPanel: View {
|
||||
rotateGroup
|
||||
}
|
||||
|
||||
actionButtons
|
||||
}
|
||||
}
|
||||
|
||||
private var splitPanel: some View {
|
||||
VStack(spacing: 14) {
|
||||
dismissHandle
|
||||
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
VStack(spacing: 24) {
|
||||
panelTitle
|
||||
|
||||
HStack(alignment: .bottom, spacing: 24) {
|
||||
heightGroup
|
||||
moveGroup
|
||||
rotateGroup
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Toggle(
|
||||
"Grube anzeigen",
|
||||
isOn: Binding(
|
||||
get: { controller.showsPit },
|
||||
set: { controller.setShowsPit($0) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
transparencyControl
|
||||
.disabled(controller.showsPit)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
actionButtons
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
private var dismissHandle: some View {
|
||||
Capsule()
|
||||
.fill(.secondary.opacity(0.7))
|
||||
.frame(width: 36, height: 4)
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onEnded { value in
|
||||
if value.translation.height > 60 {
|
||||
controller.deselectTank()
|
||||
}
|
||||
}
|
||||
)
|
||||
.accessibilityLabel("Zum Schließen nach unten wischen")
|
||||
}
|
||||
|
||||
private var panelTitle: some View {
|
||||
Text("Tank anpassen")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button("Skalierung zurücksetzen", systemImage: "arrow.counterclockwise") {
|
||||
controller.resetTankScale()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Neu platzieren", systemImage: "arrow.up.and.down.and.arrow.left.and.right") {
|
||||
controller.reposition()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var transparencyControl: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Transparenz unter Boden")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { controller.undergroundTransparency },
|
||||
set: { controller.setUndergroundTransparency($0) }
|
||||
),
|
||||
in: 0...1
|
||||
)
|
||||
HStack {
|
||||
Text("sichtbar")
|
||||
Spacer()
|
||||
Text("transparent")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(minWidth: 180)
|
||||
}
|
||||
|
||||
// MARK: - Gruppen
|
||||
@ -79,7 +178,7 @@ struct TankManipulationPanel: View {
|
||||
}
|
||||
|
||||
private func controlGroup<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack(spacing: 10) {
|
||||
content()
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
@ -113,7 +212,7 @@ private struct HoldRepeatButton: View {
|
||||
var body: some View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3)
|
||||
.frame(width: 48, height: 48)
|
||||
.frame(width: 54, height: 54)
|
||||
.background(.ultraThinMaterial, in: .circle)
|
||||
.contentShape(.circle)
|
||||
.gesture(
|
||||
|
||||
@ -27,16 +27,13 @@ void tankDepthFadeSurface(realitykit::surface_parameters params)
|
||||
auto constants = params.material_constants();
|
||||
|
||||
float2 uv = params.geometry().uv0();
|
||||
// Modelle aus USDZ nutzen eine gespiegelte Y-UV.
|
||||
uv.y = 1.0 - uv.y;
|
||||
|
||||
// Basisfarbe = Tint * Textur.
|
||||
// Originale USDZ-Farbe und Textur beibehalten.
|
||||
half3 tint = (half3)constants.base_color_tint();
|
||||
half3 color = (half3)tex.base_color().sample(textureSampler, uv).rgb;
|
||||
color *= tint;
|
||||
params.surface().set_base_color(color);
|
||||
params.surface().set_base_color(color * tint);
|
||||
|
||||
// Roughness / Metallic durchreichen (wie PhysicallyBasedMaterial).
|
||||
half roughness = tex.roughness().sample(textureSampler, uv).r * constants.roughness_scale();
|
||||
params.surface().set_roughness(roughness);
|
||||
|
||||
@ -50,6 +47,13 @@ void tankDepthFadeSurface(realitykit::surface_parameters params)
|
||||
float band = max(custom.y, 1e-3);
|
||||
float belowAlpha = custom.z;
|
||||
|
||||
// Bei maximaler Transparenz wird alles unterhalb der Bodenhöhe
|
||||
// vollständig ausgeblendet – nicht nur abgeschwächt.
|
||||
if (belowAlpha <= 0.001 && worldPosition.y < groundY) {
|
||||
params.surface().set_opacity(half(0.0));
|
||||
return;
|
||||
}
|
||||
|
||||
float t = clamp((groundY - worldPosition.y) / band, 0.0, 1.0);
|
||||
float alpha = mix(1.0, belowAlpha, t);
|
||||
params.surface().set_opacity(half(alpha));
|
||||
|
||||
Reference in New Issue
Block a user