diff --git a/CamperMonitor/Assets.xcassets/VehicleRear.imageset/Contents.json b/CamperMonitor/Assets.xcassets/VehicleRear.imageset/Contents.json new file mode 100644 index 0000000..a0f8834 --- /dev/null +++ b/CamperMonitor/Assets.xcassets/VehicleRear.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { + "filename" : "VehicleRear.png", + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "template-rendering-intent" : "template" } +} diff --git a/CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png b/CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png new file mode 100644 index 0000000..a71ab5e Binary files /dev/null and b/CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png differ diff --git a/CamperMonitor/Assets.xcassets/VehicleSide.imageset/Contents.json b/CamperMonitor/Assets.xcassets/VehicleSide.imageset/Contents.json new file mode 100644 index 0000000..7e18a2d --- /dev/null +++ b/CamperMonitor/Assets.xcassets/VehicleSide.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { + "filename" : "VehicleSide.png", + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "template-rendering-intent" : "template" } +} diff --git a/CamperMonitor/Assets.xcassets/VehicleSide.imageset/VehicleSide.png b/CamperMonitor/Assets.xcassets/VehicleSide.imageset/VehicleSide.png new file mode 100644 index 0000000..9f1c333 Binary files /dev/null and b/CamperMonitor/Assets.xcassets/VehicleSide.imageset/VehicleSide.png differ diff --git a/CamperMonitor/Views/AlignmentAssistantView.swift b/CamperMonitor/Views/AlignmentAssistantView.swift index 56ed9dc..168792f 100644 --- a/CamperMonitor/Views/AlignmentAssistantView.swift +++ b/CamperMonitor/Views/AlignmentAssistantView.swift @@ -14,6 +14,7 @@ struct AlignmentAssistantView: View { @State private var assistant = AlignmentAssistant() @State private var didAnnounceTarget = false + @AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() } @@ -21,9 +22,21 @@ struct AlignmentAssistantView: View { NavigationStack { ScrollView { VStack(spacing: 24) { - LevelBubble(pitch: state.pitch, roll: state.roll) - .frame(maxWidth: 320) - .padding(.top, 8) + Picker("Darstellung", selection: $displayStyle) { + ForEach(LevelDisplayStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .pickerStyle(.segmented) + .padding(.top, 8) + + switch displayStyle { + case .bubble: + LevelBubble(pitch: state.pitch, roll: state.roll) + .frame(maxWidth: 320) + case .vehicle: + VehicleTiltView(pitch: state.pitch, roll: state.roll) + } adviceBanner diff --git a/CamperMonitor/Views/LevelView.swift b/CamperMonitor/Views/LevelView.swift index 45f7cae..81dc9bb 100644 --- a/CamperMonitor/Views/LevelView.swift +++ b/CamperMonitor/Views/LevelView.swift @@ -109,12 +109,25 @@ struct LevelControls: View { @Environment(DeviceStore.self) private var store @State private var showResetConfirmation = false @State private var showAssistant = false + @AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble var body: some View { Section { VStack(spacing: 16) { - LevelBubble(pitch: state.pitch, roll: state.roll) - .frame(maxHeight: 220) + Picker("Darstellung", selection: $displayStyle) { + ForEach(LevelDisplayStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .pickerStyle(.segmented) + + switch displayStyle { + case .bubble: + LevelBubble(pitch: state.pitch, roll: state.roll) + .frame(maxHeight: 220) + case .vehicle: + VehicleTiltView(pitch: state.pitch, roll: state.roll) + } if let instruction = state.instruction { Label(instruction, diff --git a/CamperMonitor/Views/VehicleTiltView.swift b/CamperMonitor/Views/VehicleTiltView.swift new file mode 100644 index 0000000..3ef9c6d --- /dev/null +++ b/CamperMonitor/Views/VehicleTiltView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// Zeigt die Neigung am Fahrzeug selbst, statt an einer abstrakten Blase. +/// +/// Zwei Ansichten, jede um ihre Achse gekippt: +/// +/// * **Seitenansicht** für die Längsneigung. Die Front zeigt nach links, das +/// Heck nach rechts. +/// * **Heckansicht** für die Querneigung. Sie teilt die Blickrichtung des +/// Fahrers, links im Bild ist also links am Fahrzeug – bei einer +/// Frontansicht wäre es seitenverkehrt. +/// +/// In beiden Fällen wird gegen den mathematischen Drehsinn gekippt: Steht das +/// Heck höher, muss die rechte Bildseite nach oben. +struct VehicleTiltView: View { + let pitch: Double? + let roll: Double? + + /// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen – zwei Grad + /// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben, + /// damit niemand den Winkel für bare Münze nimmt. + static let exaggeration: Double = 3 + + var body: some View { + VStack(spacing: 20) { + tiltPanel(image: "VehicleSide", + angle: pitch, + title: "Längs", + lowerLabel: "Front", + upperLabel: "Heck", + aspect: 925.0 / 600.0) + + tiltPanel(image: "VehicleRear", + angle: roll, + title: "Quer", + lowerLabel: "links", + upperLabel: "rechts", + aspect: 1) + + // Ohne diesen Hinweis nähme man den Bildwinkel für den echten. + Text(String(format: "Neigung %.0f-fach überhöht dargestellt – " + + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.", + Self.exaggeration)) + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } + + private func tiltPanel(image: String, + angle: Double?, + title: String, + lowerLabel: String, + upperLabel: String, + aspect: Double) -> some View { + VStack(spacing: 8) { + HStack { + Text(title) + .font(.subheadline.weight(.medium)) + Spacer() + Text(angle.map { String(format: "%.1f°", $0) } ?? "–") + .font(.subheadline.weight(.semibold).monospacedDigit()) + .foregroundStyle(colour(for: angle)) + } + + ZStack { + // Waagerechte als Bezug – ohne sie ist eine kleine Neigung + // nicht einzuschätzen. + Rectangle() + .fill(Color.secondary.opacity(0.35)) + .frame(height: 1) + + Image(image) + .resizable() + .scaledToFit() + .foregroundStyle(colour(for: angle)) + .aspectRatio(aspect, contentMode: .fit) + .rotationEffect(.degrees(-(angle ?? 0) * Self.exaggeration)) + .animation(.spring(duration: 0.4), value: angle) + .opacity(angle == nil ? 0.3 : 1) + } + .frame(height: 110) + + HStack { + Text(lowerLabel) + Spacer() + Text(upperLabel) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private func colour(for angle: Double?) -> Color { + guard let angle else { return .secondary } + if abs(angle) <= LevelState.levelTolerance { return .green } + if abs(angle) <= 2 { return .orange } + return .red + } +} + +/// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg. +enum LevelDisplayStyle: String, CaseIterable, Identifiable { + case bubble + case vehicle + + var id: String { rawValue } + + var title: String { + switch self { + case .bubble: return "Libelle" + case .vehicle: return "Fahrzeug" + } + } +} diff --git a/README.md b/README.md index 344e074..6717b9c 100644 --- a/README.md +++ b/README.md @@ -132,10 +132,23 @@ MPU6050, der Längs- und Querneigung über Bluetooth bereitstellt. Er bewirbt seinen Dienst, wird beim Einrichten also sicher erkannt und die Art vorbelegt. -Die Detailansicht zeigt eine Libelle: die Blase wandert dorthin, wo das -Fahrzeug höher steht, wie bei einer echten Wasserwaage. Grün heißt eben -(bis 0,5°), orange bis zwei Grad, darüber rot. Dazu steht in Worten, welche -Seite höher steht. +Die Detailansicht bietet zwei Darstellungen, umschaltbar und über Starts +hinweg gemerkt: + +* **Libelle** – die Blase wandert dorthin, wo das Fahrzeug höher steht, wie bei + einer echten Wasserwaage. Ringe markieren Toleranz und zwei Grad. +* **Fahrzeug** – Seitenansicht für längs, Heckansicht für quer, beide gekippt. + Die Heckansicht teilt die Blickrichtung des Fahrers, links im Bild ist also + links am Fahrzeug. Die Neigung ist dreifach überhöht dargestellt, sonst wäre + sie bei zwei Grad kaum zu erkennen; die angezeigten Gradzahlen sind echt. + +In beiden Fällen: grün heißt eben (bis 0,5°), orange bis zwei Grad, darüber +rot. Dazu steht in Worten, welche Seite höher steht. + +Die Fahrzeugzeichnungen stammen aus dem Ursprungsprojekt VanAlign Pro. Sie +liegen als Schablonen im Asset-Katalog und werden je nach Abweichung +eingefärbt; die Helligkeit des Originals wurde dafür in Deckkraft übersetzt, +damit Fenster und Konturen beim Einfärben erhalten bleiben. **Vor der ersten Nutzung kalibrieren:** Fahrzeug eben stellen, dann *Auf aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors, @@ -235,6 +248,7 @@ CamperMonitor/ ├── FridgeControls.swift Bedienelemente der Kühlbox ├── LevelView.swift Libelle und Kalibrierung ├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren + ├── VehicleTiltView.swift Neigung am Fahrzeug dargestellt └── AddDeviceView.swift Scannen und Einrichten ``` diff --git a/Tools/README.md b/Tools/README.md index aca7b66..52ef1c9 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -17,3 +17,20 @@ swiftc -O -o /tmp/make-app-icon Tools/make-app-icon.swift ``` Nur nötig, wenn `logo.png` sich ändert – das erzeugte Icon liegt im Repo. + +## make-vehicle-art.swift + +Bereitet die Fahrzeugzeichnungen aus VanAlign Pro für die Neigungsanzeige auf. +Die Vorlagen sind weiß gefüllt mit dunklen Linien und Fenstern. Als Schablone +eingefärbt ginge beides verloren, weil alles Deckende zur Tintfarbe wird – +übrig bliebe eine massive Fläche. + +Das Skript übersetzt deshalb die Helligkeit in Deckkraft: dunkle Linien werden +voll deckend, die helle Karosserie nur angedeutet. Eingefärbt ergibt das eine +gefüllte Form, in der die Details erhalten bleiben. + +```bash +swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift +sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png +/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png +``` diff --git a/Tools/make-vehicle-art.swift b/Tools/make-vehicle-art.swift new file mode 100644 index 0000000..1c3ab02 --- /dev/null +++ b/Tools/make-vehicle-art.swift @@ -0,0 +1,61 @@ +import AppKit +import CoreGraphics +import Foundation + +// Die Zeichnungen sind weiss gefüllt mit dunklen Linien und Fenstern. Als +// Schablone eingefärbt geht beides verloren, weil alles Deckende zur +// Tintfarbe wird - übrig bleibt eine massive Fläche. +// +// Deshalb wird die Helligkeit in Deckkraft übersetzt: dunkle Linien werden +// voll deckend, die helle Karosserie nur angedeutet. Eingefärbt ergibt das +// eine gefüllte Form, in der die Details erhalten bleiben. + +let arguments = CommandLine.arguments +guard arguments.count >= 3, + let source = NSImage(contentsOfFile: arguments[1]), + let image = source.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("Quelle nicht lesbar"); exit(1) +} + +let width = image.width, height = image.height +let bytesPerRow = width * 4 +var pixels = [UInt8](repeating: 0, count: bytesPerRow * height) +guard let context = CGContext(data: &pixels, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { + print("Puffer nicht anlegbar"); exit(1) +} +context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + +/// Wie stark die Fläche noch durchscheint. +let fill = 0.22 +var changed = 0 + +for index in stride(from: 0, to: pixels.count, by: 4) { + let alpha = Double(pixels[index + 3]) / 255 + guard alpha > 0.02 else { + pixels[index] = 0; pixels[index + 1] = 0; pixels[index + 2] = 0; pixels[index + 3] = 0 + continue + } + // Premultipliziert: erst zurückrechnen, um die echte Helligkeit zu sehen. + let r = Double(pixels[index]) / 255 / alpha + let g = Double(pixels[index + 1]) / 255 / alpha + let b = Double(pixels[index + 2]) / 255 / alpha + let brightness = min(1, max(0, 0.299 * r + 0.587 * g + 0.114 * b)) + + let newAlpha = alpha * (fill + (1 - fill) * (1 - brightness)) + // Die Farbe selbst spielt keine Rolle, sie wird ohnehin eingefärbt. + let value = UInt8(newAlpha * 255) + pixels[index] = value; pixels[index + 1] = value; pixels[index + 2] = value + pixels[index + 3] = value + changed += 1 +} + +guard let result = context.makeImage() else { print("Bild leer"); exit(1) } +let rep = NSBitmapImageRep(cgImage: result) +guard let png = rep.representation(using: .png, properties: [:]) else { + print("PNG nicht erzeugbar"); exit(1) +} +try png.write(to: URL(fileURLWithPath: arguments[2])) +print("\(arguments[2]): \(changed) Bildpunkte umgesetzt")