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>
244 lines
7.6 KiB
Swift
244 lines
7.6 KiB
Swift
//
|
|
// TankManipulationPanel.swift
|
|
// GasTankAR
|
|
//
|
|
// Created by Dennis Nemec on 28.08.26.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// Steuer-Panel für den ausgewählten Tank: Höhe (↑↓), Verschieben auf dem Boden
|
|
/// (vor/zurück/links/rechts) und Drehen (⟲⟳). Erscheint von unten; Pinch bleibt möglich.
|
|
/// Buttons unterstützen Gedrückt-halten für kontinuierliche Anpassung.
|
|
struct TankManipulationPanel: View {
|
|
let controller: ARSessionController
|
|
|
|
var body: some View {
|
|
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
|
|
moveGroup
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
private var heightGroup: some View {
|
|
controlGroup(title: "Höhe") {
|
|
VStack(spacing: 8) {
|
|
holdButton("chevron.up", label: "Höher") { controller.adjustTankHeight(up: true) }
|
|
holdButton("chevron.down", label: "Tiefer") { controller.adjustTankHeight(up: false) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private var moveGroup: some View {
|
|
controlGroup(title: "Verschieben") {
|
|
VStack(spacing: 8) {
|
|
holdButton("arrow.up", label: "Vorwärts") { controller.moveTank(forward: true) }
|
|
HStack(spacing: 8) {
|
|
holdButton("arrow.left", label: "Nach links") { controller.moveTank(left: true) }
|
|
holdButton("arrow.right", label: "Nach rechts") { controller.moveTank(left: false) }
|
|
}
|
|
holdButton("arrow.down", label: "Rückwärts") { controller.moveTank(forward: false) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private var rotateGroup: some View {
|
|
controlGroup(title: "Drehen") {
|
|
HStack(spacing: 8) {
|
|
holdButton("rotate.left", label: "Gegen den Uhrzeigersinn") { controller.rotateTank(clockwise: false) }
|
|
holdButton("rotate.right", label: "Im Uhrzeigersinn") { controller.rotateTank(clockwise: true) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func controlGroup<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
|
VStack(spacing: 10) {
|
|
content()
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func holdButton(_ systemImage: String, label: String, action: @escaping () -> Void) -> some View {
|
|
HoldRepeatButton(
|
|
systemImage: systemImage,
|
|
accessibilityLabel: label,
|
|
action: action,
|
|
onRelease: { controller.commitTankEdit() }
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Runder Button, der bei Gedrückt-halten die Aktion wiederholt (Tastenwiederholung).
|
|
/// Einzel-Tap = ein Schritt; Halten = fortlaufend. Beim Loslassen wird `onRelease` aufgerufen.
|
|
private struct HoldRepeatButton: View {
|
|
let systemImage: String
|
|
let accessibilityLabel: String
|
|
let action: () -> Void
|
|
let onRelease: () -> Void
|
|
|
|
@State private var repeatTask: Task<Void, Never>?
|
|
|
|
private static let initialDelay: Duration = .milliseconds(350)
|
|
private static let repeatInterval: Duration = .milliseconds(60)
|
|
|
|
var body: some View {
|
|
Image(systemName: systemImage)
|
|
.font(.title3)
|
|
.frame(width: 54, height: 54)
|
|
.background(.ultraThinMaterial, in: .circle)
|
|
.contentShape(.circle)
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in if repeatTask == nil { start() } }
|
|
.onEnded { _ in stop() }
|
|
)
|
|
.accessibilityLabel(accessibilityLabel)
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
|
|
private func start() {
|
|
action() // sofortiger erster Schritt (Einzel-Tap)
|
|
repeatTask = Task { @MainActor in
|
|
try? await Task.sleep(for: Self.initialDelay)
|
|
while !Task.isCancelled {
|
|
action()
|
|
try? await Task.sleep(for: Self.repeatInterval)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stop() {
|
|
repeatTask?.cancel()
|
|
repeatTask = nil
|
|
onRelease()
|
|
}
|
|
}
|