lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain bildausschnitt.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { bildzuschnitt, planblatt } from '@/domain/geometrie/planblatt'; |
| 3 | import type { Lageplan } from '@/domain/geometrie/lageplan'; |
| 4 | |
| 5 | /** |
| 6 | * Welchen Teil des Luftbilds das Lageplanblatt zeigt. |
| 7 | * |
| 8 | * WOZU (aus dem Betrieb, 11.09.2026): Das gedruckte Blatt zeigte die |
| 9 | * Fahrlinien ohne Bild darunter und trug keine Angabe zur Verortung - kein |
| 10 | * Nordpfeil, kein Strassenname, keine Koordinate. Wer es allein vor sich |
| 11 | * hatte, konnte nicht sagen, wo die Zeichnung liegt. |
| 12 | * |
| 13 | * DAS BILD GANZ MITZUGEBEN WAR NIE MOEGLICH: bis zu 24 MiB je Unterlage. Der |
| 14 | * Ausweg ist der Zuschnitt - das Blatt zeigt ohnehin nur den gezeichneten |
| 15 | * Bereich, und danach haengt die Groesse an der Blattgroesse statt an der |
| 16 | * Vorlage. Diese Datei bewacht die Rechnung dahinter. |
| 17 | * |
| 18 | * DIE GEFAEHRLICHE STELLE ist nicht die Groesse, sondern die DECKUNG: Liegt |
| 19 | * das Bild um Bildpunkte verschoben unter den Linien, sieht ein Pruefer |
| 20 | * Raeumwege neben der Fahrbahn und haelt die Zeichnung fuer falsch - oder, |
| 21 | * schlimmer, er haelt sie fuer richtig und die Fahrbahn fuer woanders. |
| 22 | */ |
| 23 | |
| 24 | /** Ein Lageplan mit bekanntem Massstab: 100 Bildpunkte sind 20 m. */ |
| 25 | function lageplan(punkte: readonly { x: number; y: number }[]): Lageplan { |
| 26 | return { |
| 27 | arbeitsbereiche: [], |
| 28 | signalgeber: [], |
| 29 | bild: null, |
| 30 | kalibrierung: { |
| 31 | von: { x: 0, y: 0 }, |
| 32 | bis: { x: 100, y: 0 }, |
| 33 | laengeMeter: 20, |
| 34 | herkunft: 'georeferenziert', |
| 35 | }, |
| 36 | haltlinien: [], |
| 37 | linien: [ |
| 38 | { |
| 39 | id: 'l1', |
| 40 | name: 'K1', |
| 41 | mode: 'kfz', |
| 42 | movement: 'geradeaus', |
| 43 | breiteMeter: 3, |
| 44 | punkte, |
| 45 | haltlinieId: null, |
| 46 | startT: 0, |
| 47 | signalGroupId: null, |
| 48 | }, |
| 49 | ], |
| 50 | }; |
| 51 | } |
| 52 | |
| 53 | /** Ein quadratisches Blatt macht die Rechnung ohne Verzerrung nachvollziehbar. */ |
| 54 | function quadratblatt(punkte: readonly { x: number; y: number }[]) { |
| 55 | const blatt = planblatt(lageplan(punkte), 200, 200, 20); |
| 56 | if (blatt === null) throw new Error('Blatt erwartet'); |
| 57 | return blatt; |
| 58 | } |
| 59 | |
| 60 | describe('Der Bildausschnitt ist die Umkehrung der Abbildung', () => { |
| 61 | it('umfasst mehr als das Gezeichnete - naemlich das ganze Blatt', () => { |
| 62 | // Gezeichnet sind 100 Bildpunkte, das Blatt ist 200 Punkte breit mit 20 |
| 63 | // Punkten Rand: Der Faktor ist 160/100 = 1,6, und das ganze Blatt deckt |
| 64 | // 200/1,6 = 125 Bildpunkte ab. |
| 65 | const blatt = quadratblatt([ |
| 66 | { x: 0, y: 0 }, |
| 67 | { x: 100, y: 100 }, |
| 68 | ]); |
| 69 | expect(blatt.bildausschnitt.breite).toBeCloseTo(125, 6); |
| 70 | expect(blatt.bildausschnitt.hoehe).toBeCloseTo(125, 6); |
| 71 | }); |
| 72 | |
| 73 | it('beginnt um den Rand VOR dem Gezeichneten', () => { |
| 74 | const blatt = quadratblatt([ |
| 75 | { x: 40, y: 60 }, |
| 76 | { x: 140, y: 160 }, |
| 77 | ]); |
| 78 | // 20 Blattpunkte Rand sind 20/1,6 = 12,5 Bildpunkte. |
| 79 | expect(blatt.bildausschnitt.x).toBeCloseTo(40 - 12.5, 6); |
| 80 | expect(blatt.bildausschnitt.y).toBeCloseTo(60 - 12.5, 6); |
| 81 | }); |
| 82 | |
| 83 | it('bildet jeden Blattpunkt auf denselben Bildpunkt ab wie die Linien', () => { |
| 84 | /* |
| 85 | * DER EIGENTLICHE NACHWEIS DER DECKUNG. Wo eine Linie auf dem Blatt liegt, |
| 86 | * muss der Ausschnitt denselben Bildpunkt zeigen - sonst liegt das Bild |
| 87 | * gegen die Zeichnung verschoben. |
| 88 | */ |
| 89 | const roh = [ |
| 90 | { x: 10, y: 20 }, |
| 91 | { x: 210, y: 120 }, |
| 92 | ]; |
| 93 | const blatt = quadratblatt(roh); |
| 94 | const a = blatt.bildausschnitt; |
| 95 | for (const [i, punkt] of blatt.linien[0]!.punkte.entries()) { |
| 96 | const zurueck = { |
| 97 | x: a.x + (punkt.x / blatt.breite) * a.breite, |
| 98 | y: a.y + (punkt.y / blatt.hoehe) * a.hoehe, |
| 99 | }; |
| 100 | expect(zurueck.x).toBeCloseTo(roh[i]!.x, 6); |
| 101 | expect(zurueck.y).toBeCloseTo(roh[i]!.y, 6); |
| 102 | } |
| 103 | }); |
| 104 | |
| 105 | it('steht auch dann bereit, wenn das Projekt gar kein Bild fuehrt', () => { |
| 106 | // Er beschreibt eine Abbildung, keinen Bestand. Ein wahlfreies Feld |
| 107 | // muesste jede Lesestelle abfragen. |
| 108 | const blatt = quadratblatt([ |
| 109 | { x: 0, y: 0 }, |
| 110 | { x: 50, y: 50 }, |
| 111 | ]); |
| 112 | expect(blatt.bildausschnitt.breite).toBeGreaterThan(0); |
| 113 | expect(blatt.bildausschnitt.hoehe).toBeGreaterThan(0); |
| 114 | }); |
| 115 | }); |
| 116 | |
| 117 | describe('Der Zuschnitt beschneidet an den Bildkanten', () => { |
| 118 | /** Zeichnung mitten in einem grossen Bild - nichts ragt hinaus. */ |
| 119 | const innen = () => |
| 120 | quadratblatt([ |
| 121 | { x: 400, y: 400 }, |
| 122 | { x: 500, y: 500 }, |
| 123 | ]); |
| 124 | |
| 125 | it('nimmt bei einer Zeichnung mitten im Bild den ganzen Zielbereich ein', () => { |
| 126 | const z = bildzuschnitt(innen(), 1000, 1000, 400, 400); |
| 127 | expect(z).not.toBeNull(); |
| 128 | expect(z!.zielX).toBeCloseTo(0, 6); |
| 129 | expect(z!.zielY).toBeCloseTo(0, 6); |
| 130 | expect(z!.zielBreite).toBeCloseTo(400, 6); |
| 131 | expect(z!.zielHoehe).toBeCloseTo(400, 6); |
| 132 | }); |
| 133 | |
| 134 | it('rechnet den Quellbereich in Bildpunkte des Bildes um', () => { |
| 135 | const blatt = innen(); |
| 136 | const z = bildzuschnitt(blatt, 1000, 1000, 400, 400)!; |
| 137 | expect(z.quelleX).toBeCloseTo(blatt.bildausschnitt.x, 6); |
| 138 | expect(z.quelleBreite).toBeCloseTo(blatt.bildausschnitt.breite, 6); |
| 139 | }); |
| 140 | |
| 141 | it('schneidet ab, was ueber die linke obere Bildkante hinausragt', () => { |
| 142 | /* |
| 143 | * DER REGELFALL BEI EINER ZEICHNUNG AM BILDRAND. Ohne diese Rechnung |
| 144 | * verhielten sich die Ausgabewege verschieden: Die Zeichenflaeche des |
| 145 | * Browsers beschneidet und verschiebt das Ziel stillschweigend mit, ein |
| 146 | * PDF-Erzeuger nicht - und das Bild laege dann verschoben unter den Linien. |
| 147 | */ |
| 148 | const blatt = quadratblatt([ |
| 149 | { x: 0, y: 0 }, |
| 150 | { x: 100, y: 100 }, |
| 151 | ]); |
| 152 | // Der Ausschnitt beginnt bei -12,5 - also 12,5 von 125 Bildpunkten |
| 153 | // ausserhalb, das sind 10 Prozent. |
| 154 | const z = bildzuschnitt(blatt, 1000, 1000, 400, 400)!; |
| 155 | expect(z.quelleX).toBe(0); |
| 156 | expect(z.quelleY).toBe(0); |
| 157 | expect(z.zielX).toBeCloseTo(40, 6); |
| 158 | expect(z.zielY).toBeCloseTo(40, 6); |
| 159 | expect(z.zielBreite).toBeCloseTo(360, 6); |
| 160 | }); |
| 161 | |
| 162 | it('schneidet ab, was ueber die rechte untere Bildkante hinausragt', () => { |
| 163 | const blatt = quadratblatt([ |
| 164 | { x: 0, y: 0 }, |
| 165 | { x: 100, y: 100 }, |
| 166 | ]); |
| 167 | // Bild nur 100 x 100 gross: Der Ausschnitt reicht bis 112,5. |
| 168 | const z = bildzuschnitt(blatt, 100, 100, 400, 400)!; |
| 169 | expect(z.quelleBreite).toBeCloseTo(100, 6); |
| 170 | expect(z.zielX).toBeCloseTo(40, 6); |
| 171 | expect(z.zielBreite).toBeCloseTo(320, 6); |
| 172 | expect(z.zielX + z.zielBreite).toBeLessThan(400); |
| 173 | }); |
| 174 | |
| 175 | it('meldet null, wo das Blatt gar nichts vom Bild zeigt', () => { |
| 176 | // Kein Fehler: Eine Zeichnung kann vollstaendig neben dem Bild liegen. |
| 177 | const blatt = quadratblatt([ |
| 178 | { x: 5000, y: 5000 }, |
| 179 | { x: 5100, y: 5100 }, |
| 180 | ]); |
| 181 | expect(bildzuschnitt(blatt, 100, 100, 400, 400)).toBeNull(); |
| 182 | }); |
| 183 | |
| 184 | it('meldet null, wo das Blatt das Bild nur an der Kante beruehrt', () => { |
| 185 | /* |
| 186 | * Ein Ausschnitt der Breite null ist kein Ausschnitt. Wuerde er |
| 187 | * durchgelassen, ginge ein `drawImage` mit Breite 0 an die Zeichenflaeche |
| 188 | * und ein Bild ohne Ausdehnung in das PDF - je nach Ausgabeweg ein |
| 189 | * unsichtbarer Fehlschlag oder eine Ausnahme mitten im Erzeugen der |
| 190 | * Unterlage. |
| 191 | * |
| 192 | * Gezeichnet ist von 100 bis 180, der Ausschnitt beginnt deshalb bei 90. |
| 193 | * Ein Bild von genau 90 Bildpunkten Breite endet dort, wo er anfaengt. |
| 194 | */ |
| 195 | const blatt = quadratblatt([ |
| 196 | { x: 100, y: 100 }, |
| 197 | { x: 180, y: 180 }, |
| 198 | ]); |
| 199 | expect(blatt.bildausschnitt.x).toBeCloseTo(90, 6); |
| 200 | expect(bildzuschnitt(blatt, 90, 1000, 400, 400)).toBeNull(); |
| 201 | }); |
| 202 | |
| 203 | it('meldet null bei unbrauchbaren Massen, statt zu rechnen', () => { |
| 204 | const blatt = innen(); |
| 205 | expect(bildzuschnitt(blatt, 0, 1000, 400, 400)).toBeNull(); |
| 206 | expect(bildzuschnitt(blatt, 1000, 1000, 0, 400)).toBeNull(); |
| 207 | expect(bildzuschnitt(blatt, Number.NaN, 1000, 400, 400)).toBeNull(); |
| 208 | expect(bildzuschnitt(blatt, 1000, 1000, 400, Number.POSITIVE_INFINITY)).toBeNull(); |
| 209 | }); |
| 210 | |
| 211 | it('haelt Quelle und Ziel im selben Verhaeltnis - sonst waere das Bild verzerrt', () => { |
| 212 | const blatt = innen(); |
| 213 | const z = bildzuschnitt(blatt, 430, 1000, 900, 400)!; |
| 214 | const quellVerhaeltnis = z.quelleBreite / z.quelleHoehe; |
| 215 | const zielVerhaeltnis = z.zielBreite / z.zielHoehe / (900 / blatt.breite / (400 / blatt.hoehe)); |
| 216 | expect(zielVerhaeltnis).toBeCloseTo(quellVerhaeltnis, 6); |
| 217 | }); |
| 218 | }); |