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:
@ -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]) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user