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:
Dennis Nemec
2026-09-26 19:12:39 +02:00
parent 992bb32025
commit b8536d4182
63 changed files with 2317 additions and 188 deletions

View File

@ -8,6 +8,7 @@
import SwiftUI
import SwiftData
import MapKit
import UIKit
import CoreLocation
/// Galerie der im AR-Modus aufgenommenen Fotos eines Projekts.
@ -56,14 +57,17 @@ struct GalleryView: View {
}
}
.fullScreenCover(item: $selectedPhoto) { photo in
PhotoViewer(photo: photo) {
PhotoViewer(
photo: photo,
photos: project.photos.sorted { $0.creationDate > $1.creationDate }
) {
delete(photo)
}
}
}
private func thumbnail(for photo: ProjectPhoto) -> some View {
PhotoThumbnail(imageData: photo.imageData)
PhotoThumbnail(imageData: photo.annotatedImageData ?? photo.imageData)
.aspectRatio(1, contentMode: .fit)
.clipped()
.contentShape(.rect)
@ -130,52 +134,406 @@ struct PhotoThumbnail: View {
}
}
/// Vollbild-Betrachter für ein einzelnes Foto mit Schließen- und Löschen-Aktion.
/// Vollbild-Betrachter für die Projektfotos mit Schließen-, Lösch- und Wisch-Aktion.
private enum DrawingTool: String, CaseIterable, Identifiable {
case pen
case eraser
var id: Self { self }
var title: String {
switch self {
case .pen:
"Stift"
case .eraser:
"Radierer"
}
}
}
private struct DrawingStroke {
var points: [CGPoint]
var color: Color
var width: CGFloat
var isEraser: Bool
}
private struct PhotoViewer: View {
let photo: ProjectPhoto
let photos: [ProjectPhoto]
let onDelete: () -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var currentIndex: Int
@State private var isDrawing = false
@State private var strokes: [DrawingStroke] = []
@State private var activeStroke: DrawingStroke?
@State private var drawingTool: DrawingTool = .pen
@State private var drawingColor = Color.red
@State private var drawingWidth: Double = 0.012
@State private var imageRevision = 0
private var currentPhoto: ProjectPhoto {
photos[currentIndex]
}
init(photo: ProjectPhoto, photos: [ProjectPhoto], onDelete: @escaping () -> Void) {
self.photos = photos
self.onDelete = onDelete
_currentIndex = State(initialValue: photos.firstIndex(of: photo) ?? 0)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
if let image = UIImage(data: photo.imageData) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
}
if let coordinate = photo.coordinate {
VStack(alignment: .leading, spacing: 8) {
Text("Karte")
.font(.headline)
PhotoLocationCard(coordinate: coordinate)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
TabView(selection: $currentIndex) {
ForEach(photos.indices, id: \.self) { index in
photoLayout(for: photos[index])
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.background(Color(.systemGroupedBackground))
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Fertig") { dismiss() }
if isDrawing {
EmptyView()
} else {
Button("Fertig") { dismiss() }
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Löschen", systemImage: "trash", role: .destructive) {
onDelete()
dismiss()
if isDrawing {
Button {
saveDrawing()
} label: {
Image(systemName: "checkmark")
}
.buttonStyle(.glassProminent)
.tint(.accentColor)
.accessibilityLabel("Zeichnung speichern")
} else {
Button("Löschen", systemImage: "trash", role: .destructive) {
onDelete()
dismiss()
}
}
}
}
.onChange(of: currentIndex) {
isDrawing = false
strokes.removeAll()
activeStroke = nil
}
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle(photo.creationDate.formatted(.dateTime.day().month().year().hour().minute()))
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 12) {
Button {
currentIndex -= 1
} label: {
Image(systemName: "chevron.left")
}
.disabled(currentIndex == 0)
.accessibilityLabel("Vorheriges Bild")
Text(currentPhoto.creationDate.formatted(.dateTime.day().month().year().hour().minute()))
.font(.subheadline)
Button {
currentIndex += 1
} label: {
Image(systemName: "chevron.right")
}
.disabled(currentIndex == photos.count - 1)
.accessibilityLabel("Nächstes Bild")
}
}
}
.navigationBarTitleDisplayMode(.inline)
}
}
private func photoLayout(for photo: ProjectPhoto) -> some View {
GeometryReader { geometry in
HStack(spacing: 0) {
imagePane(for: photo)
.padding(.top, 16)
.padding(.leading, 24)
.padding(.bottom, 24)
.padding(.trailing, 24)
.frame(
width: geometry.size.width * 0.75,
height: geometry.size.height
)
metadataPane(for: photo)
.frame(width: geometry.size.width * 0.25)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
private func imagePane(for photo: ProjectPhoto) -> some View {
GeometryReader { geometry in
ZStack {
if let image = UIImage(data: photo.annotatedImageData ?? photo.imageData) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .top
)
.id(imageRevision)
} else {
ContentUnavailableView("Bild nicht verfügbar", systemImage: "photo")
}
if isDrawing {
drawingCanvas(in: geometry.size)
}
}
}
}
private func drawingCanvas(in size: CGSize) -> some View {
Canvas { context, canvasSize in
for stroke in strokes + (activeStroke.map { [$0] } ?? []) {
draw(stroke, in: &context, size: canvasSize)
}
}
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let point = CGPoint(
x: value.location.x / max(size.width, 1),
y: value.location.y / max(size.height, 1)
)
if drawingTool == .eraser {
strokes.removeAll { stroke in
stroke.points.contains { pointDistance($0, point) < drawingWidth * 2 }
}
} else {
if activeStroke == nil {
activeStroke = DrawingStroke(
points: [point],
color: drawingColor,
width: drawingWidth,
isEraser: false
)
} else {
activeStroke?.points.append(point)
}
}
}
.onEnded { _ in
if let activeStroke {
strokes.append(activeStroke)
self.activeStroke = nil
}
}
)
}
private func draw(
_ stroke: DrawingStroke,
in context: inout GraphicsContext,
size: CGSize
) {
guard let first = stroke.points.first else { return }
var path = Path()
path.move(to: CGPoint(x: first.x * size.width, y: first.y * size.height))
for point in stroke.points.dropFirst() {
path.addLine(to: CGPoint(x: point.x * size.width, y: point.y * size.height))
}
context.stroke(
path,
with: .color(stroke.color),
style: StrokeStyle(
lineWidth: stroke.width * min(size.width, size.height),
lineCap: .round,
lineJoin: .round
)
)
}
private func pointDistance(_ lhs: CGPoint, _ rhs: CGPoint) -> CGFloat {
hypot(lhs.x - rhs.x, lhs.y - rhs.y)
}
private var drawingSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Zeichnen")
.font(.headline)
GlassEffectContainer(spacing: 12) {
HStack(spacing: 12) {
Button {
if isDrawing && drawingTool == .pen {
isDrawing = false
activeStroke = nil
} else {
isDrawing = true
drawingTool = .pen
}
} label: {
Image(systemName: "pencil.tip")
.font(.system(size: 20, weight: .semibold))
.frame(width: 44, height: 44)
}
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.tint(isDrawing && drawingTool == .pen ? .accentColor : .primary)
.accessibilityLabel(
isDrawing && drawingTool == .pen
? "Zeichnen beenden"
: "Stift auswählen"
)
Button {
isDrawing = true
drawingTool = .eraser
activeStroke = nil
} label: {
Image(systemName: "eraser.fill")
.font(.system(size: 20, weight: .semibold))
.frame(width: 44, height: 44)
}
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.tint(isDrawing && drawingTool == .eraser ? .accentColor : .primary)
.accessibilityLabel("Radierer auswählen")
if (isDrawing && drawingTool == .pen) || currentPhoto.annotatedImageData != nil {
Spacer()
Button {
if let originalImageData = currentPhoto.originalImageData {
currentPhoto.imageData = originalImageData
}
currentPhoto.originalImageData = nil
currentPhoto.annotatedImageData = nil
strokes.removeAll()
activeStroke = nil
imageRevision += 1
try? modelContext.save()
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 19, weight: .semibold))
.frame(width: 44, height: 44)
}
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.tint(.primary)
.accessibilityLabel("Zeichnung zurücksetzen")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if isDrawing && drawingTool == .pen {
ColorPicker("Farbe", selection: $drawingColor)
VStack(alignment: .leading, spacing: 4) {
Text("Stiftdicke")
Slider(value: $drawingWidth, in: 0.003...0.04)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func saveDrawing() {
guard let baseImage = UIImage(data: currentPhoto.annotatedImageData ?? currentPhoto.imageData),
!strokes.isEmpty else {
isDrawing = false
return
}
let renderer = UIGraphicsImageRenderer(size: baseImage.size)
let renderedPNG = renderer.pngData { rendererContext in
baseImage.draw(in: CGRect(origin: .zero, size: baseImage.size))
let context = rendererContext.cgContext
for stroke in strokes {
guard let first = stroke.points.first else { continue }
context.setStrokeColor(UIColor(stroke.color).cgColor)
context.setLineWidth(stroke.width * min(baseImage.size.width, baseImage.size.height))
context.setLineCap(.round)
context.setLineJoin(.round)
context.beginPath()
context.move(
to: CGPoint(
x: first.x * baseImage.size.width,
y: first.y * baseImage.size.height
)
)
for point in stroke.points.dropFirst() {
context.addLine(
to: CGPoint(
x: point.x * baseImage.size.width,
y: point.y * baseImage.size.height
)
)
}
context.strokePath()
}
}
if currentPhoto.originalImageData == nil {
currentPhoto.originalImageData = currentPhoto.imageData
}
currentPhoto.imageData = renderedPNG
currentPhoto.annotatedImageData = renderedPNG
try? modelContext.save()
imageRevision += 1
strokes.removeAll()
activeStroke = nil
isDrawing = false
}
private func metadataPane(for photo: ProjectPhoto) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Metadaten")
.font(.title3.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
Text("Aufgenommen")
.font(.subheadline.weight(.semibold))
Text(photo.creationDate.formatted(.dateTime.day().month(.wide).year().hour().minute()))
.font(.subheadline)
.foregroundStyle(.secondary)
}
Divider()
if let coordinate = photo.coordinate {
VStack(alignment: .leading, spacing: 8) {
Text("Standort")
.font(.headline)
PhotoLocationCard(coordinate: coordinate)
}
} else {
VStack(alignment: .leading, spacing: 6) {
Label("Kein Standort gespeichert", systemImage: "mappin.slash")
.font(.headline)
Text("Bei diesem Bild wurden keine Standortdaten aufgenommen.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
drawingSection
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.background(Color(.systemGroupedBackground))
}
}
/// Schwebende Karte, die den Aufnahmeort eines Fotos zeigt: Karten-Vorschau plus
@ -249,7 +607,6 @@ private struct PhotoLocationCard: View {
String(format: "%.5f, %.5f", coordinate.latitude, coordinate.longitude)
}
}
#Preview("Galerie") {
let sample = PreviewSupport.galleryProjectContainer()
return NavigationStack {
@ -259,5 +616,6 @@ private struct PhotoLocationCard: View {
}
#Preview("Foto-Detail") {
PhotoViewer(photo: PreviewSupport.samplePhoto()) {}
let photo = PreviewSupport.samplePhoto()
PhotoViewer(photo: photo, photos: [photo]) {}
}