diff --git a/CamperMonitor/Views/AlignmentAssistantView.swift b/CamperMonitor/Views/AlignmentAssistantView.swift index 871f477..f2e6cf2 100644 --- a/CamperMonitor/Views/AlignmentAssistantView.swift +++ b/CamperMonitor/Views/AlignmentAssistantView.swift @@ -176,37 +176,37 @@ struct AlignmentAssistantView: View { @ViewBuilder private var wedgeSection: some View { - let across = profile?.trackWidth.flatMap { width in - state.roll.flatMap { LevelingWedge.across(roll: $0, trackWidth: width) } - } - let along = profile?.wheelbase.flatMap { base in - state.pitch.flatMap { LevelingWedge.along(pitch: $0, wheelbase: base) } - } + // Gerechnet wird über alle vier Räder auf einmal. Getrennte Angaben für + // quer und längs beschreiben dasselbe Fahrzeug und lassen sich nicht + // getrennt ausführen: Unter „rechts" liegen zwei Räder, unter „vorne" + // auch, und eines davon ist dasselbe. + let lift: LevelingLift? = { + guard let width = profile?.trackWidth, let base = profile?.wheelbase, + let pitch = state.pitch, let roll = state.roll else { return nil } + return LevelingLift.compute(pitch: pitch, roll: roll, + trackWidth: width, wheelbase: base) + }() VStack(alignment: .leading, spacing: 10) { Label("Auffahrkeile", systemImage: "triangle.fill") .font(.headline) - if profile?.trackWidth == nil && profile?.wheelbase == nil { + if profile?.trackWidth == nil || profile?.wheelbase == nil { Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt " + "sich beim Fahrzeug hinterlegen.") .font(.callout) .foregroundStyle(.secondary) - } else if across == nil && along == nil { - Text("Keine Keile nötig.") - .font(.callout) + } else if let lift, !lift.isNegligible { + WheelLiftPlan(lift: lift) + .frame(maxWidth: .infinity) + + Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad " + + "bleibt liegen, die übrigen werden auf seine Höhe gebracht.") + .font(.caption) .foregroundStyle(.secondary) } else { - ForEach([across, along].compactMap { $0 }, id: \.side) { wedge in - HStack { - Text(wedge.side.text.capitalized) - Spacer() - Text(String(format: "%.0f cm", wedge.heightInCentimetres)) - .font(.title3.weight(.semibold).monospacedDigit()) - } - } - Text("Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.") - .font(.caption) + Text("Keine Keile nötig.") + .font(.callout) .foregroundStyle(.secondary) } } @@ -230,4 +230,3 @@ struct AlignmentAssistantView: View { } } -extension LevelingWedge.Side: Hashable {} diff --git a/CamperMonitorWatch/Views/WatchLevelView.swift b/CamperMonitorWatch/Views/WatchLevelView.swift index 13ffd2a..09f2c76 100644 --- a/CamperMonitorWatch/Views/WatchLevelView.swift +++ b/CamperMonitorWatch/Views/WatchLevelView.swift @@ -36,11 +36,9 @@ struct WatchLevelView: View { .multilineTextAlignment(.center) .foregroundStyle(reading.state.isLevel ? .green : .primary) - if let wedges = wedgeText { - Text(wedges) - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + if let lift, !lift.isNegligible { + WheelLiftPlan(lift: lift, isCompact: true) + .padding(.top, 2) } NavigationLink { @@ -76,20 +74,15 @@ struct WatchLevelView: View { } } - /// Wie hoch die Auffahrkeile sein müssen – reine Geometrie, und nur zu + /// Wie hoch jedes Rad unterlegt werden muss – reine Geometrie, und nur zu /// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen. - private var wedgeText: String? { - guard let profile = link.payload?.profile else { return nil } - var parts: [String] = [] - if let roll = reading.state.roll, let width = profile.trackWidth, - let wedge = LevelingWedge.across(roll: roll, trackWidth: width) { - parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text)) - } - if let pitch = reading.state.pitch, let base = profile.wheelbase, - let wedge = LevelingWedge.along(pitch: pitch, wheelbase: base) { - parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text)) - } - return parts.isEmpty ? nil : "Keile: " + parts.joined(separator: ", ") + private var lift: LevelingLift? { + guard let profile = link.payload?.profile, + let width = profile.trackWidth, let base = profile.wheelbase, + let pitch = reading.state.pitch, let roll = reading.state.roll + else { return nil } + return LevelingLift.compute(pitch: pitch, roll: roll, + trackWidth: width, wheelbase: base) } } diff --git a/README.md b/README.md index 0575446..1a947c2 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,16 @@ Das steckt vollständig im zeitlichen Verlauf der Neigung – ohne jede Annahme über das Gelände. Sind beim Fahrzeug **Spurweite und Radstand** hinterlegt, rechnet der Assistent -zusätzlich die nötige Höhe der Auffahrkeile aus. Das ist reine Geometrie und -damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die -tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das -ⓘ in der Fahrzeugliste. +zusätzlich die nötige Höhe der Auffahrkeile aus – **je Rad**, als Draufsicht auf +das Fahrzeug. Getrennte Angaben für quer und längs wären irreführend: „rechts +8 cm" und „vorne 4 cm" beschreiben dasselbe Fahrzeug, und unter beiden liegt +teils dasselbe Rad. Es steht auf vier Punkten, also gehören vier Zahlen hin. + +Das ist reine Geometrie und damit exakt: 2,0° Querneigung bei 2,00 m Spurweite +ergeben 7,0 cm unter beide Räder der tieferstehenden Seite; kommen 1,5° +Längsneigung bei 3,50 m Radstand dazu, braucht die tiefste Ecke 16,1 cm, ihre +Nachbarn 9,2 und 7,0 cm, und das höchststehende Rad bleibt liegen. Die Maße +stehen im Fahrzeugprofil, erreichbar über das ⓘ in der Fahrzeugliste. ## Apple Watch @@ -219,7 +225,7 @@ den Arm hebt: Verbindung. Ganz unten steht, wie alt der Stand ist. * **Nivellierung** – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über Starts - hinweg gemerkt. Dazu, was zu tun ist, und die nötige Keilhöhe, falls im + hinweg gemerkt. Dazu, was zu tun ist, und die Keilhöhe je Rad, falls im Fahrzeugprofil Spurweite und Radstand stehen. Kalibriert wird nur am iPhone: Das gehört einmalig auf ebenen Boden, und ein Knopf dafür an der Uhr wäre vor allem eine Gelegenheit, die Nullage aus Versehen zu verstellen. @@ -328,7 +334,7 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome) Shared/ In iPhone- und Watch-App übersetzt ├── Models/ │ ├── Profile.swift Fahrzeug samt Maßen -│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung +│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilhöhe je Rad │ ├── SensorOrientation.swift Einbaulage und ihre Erkennung │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform @@ -338,6 +344,7 @@ Shared/ In iPhone- und Watch-App übersetzt │ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung │ └── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr) ├── VehicleTilt.swift Überhöhung und Farben der Fahrzeugansicht +├── WheelLiftPlan.swift Keilhöhen als Draufsicht auf die vier Räder └── WatchLink/ └── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr diff --git a/Shared/Models/AlignmentAssistant.swift b/Shared/Models/AlignmentAssistant.swift index 5115980..470acc2 100644 --- a/Shared/Models/AlignmentAssistant.swift +++ b/Shared/Models/AlignmentAssistant.swift @@ -129,55 +129,85 @@ struct AlignmentAssistant { } } -/// Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen. +/// Wie hoch jedes einzelne Rad unterlegt werden muss. /// -/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen -/// beziehungsweise der Räder. -struct LevelingWedge: Equatable { - /// Wo der Keil hin muss. - let side: Side - /// Höhe in Metern. - let height: Double - /// Der zugrundeliegende Winkel in Grad. - let angle: Double +/// Getrennte Angaben wie „rechts 8 cm" und „vorne 4 cm" beschreiben dasselbe +/// Fahrzeug und lassen sich nicht getrennt ausführen: Unter „rechts" liegen +/// zwei Räder, unter „vorne" auch, und zwei davon sind dieselben. Gefragt ist +/// deshalb die Höhe **je Rad**. +/// +/// Das ist reine Geometrie und damit exakt. Das Fahrzeug steht auf vier +/// Punkten; deren Höhen ergeben sich aus Spurweite, Radstand und den beiden +/// Winkeln. Angehoben wird auf die Höhe des höchsten Rades – das bleibt +/// liegen, die anderen bekommen die Differenz. +struct LevelingLift: Equatable { - enum Side: Equatable { - case front, rear, left, right + enum Wheel: CaseIterable, Hashable { + case frontLeft, frontRight, rearLeft, rearRight var text: String { switch self { - case .front: return "vorne" - case .rear: return "hinten" - case .left: return "links" - case .right: return "rechts" + case .frontLeft: return "vorne links" + case .frontRight: return "vorne rechts" + case .rearLeft: return "hinten links" + case .rearRight: return "hinten rechts" } } } - var heightInCentimetres: Double { height * 100 } + /// Höhen in Metern, je Rad. Das höchste Rad steht auf 0. + let frontLeft: Double + let frontRight: Double + let rearLeft: Double + let rearRight: Double - /// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll - /// heisst, dass rechts höher steht – der Keil gehört also nach links. - static func across(roll: Double, trackWidth: Double) -> LevelingWedge? { - wedge(angle: roll, distance: trackWidth, - whenPositive: .left, whenNegative: .right) + func height(_ wheel: Wheel) -> Double { + switch wheel { + case .frontLeft: return frontLeft + case .frontRight: return frontRight + case .rearLeft: return rearLeft + case .rearRight: return rearRight + } } - /// Längs: positiver Pitch heisst, dass das Heck höher steht – der Keil - /// gehört unter die Vorderräder. - static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? { - wedge(angle: pitch, distance: wheelbase, - whenPositive: .front, whenNegative: .rear) + func centimetres(_ wheel: Wheel) -> Double { height(wheel) * 100 } + + /// Das am tiefsten stehende Rad – dort liegt die grösste Höhe. + var deepest: Wheel { + Wheel.allCases.max { height($0) < height($1) } ?? .frontLeft } - private static func wedge(angle: Double, - distance: Double, - whenPositive: Side, - whenNegative: Side) -> LevelingWedge? { - guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil } - let height = tan(abs(angle) * .pi / 180) * distance - return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative, - height: height, - angle: abs(angle)) + var maximumCentimetres: Double { centimetres(deepest) } + + /// Unter einem Zentimeter lohnt kein Keil; so genau steht kein Fahrzeug, + /// und so genau misst der Sensor auch nicht. + var isNegligible: Bool { maximumCentimetres < 1 } + + /// `pitch` positiv heisst, das Heck steht höher; `roll` positiv, die rechte + /// Seite steht höher. Ohne Fahrzeugmasse gibt es nichts zu rechnen. + static func compute(pitch: Double, + roll: Double, + trackWidth: Double, + wheelbase: Double) -> LevelingLift? { + guard trackWidth > 0, wheelbase > 0 else { return nil } + + // Wie viel höher die rechte Seite und wie viel höher das Heck steht. + // Vorzeichen inbegriffen: tan(-2°) ist negativ, dann steht links höher. + let rightRise = tan(roll * .pi / 180) * trackWidth + let rearRise = tan(pitch * .pi / 180) * wheelbase + + // Höhen der vier Aufstandspunkte, bezogen auf vorne links. + let heights: [Wheel: Double] = [ + .frontLeft: 0, + .frontRight: rightRise, + .rearLeft: rearRise, + .rearRight: rightRise + rearRise, + ] + let top = heights.values.max() ?? 0 + + return LevelingLift(frontLeft: top - heights[.frontLeft]!, + frontRight: top - heights[.frontRight]!, + rearLeft: top - heights[.rearLeft]!, + rearRight: top - heights[.rearRight]!) } } diff --git a/Shared/WheelLiftPlan.swift b/Shared/WheelLiftPlan.swift new file mode 100644 index 0000000..958505f --- /dev/null +++ b/Shared/WheelLiftPlan.swift @@ -0,0 +1,74 @@ +import SwiftUI + +/// Die Keilhöhen als Draufsicht auf das Fahrzeug. +/// +/// Vier Zahlen an vier Rädern, statt zweier Angaben „rechts" und „vorne", die +/// sich nicht getrennt ausführen lassen. Oben ist vorne; links im Bild ist +/// links am Fahrzeug, aus Sicht des Fahrers. +/// +/// Das höchststehende Rad bekommt nichts – es bleibt liegen, die anderen +/// werden auf seine Höhe gebracht. +struct WheelLiftPlan: View { + let lift: LevelingLift + /// Für die Uhr: kleinere Schrift, engerer Aufbau. + var isCompact = false + + var body: some View { + VStack(spacing: isCompact ? 2 : 6) { + Text("vorne") + .font(labelFont) + .foregroundStyle(.secondary) + + HStack(spacing: isCompact ? 6 : 12) { + VStack(spacing: isCompact ? 6 : 14) { + badge(.frontLeft) + badge(.rearLeft) + } + body_ + VStack(spacing: isCompact ? 6 : 14) { + badge(.frontRight) + badge(.rearRight) + } + } + + Text("hinten") + .font(labelFont) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityText) + } + + /// Der Umriss dazwischen macht aus vier Zahlen ein Fahrzeug. + private var body_: some View { + RoundedRectangle(cornerRadius: isCompact ? 4 : 8) + .strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1) + .frame(width: isCompact ? 26 : 48, height: isCompact ? 44 : 84) + } + + private func badge(_ wheel: LevelingLift.Wheel) -> some View { + let value = lift.centimetres(wheel) + let needed = value >= 1 + return Text(needed ? String(format: "%.0f", value) : "–") + .font(valueFont) + .monospacedDigit() + .foregroundStyle(needed ? .primary : .secondary) + .frame(width: isCompact ? 26 : 42, height: isCompact ? 20 : 30) + .background(needed ? Color.orange.opacity(0.25) : Color.secondary.opacity(0.12), + in: .rect(cornerRadius: isCompact ? 4 : 6)) + } + + private var labelFont: Font { isCompact ? .system(size: 9) : .caption2 } + private var valueFont: Font { + isCompact ? .system(size: 12, weight: .semibold) : .callout.weight(.semibold) + } + + private var accessibilityText: String { + let parts = LevelingLift.Wheel.allCases.compactMap { wheel -> String? in + let value = lift.centimetres(wheel) + guard value >= 1 else { return nil } + return String(format: "%@ %.0f Zentimeter", wheel.text, value) + } + return parts.isEmpty ? "Keine Keile nötig" : "Unterlegen: " + parts.joined(separator: ", ") + } +} diff --git a/Tests/main.swift b/Tests/main.swift index e0c54bb..544015e 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -826,28 +826,53 @@ ageing.add(pitch: 1, roll: 0, at: start.addingTimeInterval(AlignmentAssistant.me checkEqual("alte Messwerte fallen heraus", ageing.samples.count, 1) // MARK: Auffahrkeile -print("\nAuffahrkeile") -// tan(2°) x 2,00 m = 6,99 cm -let acrossWedge = LevelingWedge.across(roll: 2.0, trackWidth: 2.0) -checkEqual("Keilhöhe quer", - acrossWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 7.0) -checkEqual("rechts höher heisst Keil nach links", acrossWedge?.side, .left) -checkEqual("links höher heisst Keil nach rechts", - LevelingWedge.across(roll: -2.0, trackWidth: 2.0)?.side, .right) +print("\nAuffahrkeile – Höhe je Rad") -// tan(1,5°) x 3,50 m = 9,16 cm -let alongWedge = LevelingWedge.along(pitch: 1.5, wheelbase: 3.5) -checkEqual("Keilhöhe längs", - alongWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 9.2) -checkEqual("Heck höher heisst Keil nach vorne", alongWedge?.side, .front) -checkEqual("Front höher heisst Keil nach hinten", - LevelingWedge.along(pitch: -1.5, wheelbase: 3.5)?.side, .rear) +// Reine Querneigung: tan(2°) x 2,00 m = 6,99 cm unter beide Räder der +// tieferstehenden Seite. Rechts steht höher, also muss links hoch. +let acrossOnly = LevelingLift.compute(pitch: 0, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5) +checkEqual("quer: vorne links", + acrossOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 7.0) +checkEqual("quer: hinten links", + acrossOnly.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0) +checkEqual("quer: rechts bleibt liegen", + acrossOnly.map { $0.centimetres(.frontRight) + $0.centimetres(.rearRight) }, 0) -checkEqual("innerhalb der Toleranz kein Keil", - LevelingWedge.across(roll: 0.3, trackWidth: 2.0) == nil, true) -checkEqual("ohne Maß kein Keil", - LevelingWedge.across(roll: 3.0, trackWidth: 0) == nil, true) +// Reine Längsneigung: tan(1,5°) x 3,50 m = 9,16 cm unter beide Vorderräder, +// wenn das Heck höher steht. +let alongOnly = LevelingLift.compute(pitch: 1.5, roll: 0, trackWidth: 2.0, wheelbase: 3.5) +checkEqual("längs: vorne links", + alongOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 9.2) +checkEqual("längs: vorne rechts", + alongOnly.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2) +checkEqual("längs: hinten bleibt liegen", + alongOnly.map { $0.centimetres(.rearLeft) + $0.centimetres(.rearRight) }, 0) + +// Beides zusammen: Das tiefste Rad braucht die Summe, das höchste nichts, und +// die beiden dazwischen je einen Anteil. Genau das lässt sich mit getrennten +// Angaben für "rechts" und "vorne" nicht ausdrücken. +let liftBoth = LevelingLift.compute(pitch: 1.5, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5) +checkEqual("kombiniert: tiefstes Rad ist vorne links", liftBoth?.deepest, .frontLeft) +checkEqual("kombiniert: vorne links trägt beide Anteile", + liftBoth.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 16.1) +checkEqual("kombiniert: vorne rechts nur den Längsanteil", + liftBoth.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2) +checkEqual("kombiniert: hinten links nur den Queranteil", + liftBoth.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0) +checkEqual("kombiniert: hinten rechts steht am höchsten und bleibt liegen", + liftBoth.map { $0.centimetres(.rearRight) }, 0) + +// Vorzeichen: andersherum geneigt, andere Ecke. +let mirrored = LevelingLift.compute(pitch: -1.5, roll: -2.0, trackWidth: 2.0, wheelbase: 3.5) +checkEqual("umgekehrt geneigt: tiefstes Rad ist hinten rechts", + mirrored?.deepest, .rearRight) + +checkEqual("fast eben: kein Keil nötig", + LevelingLift.compute(pitch: 0.1, roll: 0.1, + trackWidth: 2.0, wheelbase: 3.5)?.isNegligible, true) +checkEqual("ohne Fahrzeugmasse keine Rechnung", + LevelingLift.compute(pitch: 2, roll: 2, trackWidth: 0, wheelbase: 3.5) == nil, true) // MARK: 12 – Einbaulage des Neigungsmessers print("\nEinbaulage")