forked from fritob/Camper-Monitor
Neigung am Fahrzeug darstellen
Neben der Libelle gibt es jetzt eine zweite Darstellung: die Neigung am Fahrzeug selbst, mit den Zeichnungen aus dem Ursprungsprojekt. Umschaltbar und über Starts hinweg gemerkt. Seitenansicht für längs, Heckansicht für quer. Die Heckansicht ist bewusst gewählt, nicht die Frontansicht: sie teilt die Blickrichtung des Fahrers, links im Bild ist also links am Fahrzeug. Von vorne betrachtet wäre es seitenverkehrt und damit beim Ausrichten irreführend. Die Zeichnungen sind weiss gefüllt mit dunklen Linien. Als Schablone eingefärbt wurde daraus eine massive Fläche, weil alles Deckende zur Tintfarbe wird - Fenster, Räder und Konturen verschwanden. Tools/ make-vehicle-art übersetzt deshalb die Helligkeit in Deckkraft: dunkle Linien voll deckend, helle Karosserie nur angedeutet. Eingefärbt bleibt die Zeichnung erhalten und passt sich Hell- wie Dunkeldarstellung an. Die Neigung wird dreifach überhöht gezeigt, sonst wären zwei Grad am Fahrzeug kaum auszumachen. Das steht als Hinweis darunter, zusammen mit der Klarstellung, dass die Gradzahlen echt sind - sonst nähme man den Bildwinkel für den tatsächlichen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleRear.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 253 KiB |
@@ -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) {
|
||||
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)
|
||||
.padding(.top, 8)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||
}
|
||||
|
||||
adviceBanner
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user