first commit

This commit is contained in:
fototeddy
2026-09-04 21:30:51 +02:00
commit da64b1b6bd
160 changed files with 20664 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Werkzeuge
## make-app-icon.swift
Bereitet `logo.png` zum App-Icon auf. Nötig, weil das Logo als fertige Kachel
vorliegt: mit weißem Rand und eingebrannten runden Ecken. iOS maskiert die
Ecken selbst und verbietet einen Alphakanal unbearbeitet säße auf dem
Homescreen ein weißer Rahmen um das Icon.
Das Skript schneidet den weißen Rand weg, macht den Ausschnitt quadratisch und
versetzt ihn so weit nach innen, bis die vier Ecken im Hintergrund liegen.
Anschließend skaliert es auf 1024×1024 ohne Alphakanal.
```bash
swiftc -O -o /tmp/make-app-icon Tools/make-app-icon.swift
/tmp/make-app-icon logo.png CamperMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
```
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
```
+173
View File
@@ -0,0 +1,173 @@
import AppKit
import CoreGraphics
import Foundation
// App-Icons dürfen keinen Alphakanal und keine eigenen runden Ecken haben:
// iOS maskiert selbst. Ein Logo mit weißem Rand und eingebrannter Rundung
// bekäme sonst einen weißen Rahmen. Also Rand abschneiden, quadratisch
// zuschneiden und die dann weißen Ecken mit der Randfarbe auffüllen.
let arguments = CommandLine.arguments
guard arguments.count >= 3 else { print("Aufruf: makeicon <quelle> <ziel>"); exit(1) }
guard let source = NSImage(contentsOfFile: arguments[1]),
let sourceImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
print("Quelle nicht lesbar"); exit(1)
}
let width = sourceImage.width, height = sourceImage.height
let bytesPerRow = width * 4
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
guard let readContext = CGContext(
data: &pixels, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { print("Puffer nicht anlegbar"); exit(1) }
readContext.draw(sourceImage, in: CGRect(x: 0, y: 0, width: width, height: height))
@inline(__always) func offset(_ x: Int, _ y: Int) -> Int { y * bytesPerRow + x * 4 }
@inline(__always) func isWhite(_ x: Int, _ y: Int) -> Bool {
let i = offset(x, y)
return pixels[i] > 242 && pixels[i + 1] > 242 && pixels[i + 2] > 242
}
// Eine Reihe gilt als leer, wenn fast nichts darin farbig ist die
// abgerundete Ecke liefert sonst falsche Treffer.
func rowIsBlank(_ y: Int) -> Bool {
var coloured = 0
for x in stride(from: 0, to: width, by: 3) where !isWhite(x, y) {
coloured += 1
if coloured >= 3 { return false }
}
return true
}
func columnIsBlank(_ x: Int) -> Bool {
var coloured = 0
for y in stride(from: 0, to: height, by: 3) where !isWhite(x, y) {
coloured += 1
if coloured >= 3 { return false }
}
return true
}
var top = 0, bottom = height - 1, left = 0, right = width - 1
while top < bottom, rowIsBlank(top) { top += 1 }
while bottom > top, rowIsBlank(bottom) { bottom -= 1 }
while left < right, columnIsBlank(left) { left += 1 }
while right > left, columnIsBlank(right) { right -= 1 }
print("Quelle \(width)x\(height) → Inhalt (\(left),\(top)) \(right - left + 1)x\(bottom - top + 1)")
// Auf ein Quadrat erweitern, sonst verzerrt das Skalieren.
let side = min(max(right - left + 1, bottom - top + 1), min(width, height))
let centreX = (left + right) / 2, centreY = (top + bottom) / 2
let cropX = max(0, min(width - side, centreX - side / 2))
let cropY = max(0, min(height - side, centreY - side / 2))
print("Quadrat: (\(cropX),\(cropY)) \(side)x\(side)")
// Reparieren lohnt nicht: der weiche Übergang an der eingebrannten Rundung
// lässt sich nicht sauber nachfärben. Stattdessen so weit hineinschneiden,
// bis die vier Ecken im Hintergrund liegen. Das kostet ein paar Prozent
// Bildfläche, dafür bleibt kein heller Rand stehen.
@inline(__always) func isPale(_ x: Int, _ y: Int) -> Bool {
let i = offset(x, y)
return pixels[i] > 150 && pixels[i + 1] > 150 && pixels[i + 2] > 150
}
var inset = 0
while inset < side / 5 {
let x0 = cropX + inset, y0 = cropY + inset
let x1 = cropX + side - 1 - inset, y1 = cropY + side - 1 - inset
if !isPale(x0, y0) && !isPale(x1, y0) && !isPale(x0, y1) && !isPale(x1, y1) { break }
inset += 2
}
inset += 8 // Sicherheitsabstand gegen weiche Kanten
let innerX = cropX + inset, innerY = cropY + inset
let innerSide = side - 2 * inset
print("Nach innen versetzt um \(inset) → (\(innerX),\(innerY)) \(innerSide)x\(innerSide)")
// Den hellen Aussenbereich vom Bildrand her zusammenhängend markieren.
// Über die Helligkeit allein ginge es nicht: das Wohnmobil und die Schrift
// im Logo sind ebenfalls weiss, liegen aber innen und werden so nicht
// erfasst. Der weiche Übergang an der Rundung gehört mit dazu, sonst
// bleiben helle Ränder stehen.
@inline(__always) func isBright(_ x: Int, _ y: Int) -> Bool {
let i = offset(x, y)
return pixels[i] > 200 && pixels[i + 1] > 200 && pixels[i + 2] > 200
}
var isOutside = [Bool](repeating: false, count: width * height)
var queue: [Int] = []
for x in 0..<width {
for y in [0, height - 1] where isBright(x, y) && !isOutside[y * width + x] {
isOutside[y * width + x] = true
queue.append(y * width + x)
}
}
for y in 0..<height {
for x in [0, width - 1] where isBright(x, y) && !isOutside[y * width + x] {
isOutside[y * width + x] = true
queue.append(y * width + x)
}
}
var head = 0
while head < queue.count {
let index = queue[head]; head += 1
let x = index % width, y = index / width
for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
let nx = x + dx, ny = y + dy
guard nx >= 0, nx < width, ny >= 0, ny < height else { continue }
let neighbour = ny * width + nx
guard !isOutside[neighbour], isBright(nx, ny) else { continue }
isOutside[neighbour] = true
queue.append(neighbour)
}
}
// Markierte Pixel durch die nächste erhaltene Stelle derselben Zeile
// ersetzen, damit der Farbverlauf des Hintergrunds weiterläuft.
var repaired = 0
for y in 0..<height {
var firstKept = -1, lastKept = -1
for x in 0..<width where !isOutside[y * width + x] {
if firstKept < 0 { firstKept = x }
lastKept = x
}
guard firstKept >= 0 else { continue }
for x in 0..<width where isOutside[y * width + x] {
let sourceX = x < firstKept ? firstKept : (x > lastKept ? lastKept : -1)
guard sourceX >= 0 else { continue }
let from = offset(sourceX, y), to = offset(x, y)
pixels[to] = pixels[from]
pixels[to + 1] = pixels[from + 1]
pixels[to + 2] = pixels[from + 2]
repaired += 1
}
}
print("Aussenbereich ersetzt: \(repaired) Pixel")
guard let repairedImage = readContext.makeImage(),
let cropped = repairedImage.cropping(
to: CGRect(x: innerX, y: innerY, width: innerSide, height: innerSide))
else { print("Zuschnitt fehlgeschlagen"); exit(1) }
// Auf 1024x1024 bringen, ohne Alphakanal.
let target = 1024
guard let outputContext = CGContext(
data: nil, width: target, height: target,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
) else { print("Ausgabe nicht anlegbar"); exit(1) }
outputContext.interpolationQuality = .high
outputContext.draw(cropped, in: CGRect(x: 0, y: 0, width: target, height: target))
guard let finalImage = outputContext.makeImage() else { print("Bild leer"); exit(1) }
let rep = NSBitmapImageRep(cgImage: finalImage)
guard let png = rep.representation(using: .png, properties: [:]) else {
print("PNG nicht erzeugbar"); exit(1)
}
try png.write(to: URL(fileURLWithPath: arguments[2]))
print("Geschrieben: \(arguments[2]) (\(target)x\(target))")
+61
View File
@@ -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")