import type { Meters } from '../units'; import { isFiniteNumber, roundTo } from '../units'; import type { Movement, TrafficMode } from '../rilsa/types'; /** * Vermessung aus gezeichneter Geometrie. * * Ziel: Raeum- und Einfahrweg nicht mehr von Hand abmessen und abtippen, * sondern aus den im Lageplan gezeichneten Fahrlinien ableiten. * * DIE ENTSCHEIDENDE STELLE * * Ein Konfliktbereich ist keine Punktueberschneidung. Fahrzeuge haben eine * Breite; der Bereich, in dem sich zwei Stroeme ins Gehege kommen, hat deshalb * eine Ausdehnung. Nach RiLSA gilt: * * Raeumweg sr Haltlinie des raeumenden Stroms bis zum ENDE des Konfliktbereichs * Einfahrweg se Haltlinie des einfahrenden Stroms bis zum BEGINN des Konfliktbereichs * * Modelliert wird jeder Strom als Mittellinie mit einer Breite. Kreuzen sich * zwei Mittellinien unter dem Winkel theta, so durchfaehrt Strom A den Korridor * von Strom B ueber die Laenge * * l = b_B / sin(theta) * * mit b_B als Breite von B. Bei rechtwinkliger Kreuzung ist das genau die * Breite von B; bei spitzem Winkel entsprechend mehr - was fachlich richtig ist, * denn ein schraeg kreuzendes Fahrzeug ist laenger im Konfliktbereich. * * Daraus folgt fuer den Schnittpunkt im Abstand d entlang der jeweiligen Linie: * * sr(A) = d_A + (b_B / 2) / sin(theta) bis zur hinteren Kante * se(B) = d_B − (b_A / 2) / sin(theta) bis zur vorderen Kante * * Die Fahrzeuglaenge kommt NICHT hier dazu - sie wird in der * Zwischenzeitberechnung getrennt beruecksichtigt, damit im Pruefbericht * nachvollziehbar bleibt, woraus sich der Raeumweg zusammensetzt. * * Fuer Fussgaengerfurten und Radquerungen gilt statt der Zeichenrichtung die * unguenstigere Richtung - siehe `vermesseWege`. */ export interface Punkt { readonly x: Meters; readonly y: Meters; } /** * Woher der Strom kommt. * * Die einzige fachliche Angabe, die dieses Modul kennt - und es braucht sie: * Ohne sie laesst sich nicht entscheiden, ob eine Beruehrung kurz hinter dem * Start ein Konflikt ist oder das Auffaechern zweier Stroeme aus demselben * Querschnitt. Aus derselben Zufahrt geradeaus und links abzubiegen ergibt * zwei Fahrlinien mit gemeinsamem Anfangspunkt; rein geometrisch ist das eine * Kreuzung, fachlich ist es keine. */ export interface Strombezug { /** `null` heisst "keiner Haltlinie zugeordnet" - dann greift keine Ausnahme. */ readonly haltlinieId: string | null; readonly haltlinienArt: 'zufahrt' | 'querung' | null; readonly mode: TrafficMode; /** * Fahrbeziehung der gezeichneten Linie. `fahrlinienVon` gibt sie jeder Linie * mit; fehlt sie, ist sie unbekannt, und `querungsart` behandelt sie wie * eine Querung - unbekannt heisst sicher. */ readonly movement?: Movement; /** Lage auf der Haltlinie, 0..1 - gibt die Reihenfolge im Querschnitt. */ readonly startT: number; } /** * Fahrlinie eines Verkehrsstroms. * * Der erste Punkt ist die Haltlinie; von dort werden alle Wege gemessen. Die * weiteren Punkte beschreiben den Fahrweg durch den Knotenpunkt - bei * Abbiegern also einen Bogen, angenaehert durch einen Linienzug. * * AUSNAHME QUERUNG: Eine Furt oder Radquerung wird von beiden Enden aus * gemessen, und es gilt die unguenstigere Richtung (`querungsart`, * `vermesseWege`). Ihr erster Punkt sagt nur, wo die Zeichnung begann. */ export interface Fahrlinie { readonly id: string; /** Mindestens zwei Punkte; der erste liegt auf der Haltlinie. */ readonly punkte: readonly Punkt[]; /** Breite des beanspruchten Korridors in Metern. */ readonly breite: Meters; /** * Herkunft des Stroms. * * Bewusst wahlfrei: Fehlt sie, verhaelt sich alles wie zuvor. Damit bleiben * uebernommene Projekte, in denen keine Haltlinien gezeichnet sind, exakt bei * ihrem bisherigen Verhalten - die sichere Seite, denn ohne Zuordnung wird * keine Beruehrung ausgeblendet. */ readonly bezug?: Strombezug; } /** Uebliche Korridorbreiten, wenn nichts anderes bekannt ist. */ export const REGELBREITE = { /** Fahrstreifen Kraftfahrzeugverkehr. */ kfz: 3.25 as Meters, /** Radverkehrsanlage. */ rad: 1.6 as Meters, /** Fussgaengerfurt. */ fuss: 4.0 as Meters, /** Strassenbahn. */ oepnv: 3.0 as Meters, } as const; /** * Vorbelegte Breite eines gezeichneten Arbeitsbereichs. * * KEIN REGELWERKSWERT und deshalb nicht in `constants.ts`: Die RSA 21 gibt * keine Breite einer Arbeitsstelle vor - sie haengt an der Baustelle. 4,0 m * sind der Anhalt einer gesperrten Fahrspur samt Absperrung; der Bearbeiter * traegt die wirkliche Breite ein. */ export const ARBEITSBEREICH_REGELBREITE: Meters = 4.0; /** Ein Schnitt zweier Fahrlinien mit den Abstaenden entlang beider Linien. */ export interface Schnitt { readonly punkt: Punkt; /** Abstand von der Haltlinie entlang Linie A. */ readonly abstandA: Meters; /** Abstand von der Haltlinie entlang Linie B. */ readonly abstandB: Meters; /** Schnittwinkel im Bogenmass, immer zwischen 0 und pi/2. */ readonly winkel: number; } /** Ausdehnung des Konfliktbereichs entlang beider Fahrlinien. */ export interface Konfliktbereich { /** Beginn entlang A, von der Haltlinie A aus. */ readonly vonA: Meters; /** Ende entlang A. */ readonly bisA: Meters; readonly vonB: Meters; readonly bisB: Meters; /** Wie viele Schnittpunkte lagen zugrunde? */ readonly schnitte: number; /** * Winkel des Schnitts, der `bisA` bestimmt hat - der fuer den Raeumweg * massgebende. Zuvor nahm die Vermassung den ERSTEN Schnitt; kreuzte ein * Abbieger weiter hinten einen zweiten Strom, gehoerte der ausgewiesene * Winkel zur falschen Stelle, und Rechnung und Anzeige fielen auseinander. * * Bestimmt statt eines Schnitts ein gemeinsames Stueck das Ende entlang A, * steht hier 0 - dieselbe Bedeutung "laengs" wie bei einem Bereich ganz ohne * Punktschnitt. Es gibt also Bereiche mit `schnitte` groesser null und * `winkel` gleich null; einen Schnitt, der `bisA` bestimmt haette, gibt es * dort nicht. */ readonly winkel: number; /** * Ist eine Ueberlagerung in den Bereich eingegangen? Auch dann, wenn es * daneben Punktschnitte gab - Kreuzen und Laengslauf schliessen einander * nicht aus, und beides gehoert in dieselbe Huellkurve. */ readonly ausUeberlappung: boolean; } /** Ergebnis der Vermessung einer gerichteten Beziehung. */ export interface VermesseneWege { /** * Raeumweg: Haltlinie des raeumenden Stroms bis zum Ende des Konfliktbereichs. * Bei einer Querung in der unguenstigeren Richtung (siehe `vermesseWege`). */ readonly raeumweg: Meters; /** * Einfahrweg: Haltlinie des einfahrenden Stroms bis zum Beginn des * Konfliktbereichs. Bei einer Querung in der unguenstigeren Richtung, beim * Fussgaenger 0 m (siehe `vermesseWege`). */ readonly einfahrweg: Meters; /** Schnittwinkel in Grad - fuer die Nachvollziehbarkeit im Bericht. */ readonly winkelGrad: number; readonly konfliktbereich: Konfliktbereich; } // --- Grundrechnungen -------------------------------------------------------- export function abstand(a: Punkt, b: Punkt): Meters { return Math.hypot(b.x - a.x, b.y - a.y); } /** Gesamtlaenge eines Linienzugs. */ export function laenge(linie: Fahrlinie): Meters { let summe = 0; for (let i = 1; i < linie.punkte.length; i += 1) { summe += abstand(linie.punkte[i - 1]!, linie.punkte[i]!); } return summe; } /** Untere Schranke des Schnittwinkels; darunter ist "kreuzen" nicht mehr sinnvoll. */ const MIN_WINKEL = (10 * Math.PI) / 180; const SIN_MIN = Math.sin(MIN_WINKEL); /** Ab dieser Laenge gilt ein gemeinsames Stueck als Ueberlagerung, nicht als Rauschen. */ const MIN_UEBERLAPPUNG: Meters = 1.0; /** Bis zu diesem Winkel gelten zwei Segmente als gleichlaufend. */ const COS_PARALLEL = Math.cos((5 * Math.PI) / 180); /** Was bei der Schnittsuche gefunden und was verworfen wurde. */ export interface Schnittbefund { readonly schnitte: readonly Schnitt[]; /** Wie viele Treffer als Auffaecherung verworfen wurden. */ readonly unterdrueckt: number; /** Wie viele Treffer doppelt gezaehlt worden waeren. */ readonly doppelt: number; } /** * Schnittpunkte zweier Fahrlinien - mit Auskunft darueber, was verworfen wurde. * * Geprueft wird segmentweise. Beruehrungen in einem Endpunkt zaehlen weiterhin * mit: Zwei Stroeme, die in dieselbe Ausfahrt einlaufen, treffen sich in ihrem * letzten Punkt und stehen dennoch in Konflikt. Eine Regel "Beruehrung im * Endpunkt zaehlt nie" waere die unsichere Seite - sie verwuerfe unter anderem * eine Fussgaengerfurt, deren erster Punkt auf der Haltlinie eines Kfz-Stroms * liegt. * * Was NICHT mehr zaehlt, ist das Auffaechern zweier Kraftfahrzeugstroeme aus * derselben Haltlinie - und zwar aufgrund ihrer Zugehoerigkeit, nicht aufgrund * einer Toleranz. Jede so verworfene Beruehrung wird gezaehlt und der * Oberflaeche ausgewiesen; das Unterdruecken eines Konflikts ist * sicherheitsrelevant und darf nie stillschweigend geschehen. */ export function schneideMitBefund(a: Fahrlinie, b: Fahrlinie): Schnittbefund { const ergebnis: Schnitt[] = []; let unterdrueckt = 0; let doppelt = 0; let laengeA = 0; for (let i = 1; i < a.punkte.length; i += 1) { const a1 = a.punkte[i - 1]!; const a2 = a.punkte[i]!; const segmentA = abstand(a1, a2); let laengeB = 0; for (let j = 1; j < b.punkte.length; j += 1) { const b1 = b.punkte[j - 1]!; const b2 = b.punkte[j]!; const segmentB = abstand(b1, b2); const treffer = segmentSchnitt(a1, a2, b1, b2); if (treffer !== null) { const schnitt: Schnitt = { punkt: treffer.punkt, abstandA: laengeA + treffer.tA * segmentA, abstandB: laengeB + treffer.tB * segmentB, winkel: treffer.winkel, }; // Verglichen wird das Abstandspaar, nicht der geometrische Punkt: Ein // Strom, der dieselbe Stelle zweimal durchfaehrt - Wendefahrbeziehung, // zweifach gequerte Furt -, liefert dort zwei verschiedene Treffer. // Ueber den Punkt entdoppelt, braeche die Huellkurve zusammen, fuer die // der Konfliktbereich gerade da ist. if (schonErfasst(ergebnis, schnitt)) { doppelt += 1; } else if (istAuffaecherung(a, b, schnitt)) { unterdrueckt += 1; } else { ergebnis.push(schnitt); } } laengeB += segmentB; } laengeA += segmentA; } return { schnitte: ergebnis, unterdrueckt, doppelt }; } /** Schnittpunkte zweier Fahrlinien. */ export function schneide(a: Fahrlinie, b: Fahrlinie): Schnitt[] { return [...schneideMitBefund(a, b).schnitte]; } /** * Ein Stuetzpunkt gehoert zu zwei Segmenten. Faellt ein Schnitt genau darauf, * wird er von beiden gefunden und zaehlte sonst doppelt. */ function schonErfasst(bisher: readonly Schnitt[], neu: Schnitt): boolean { return bisher.some( (s) => Math.abs(s.abstandA - neu.abstandA) < 1e-6 && Math.abs(s.abstandB - neu.abstandB) < 1e-6, ); } /** * Ist der Treffer nur das Auffaechern zweier Stroeme aus demselben Querschnitt? * * Zwei Kraftfahrzeugstroeme, die an derselben Haltlinie nebeneinander stehen - * einer faehrt geradeaus, einer biegt links ab -, fahren gleichzeitig ab und * laufen auseinander. Ihr gemeinsamer Anfang ist keine Konfliktflaeche, sondern * der gemeinsame Ausgangspunkt. Eine Zwischenzeit waere dort auch * schalttechnisch nicht darstellbar: Sie setzt voraus, dass der einfahrende * Strom wartet, waehrend der andere raeumt - bei gemeinsamer Haltlinie und * gleichzeitiger Freigabe gibt es diesen Zustand nicht. * * Die Ausnahme ist eng gefasst. Jede einzelne Bedingung faengt einen Fall ab, * der ein echter Konflikt waere: * * 1. NUR AN EIN UND DERSELBEN HALTLINIE, als Objekt verglichen. Versetzte * Haltlinien, aufgeweitete Radaufstellstreifen und vorgelagerte * Teilknoten sind eigene Objekte; dort greift die Ausnahme nicht. Ohne * Zuordnung greift sie ebenfalls nicht. * 2. NUR KRAFTFAHRZEUG GEGEN KRAFTFAHRZEUG. Der Rechtsabbieger gegen den * parallel gefuehrten Radstrom derselben Zufahrt ist einer der * sicherheitskritischsten Konflikte an einer Lichtsignalanlage. * 3. NUR AN EINER HALTLINIE DER ZUFAHRT. Eine Furt gehoert zum Knotenpunktarm, * nicht zu dessen Zufahrt; sie ist gegenueber den einfahrenden Stroemen * ihres eigenen Arms planmaessig feindlich. * 4. NUR SOLANGE DIE STROEME BIS DAHIN GEMEINSAM LAUFEN. Das ist die * entscheidende Bedingung - siehe unten. * * ZUR VIERTEN BEDINGUNG * * Eine einstreifige Zufahrt, aus der geradeaus, links und rechts gefahren * werden darf, ergibt drei Stroeme auf demselben Fahrstreifen. Sie laufen von * der Haltlinie bis in den Knotenpunkt gemeinsam und trennen sich erst dort. * Ihr Trennpunkt ist keine Konfliktflaeche, sondern das Ende des gemeinsamen * Fahrstreifens - und er liegt nicht bei null, sondern etliche Meter hinter der * Haltlinie. * * Massgebend ist deshalb nicht der Abstand zur Haltlinie, sondern die Frage: * Sind die beiden Stroeme von der Haltlinie bis zu dieser Beruehrung * durchgehend im selben Fahrstreifen gefahren? Wenn ja, ist die Beruehrung ihr * Auseinanderlaufen. Wenn nein, kreuzt einer die Bahn des anderen, und das ist * ein Konflikt - der Linksabbieger vom rechten Fahrstreifen ist genau dieser * Fall. * * Zwei frueher erwogene Fassungen sind verworfen worden, beide aufgrund eigener * Testfaelle: eine Seitenpruefung, die bei einem Millimeter Versatz im * Startpunkt kippte, und eine feste Laengenschranke, die den gemeinsamen * Fahrstreifen nicht erfasste. */ function istAuffaecherung(a: Fahrlinie, b: Fahrlinie, schnitt: Schnitt): boolean { const bezugA = a.bezug; const bezugB = b.bezug; if (bezugA === undefined || bezugB === undefined) return false; if (bezugA.haltlinieId === null || bezugA.haltlinieId !== bezugB.haltlinieId) return false; if (bezugA.mode !== 'kfz' || bezugB.mode !== 'kfz') return false; if (bezugA.haltlinienArt !== 'zufahrt') return false; return laufenGemeinsam(a, b, Math.max(schnitt.abstandA, schnitt.abstandB)); } /** * Sind zwei Stroeme von der Haltlinie bis zum Abstand `bis` durchgehend im * selben Fahrstreifen gefahren? * * Verglichen werden die Orte bei gleicher Bogenlaenge: Zwei Fahrzeuge im selben * Fahrstreifen stehen hintereinander in derselben Schlange und legen dieselbe * Strecke zurueck. * * Als "im selben Fahrstreifen" gilt ein Abstand unter einer halben * Korridorbreite. Der Wert ist nicht frei gewaehlt, sondern trennt genau die * beiden Faelle, um die es geht: Stroeme desselben Fahrstreifens liegen * praktisch aufeinander, Stroeme benachbarter Fahrstreifen eine volle * Fahrstreifenbreite auseinander - also mehr als doppelt so weit. * * GENAUIGKEIT: Zwischen zwei Stuetzpunkten verlaufen beide Linien geradlinig, * ihr Abstandsquadrat ist dort also eine nach oben geoeffnete Parabel. Sein * Groesstwert liegt damit immer an einem Stuetzpunkt. Es genuegt, die * Stuetzpunkte beider Linien zu pruefen - abgetastet wird nichts, das Ergebnis * haengt an keiner Schrittweite. */ function laufenGemeinsam(a: Fahrlinie, b: Fahrlinie, bis: Meters): boolean { const grenze = Math.min(a.breite, b.breite) / 2; const stellen = new Set([0]); for (const linie of [a, b]) { let s = 0; for (let i = 1; i < linie.punkte.length; i += 1) { s += abstand(linie.punkte[i - 1]!, linie.punkte[i]!); if (s > 0 && s <= bis) stellen.add(s); } } if (bis > 0) stellen.add(bis); for (const s of stellen) { const pa = punktBeiAbstand(a, s); const pb = punktBeiAbstand(b, s); if (pa === null || pb === null) return false; if (Math.hypot(pa.x - pb.x, pa.y - pb.y) > grenze) return false; } return true; } /** Punkt auf der Linie im gegebenen Abstand von der Haltlinie. */ export function punktBeiAbstand(linie: Fahrlinie, s: Meters): Punkt | null { if (linie.punkte.length < 2 || !isFiniteNumber(s) || s < 0) return null; let rest = s; for (let i = 1; i < linie.punkte.length; i += 1) { const p = linie.punkte[i - 1]!; const q = linie.punkte[i]!; const l = abstand(p, q); if (l <= 0) continue; if (rest <= l) { const t = rest / l; return { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }; } rest -= l; } return linie.punkte[linie.punkte.length - 1] ?? null; } interface SegmentTreffer { readonly punkt: Punkt; /** Lage auf Segment A, 0..1. */ readonly tA: number; readonly tB: number; readonly winkel: number; } function segmentSchnitt(a1: Punkt, a2: Punkt, b1: Punkt, b2: Punkt): SegmentTreffer | null { const rx = a2.x - a1.x; const ry = a2.y - a1.y; const sx = b2.x - b1.x; const sy = b2.y - b1.y; const nenner = rx * sy - ry * sx; // Parallel oder entartet: kein Kreuzungspunkt. if (Math.abs(nenner) < 1e-12) return null; const tA = ((b1.x - a1.x) * sy - (b1.y - a1.y) * sx) / nenner; const tB = ((b1.x - a1.x) * ry - (b1.y - a1.y) * rx) / nenner; const toleranz = 1e-9; if (tA < -toleranz || tA > 1 + toleranz || tB < -toleranz || tB > 1 + toleranz) return null; const laengeR = Math.hypot(rx, ry); const laengeS = Math.hypot(sx, sy); if (laengeR < 1e-12 || laengeS < 1e-12) return null; // Schnittwinkel, auf 0..pi/2 gefaltet: fuer die Ausdehnung des // Konfliktbereichs ist nur der spitze Winkel massgebend. const cos = Math.abs((rx * sx + ry * sy) / (laengeR * laengeS)); const winkel = Math.acos(Math.min(1, cos)); return { punkt: { x: a1.x + tA * rx, y: a1.y + tA * ry }, tA, tB, winkel: winkel === 0 ? 0 : Math.min(winkel, Math.PI - winkel), }; } /** * Ausdehnung des Konfliktbereichs entlang beider Fahrlinien. * * Bei mehreren Schnittpunkten - etwa wenn ein Abbieger eine Furt zweimal quert - * wird die Huellkurve gebildet: vom ersten Eintritt bis zum letzten Austritt. * Das ist die sichere Auslegung, weil der Strom den Bereich erst danach * endgueltig verlassen hat. * * In die Huellkurve geht auch eine Ueberlagerung ein, und zwar unabhaengig * davon, ob es daneben Punktschnitte gibt. Kreuzen und Laengslauf schliessen * einander nicht aus: Ein Abbieger, der eine Furt quert und danach neben ihr * herlaeuft, verlaesst deren Korridor erst am Ende des gemeinsamen Stuecks. */ export function konfliktbereich(a: Fahrlinie, b: Fahrlinie): Konfliktbereich | null { const schnitte = schneide(a, b); // Kein Punktschnitt heisst nicht "vertraeglich": Zwei Stroeme koennen auch // nebeneinander herlaufen und sich denselben Fahrstreifen teilen. Solche // Ueberlagerungen fand die Vermassung frueher gar nicht - der Fall, in dem // sie in die UNSICHERE Richtung falsch lag. const ueber = ueberlappung(a, b); if (schnitte.length === 0) { if (ueber === null) return null; return { vonA: roundTo(Math.max(0, ueber.vonA), 3), bisA: roundTo(ueber.bisA, 3), vonB: roundTo(Math.max(0, ueber.vonB), 3), bisB: roundTo(ueber.bisB, 3), schnitte: 0, // Gleichlaufende Stroeme haben keinen Schnittwinkel. Null bedeutet hier // "laengs", nicht "unbekannt". winkel: 0, ausUeberlappung: true, }; } let vonA = Number.POSITIVE_INFINITY; let bisA = Number.NEGATIVE_INFINITY; let vonB = Number.POSITIVE_INFINITY; let bisB = Number.NEGATIVE_INFINITY; // Der Winkel des Schnitts, der das Ende entlang A bestimmt: Er gehoert zum // Raeumweg und ist daher der auszuweisende. let winkel = schnitte[0]!.winkel; for (const schnitt of schnitte) { // Bei nahezu parallelem Schnitt waechst die Ausdehnung ins Unendliche. // Zehn Grad sind die untere Schranke; darunter ist die Annahme // "kreuzende Stroeme" ohnehin nicht mehr sinnvoll. const wirksam = Math.max(Math.sin(schnitt.winkel), SIN_MIN); const halbeA = a.breite / 2 / wirksam; const halbeB = b.breite / 2 / wirksam; // A durchfaehrt den Korridor von B - also zaehlt die Breite von B. vonA = Math.min(vonA, schnitt.abstandA - halbeB); if (schnitt.abstandA + halbeB > bisA) { bisA = schnitt.abstandA + halbeB; winkel = schnitt.winkel; } vonB = Math.min(vonB, schnitt.abstandB - halbeA); bisB = Math.max(bisB, schnitt.abstandB + halbeA); } // Laeuft neben den Punktschnitten auch ein gemeinsames Stueck, gehoert es zum // selben Konfliktbereich. Sonst endete der Raeumweg kurz hinter dem // Kreuzungspunkt, obwohl der raeumende Strom den fremden Korridor erst am // Ende der Ueberlagerung verlaesst - eine zu kurze Zwischenzeit. if (ueber !== null) { vonA = Math.min(vonA, ueber.vonA); if (ueber.bisA > bisA) { bisA = ueber.bisA; // Bestimmt das gemeinsame Stueck das Ende entlang A, gehoert zum // Raeumweg kein Schnittwinkel. Null bedeutet hier "laengs". winkel = 0; } vonB = Math.min(vonB, ueber.vonB); bisB = Math.max(bisB, ueber.bisB); } return { vonA: roundTo(Math.max(0, vonA), 3), bisA: roundTo(bisA, 3), vonB: roundTo(Math.max(0, vonB), 3), bisB: roundTo(bisB, 3), schnitte: schnitte.length, winkel, ausUeberlappung: ueber !== null, }; } /** Abschnitt, auf dem zwei Fahrlinien nebeneinander oder deckungsgleich laufen. */ export interface Ueberlappung { readonly vonA: Meters; readonly bisA: Meters; readonly vonB: Meters; readonly bisB: Meters; /** Laenge des gemeinsamen Stuecks entlang A. */ readonly laenge: Meters; } /** Ein Stueck, auf dem ein einzelnes Segmentpaar seine Korridore teilt. */ interface Teilstueck { vonA: number; bisA: number; vonB: number; bisB: number; } /** * Laufen zwei Fahrlinien auf einem Stueck ineinander, statt sich zu kreuzen? * * `segmentSchnitt` verwirft gleichlaufende Segmente - der Nenner wird null - * und liefert dort nichts. Ohne diese Pruefung gelten zwei Stroeme, die sich * einen Fahrstreifen teilen oder deren Furt entlang einer Fahrlinie liegt, als * vertraeglich. Das ist die unsichere Richtung. * * Massgebend ist nicht die Deckungsgleichheit der Mittellinien, sondern der * Abstand gemessen an den Korridorbreiten: Zwei Fahrzeuge kommen sich ins * Gehege, sobald ihre Korridore ueberlappen. Gerechnet wird durch Projektion, * ohne Abtastung - das Ergebnis haengt an keiner Schrittweite. * * Es haengt auch an keiner Stuetzpunktdichte: Die Teilstuecke aller * Segmentpaare werden gesammelt und zu zusammenhaengenden gemeinsamen Stuecken * vereinigt; erst darauf greift die Rauschschwelle. */ export function ueberlappung(a: Fahrlinie, b: Fahrlinie): Ueberlappung | null { if (!istGueltig(a) || !istGueltig(b)) return null; const grenzAbstand = (a.breite + b.breite) / 2; const teilstuecke: Teilstueck[] = []; let laengeA = 0; for (let i = 1; i < a.punkte.length; i += 1) { const a1 = a.punkte[i - 1]!; const a2 = a.punkte[i]!; const la = abstand(a1, a2); if (la < 1e-9) continue; const ux = (a2.x - a1.x) / la; const uy = (a2.y - a1.y) / la; let laengeB = 0; for (let j = 1; j < b.punkte.length; j += 1) { const b1 = b.punkte[j - 1]!; const b2 = b.punkte[j]!; const lb = abstand(b1, b2); if (lb < 1e-9) continue; const vx = (b2.x - b1.x) / lb; const vy = (b2.y - b1.y) / lb; // Nur gleichlaufende Segmente - auch gegenlaeufige, denn eine Furt entlang // einer Fahrlinie kann in beide Richtungen gezeichnet sein. if (Math.abs(ux * vx + uy * vy) < COS_PARALLEL) { laengeB += lb; continue; } // Laengs- und Querlage der beiden Endpunkte von B im System von A. const l1 = (b1.x - a1.x) * ux + (b1.y - a1.y) * uy; const q1 = -(b1.x - a1.x) * uy + (b1.y - a1.y) * ux; const l2 = (b2.x - a1.x) * ux + (b2.y - a1.y) * uy; const q2 = -(b2.x - a1.x) * uy + (b2.y - a1.y) * ux; // Teilstueck von B, auf dem der Querabstand die Grenze einhaelt. Weil das // Segment gerade ist, ist der Querabstand linear - der Bereich laesst sich // ausrechnen statt abtasten. const nah = naheSpanne(q1, q2, grenzAbstand); if (nah === null) { laengeB += lb; continue; } // Dieses Teilstueck auf die Laengsachse von A abbilden und dort auf das // Segment von A beschneiden. const lNah1 = l1 + nah.von * (l2 - l1); const lNah2 = l1 + nah.bis * (l2 - l1); const von = Math.max(0, Math.min(lNah1, lNah2)); const bis = Math.min(la, Math.max(lNah1, lNah2)); // Nichts uebrig, nachdem auf das Segment von A beschnitten wurde. Die // Rauschschwelle greift hier nicht: Sie gilt dem gemeinsamen Stueck, das // sich erst aus allen Teilstuecken zusammensetzt. if (bis <= von) { laengeB += lb; continue; } // Der beschnittene Bereich, zurueckgerechnet auf die Bogenlaenge von B. const spanneL = lNah2 - lNah1; const tVon = Math.abs(spanneL) < 1e-9 ? nah.von : nah.von + ((von - lNah1) / spanneL) * (nah.bis - nah.von); const tBis = Math.abs(spanneL) < 1e-9 ? nah.bis : nah.von + ((bis - lNah1) / spanneL) * (nah.bis - nah.von); teilstuecke.push({ vonA: laengeA + von, bisA: laengeA + bis, vonB: laengeB + Math.min(tVon, tBis) * lb, bisB: laengeB + Math.max(tVon, tBis) * lb, }); laengeB += lb; } laengeA += la; } const gemeinsam = vereinigeTeilstuecke(teilstuecke); if (gemeinsam.length === 0) return null; let vonA = Number.POSITIVE_INFINITY; let bisA = Number.NEGATIVE_INFINITY; let vonB = Number.POSITIVE_INFINITY; let bisB = Number.NEGATIVE_INFINITY; for (const stueck of gemeinsam) { vonA = Math.min(vonA, stueck.vonA); bisA = Math.max(bisA, stueck.bisA); vonB = Math.min(vonB, stueck.vonB); bisB = Math.max(bisB, stueck.bisB); } return { vonA: roundTo(Math.max(0, vonA), 3), bisA: roundTo(bisA, 3), vonB: roundTo(Math.max(0, vonB), 3), bisB: roundTo(bisB, 3), laenge: roundTo(bisA - vonA, 3), }; } /** * Fasst die Teilstuecke der einzelnen Segmentpaare zu zusammenhaengenden * gemeinsamen Stuecken zusammen und verwirft, was unter der Rauschschwelle * bleibt. * * Die Schranke gehoert an das gemeinsame Stueck, nicht an das einzelne * Segmentpaar. Wer eine Fahrlinie mit dichten Stuetzpunkten nachfaehrt - in der * Arbeitsstufe "normal" sind das rund 0,6 m je Anschlag -, zerlegt dieselbe * Ueberlagerung in lauter kurze Teilstuecke. Geprueft am einzelnen Paar fiele * jedes davon durch, und zwei Stroeme mit ueber zig Meter deckungsgleichen * Korridoren galten als vertraeglich: kein Konflikt, keine Zwischenzeit. */ function vereinigeTeilstuecke(teile: readonly Teilstueck[]): Teilstueck[] { const zusammen: Teilstueck[] = []; for (const teil of [...teile].sort((x, y) => x.vonA - y.vonA)) { const letztes = zusammen[zusammen.length - 1]; // Aufeinanderfolgende Segmente stossen im Stuetzpunkt aneinander; die // Toleranz faengt allein den Rundungsfehler dieser Naht ab. if (letztes !== undefined && teil.vonA <= letztes.bisA + 1e-6) { letztes.bisA = Math.max(letztes.bisA, teil.bisA); letztes.vonB = Math.min(letztes.vonB, teil.vonB); letztes.bisB = Math.max(letztes.bisB, teil.bisB); } else { zusammen.push({ ...teil }); } } return zusammen.filter((stueck) => stueck.bisA - stueck.vonA >= MIN_UEBERLAPPUNG); } /** * Der Teil eines Segments, auf dem der Querabstand die Grenze einhaelt. * `q1` und `q2` sind die vorzeichenbehafteten Querabstaende der Endpunkte; der * Abstand verlaeuft dazwischen linear. */ function naheSpanne(q1: number, q2: number, grenze: Meters): { von: number; bis: number } | null { const nah1 = Math.abs(q1) <= grenze; const nah2 = Math.abs(q2) <= grenze; if (nah1 && nah2) return { von: 0, bis: 1 }; const differenz = q2 - q1; if (Math.abs(differenz) < 1e-12) return null; // Schnittstellen mit den beiden Grenzgeraden +grenze und -grenze. const stellen = [(grenze - q1) / differenz, (-grenze - q1) / differenz] .filter((t) => t >= 0 && t <= 1) .sort((x, y) => x - y); if (nah1) return { von: 0, bis: stellen[0] ?? 0 }; if (nah2) return { von: stellen[stellen.length - 1] ?? 1, bis: 1 }; // Beide Endpunkte ausserhalb: Das Segment kann die Roehre nur durchqueren, // wenn es beide Grenzgeraden schneidet. if (stellen.length < 2) return null; return { von: stellen[0]!, bis: stellen[stellen.length - 1]! }; } /** * Ist diese Linie eine Querung, deren Geh- oder Fahrrichtung die Zeichnung * nicht festlegt - und wenn ja, welche? * * WARUM ES DIESE UNTERSCHEIDUNG GIBT (Fassung 5.43.0): Eine Furt oder * Radquerung wird als EINE Linie gezeichnet, begangen wird sie aber von beiden * Bordkanten aus. Gemessen wurde bis dahin allein ab ihrem ersten Punkt, und * damit hingen Raeum- und Einfahrweg an der Bordkante, an der der Bearbeiter zu * zeichnen begann: Je Furt fielen zwei von vier Beziehungen zu kurz aus, bis * -9 s. Eine Gegenlinie in derselben Signalgruppe lehnt `verbindeMitGruppe` ab * (gleich schaltende Geber stehen an einem Querschnitt), und eine eigene Gruppe * fuer die Gegenrichtung erzeugte einen Konflikt Furt gegen Furt. Die * Gegenrichtung muss deshalb HIER mitgemessen werden. * * 'fuss' jede Fussgaengerlinie - Fussgaenger queren immer. * 'rad' jede Radlinie, AUSSER sie beginnt an der Haltlinie einer ZUFAHRT * und faehrt dort nachweislich geradeaus, links oder rechts. Ein * solcher Radstrom faehrt in einer Richtung (RiLSA 2015, Fall 5 mit * Bild 14: Einrichtungsradweg) und bleibt gerichtet. Querung ist * also, was an einer Aufstelllinie der Art "querung" beginnt, was * keiner Haltlinie zugeordnet ist - und was die Fahrbeziehung * "querung" traegt, gleich an welcher Haltlinie. Unbekannt (keine * Art, keine Fahrbeziehung) heisst sicher. * null alles andere, auch Kraftfahrzeuge und OePNV - und eine Linie ohne * `bezug`: Ohne ihn ist nicht einmal die Verkehrsart bekannt, und * `fahrlinienVon` gibt ihn jeder Linie des Lageplans mit. * * WARUM DIE FAHRBEZIEHUNG MITENTSCHEIDET (Fassung 5.43.0): Bis * dahin sah diese Funktion allein die Haltlinienart. Eine Radlinie mit der * Fahrbeziehung "querung", deren erster Punkt auf einer Haltlinie der Art * "zufahrt" liegt, blieb gerichtet - und genau so entsteht sie leicht: Der * Haltliniendialog stellt "zufahrt" voreingestellt ein, und der erste Punkt * einer Linie rastet auf die naechste Haltlinie jeder Art ein. Die * Zeichenrichtung entschied dort weiter ueber bis zu 3 s. */ export function querungsart(linie: Fahrlinie): 'fuss' | 'rad' | null { const bezug = linie.bezug; if (bezug === undefined) return null; if (bezug.mode === 'fuss') return 'fuss'; if (bezug.mode !== 'rad') return null; const gerichtet = bezug.haltlinienArt === 'zufahrt' && bezug.movement !== undefined && bezug.movement !== 'querung'; return gerichtet ? null : 'rad'; } /** * Dieselbe Linie, von ihrem anderen Ende aus. * * Ohne `bezug`: Die Haltlinie gehoert zum ersten Punkt der gezeichneten Linie, * nicht zu dem der umgekehrten. Wirkung hat das keine - `bezug` braucht nur * die Auffaecherung, und die gilt allein Kraftfahrzeugen an einer Zufahrt, nie * einer Querung. */ function umgekehrt(linie: Fahrlinie): Fahrlinie { return { id: linie.id, punkte: [...linie.punkte].reverse(), breite: linie.breite }; } /** * Misst Raeum- und Einfahrweg fuer die gerichtete Beziehung * "`raeumend` raeumt, `einfahrend` faehrt ein". * * Gibt `null` zurueck, wenn sich die Fahrlinien nicht kreuzen - dann sind die * Stroeme vertraeglich und es ist keine Zwischenzeit zu bilden. * * QUERUNGEN IN DER UNGUENSTIGEREN RICHTUNG (siehe `querungsart`). Fuer einen * querenden Strom gilt nicht die Zeichenrichtung, sondern je Beziehung die * Richtung mit der LAENGEREN Zwischenzeit: der groessere Raeumweg und der * kleinere Einfahrweg beider Richtungen. Beide Richtungen werden als Linie * gemessen, nicht als "Laenge minus Abstand" gerechnet - so gilt fuer Linien * mit Stuetzpunkten, fuer mehrfach gekreuzte Querungen und fuer einen * Korridor, der ueber ein Linienende hinausreicht, dieselbe Rechnung wie fuer * die gezeichnete Richtung, und das Ergebnis ist fuer eine Linie und ihre * Umkehrung dasselbe. DAS IST EINE AUSLEGUNG, kein Satz der RiLSA 2015: Sie * verlangt die Rechnung fuer alle nichtvertraeglichen Kombinationen mit der * groessten Zwischenzeit als massgebender (Abschnitt 2.5), misst den Weg des * Fussgaengers vom Beginn der Furt (2.5.1), zeigt in Bild 15 (Fall 6) den * Fussgaengerstrom mit Doppelpfeil und in Bild 5 und 6 ein Weg-Zeit-Bild je * Gehrichtung. Welche Bordkante "Beginn" ist, sagt sie nicht. * * FUER DEN FUSSGAENGER ZUSAETZLICH, beides nie kuerzer als die RiLSA: * * Einfahrweg 0 m. Beginnt die Konfliktflaeche am Fahrbahnrand, ist te = 0 s; * mit 1,5 m/s wird nur gerechnet, wenn der raeumende Verkehr den * Randfahrstreifen nicht benutzen darf (Abschnitt 2.5.3). Ob er es darf, * zeigt keine Zeichnung. Gemessen ergaebe sich selbst am Randfahrstreifen * ein Rest - der Korridor von 3,25 m endet vor der Bordkante eines 3,5 m * breiten Streifens 0,125 m, das sind 0,08 s, und die kosten an einer * Rundungsgrenze eine ganze Sekunde. VOR EINEM STROM IM EIGENEN * FAHRSTREIFEN EINER ZUFAHRT - etwa einem Linksabbieger, der den * Randfahrstreifen nicht benutzen darf - ist der Einfahrweg 0 m eine * Verschaerfung dieses Programms und keine Umsetzung der RiLSA; an der * mitgelieferten Beispieldatei ergibt das viermal 5 s statt 3 s. * * Raeumweg mindestens die ganze gezeichnete Furt. Aus demselben Grund in der * Gegenrichtung: Ob der einfahrende Verkehr den Fahrstreifen am fernen Rand * benutzt, zeigt die Zeichnung ebenso wenig - auf einer Ausfahrt darf er * jeden. Bild 15 fuehrt s0 ueber die ganze Fahrbahnbreite, und selbst am * Randfahrstreifen kostete der gemessene Rest 0,10 s. * * Wer den Sonderfall der RiLSA belegen kann - gesperrter Randfahrstreifen -, * traegt den Weg an der Beziehung von Hand ein; der Pruefbericht fragt dann * nach der Begruendung (`zwischenzeiten.kuerzer-als-lageplan`). * * FUER DIE RADQUERUNG nur den Rest am Rand: Liegt zwischen Linienende und * Konfliktbereich kein ganzer Korridor des anderen Stroms, beginnt bzw. endet * der Konfliktbereich am Rand - kein zweiter Fahrstreifen passt dazwischen, * und der Rest ist das Mass, um das der Korridor schmaler gezeichnet ist als * der Fahrstreifen. Eine Einfahrzeit von 0 s nennt die RiLSA fuer den * Radverkehr nicht; gemessen wird deshalb weiter, wo ein Fahrstreifen * dazwischen liegt. * * `konfliktbereich` bleibt der Bereich entlang der GEZEICHNETEN Linien. */ export function vermesseWege(raeumend: Fahrlinie, einfahrend: Fahrlinie): VermesseneWege | null { if (!istGueltig(raeumend) || !istGueltig(einfahrend)) return null; // Der Konfliktbereich fuehrt den massgebenden Winkel selbst mit; die // Schnittsuche wurde hier zuvor ein zweites Mal gerechnet, und der genommene // Winkel gehoerte zum ersten statt zum massgebenden Schnitt. const bereich = konfliktbereich(raeumend, einfahrend); if (bereich === null) return null; // Der raeumende Strom muss bis zum ENDE des Bereichs. let raeumweg = bereich.bisA; let winkel = bereich.winkel; const raeumendQuert = querungsart(raeumend); if (raeumendQuert !== null) { const gegen = konfliktbereich(umgekehrt(raeumend), einfahrend); if (gegen !== null && gegen.bisA > raeumweg) { raeumweg = gegen.bisA; winkel = gegen.winkel; } const ganzeLinie = laenge(raeumend); if (raeumendQuert === 'fuss' || ganzeLinie - raeumweg < einfahrend.breite) { raeumweg = Math.max(raeumweg, ganzeLinie); } } // Der einfahrende Strom erreicht den Bereich an seiner VORDEREN Kante. let einfahrweg = bereich.vonB; const einfahrendQuert = querungsart(einfahrend); if (einfahrendQuert === 'fuss') { einfahrweg = 0; } else if (einfahrendQuert === 'rad') { const gegen = konfliktbereich(raeumend, umgekehrt(einfahrend)); if (gegen !== null) einfahrweg = Math.min(einfahrweg, gegen.vonB); if (einfahrweg < raeumend.breite) einfahrweg = 0; } return { raeumweg: roundTo(raeumweg, 2), einfahrweg: roundTo(einfahrweg, 2), winkelGrad: roundTo((winkel * 180) / Math.PI, 1), konfliktbereich: bereich, }; } /** * Kreuzen sich zwei Fahrlinien in einem Punkt? * * Beantwortet ausschliesslich die Punktschnittfrage. Ob zwei Stroeme feindlich * sind, entscheidet `beurteile` in ableitung.ts - dort kommt die Ueberlagerung * hinzu, die hier nicht erfasst wird. */ export function kreuzenSich(a: Fahrlinie, b: Fahrlinie): boolean { return istGueltig(a) && istGueltig(b) && schneideMitBefund(a, b).schnitte.length > 0; } function istGueltig(linie: Fahrlinie): boolean { if (linie.punkte.length < 2) return false; if (!isFiniteNumber(linie.breite) || linie.breite <= 0) return false; return linie.punkte.every((p) => isFiniteNumber(p.x) && isFiniteNumber(p.y)); } // --- Massstab --------------------------------------------------------------- /** * Umrechnung Bildpunkte in Meter. * * Ein eingelesener Lageplan hat keinen bekannten Massstab. Der Anwender zieht * deshalb eine Strecke bekannter Laenge - eine Fahrbahnbreite, ein Gebaeude, * ein Massstabsbalken - und gibt die zugehoerige Laenge in Metern an. */ export interface Massstab { /** Meter je Bildpunkt. */ readonly meterJeBildpunkt: number; } export function massstabAusStrecke( von: Punkt, bis: Punkt, laengeInMetern: Meters, ): Massstab | null { const bildpunkte = abstand(von, bis); if (bildpunkte < 1e-6) return null; if (!isFiniteNumber(laengeInMetern) || laengeInMetern <= 0) return null; return { meterJeBildpunkt: laengeInMetern / bildpunkte }; } /** Rechnet einen in Bildpunkten gezeichneten Punkt in Meter um. */ export function inMeter(punkt: Punkt, massstab: Massstab): Punkt { return { x: punkt.x * massstab.meterJeBildpunkt, y: punkt.y * massstab.meterJeBildpunkt }; } /** Rechnet eine ganze Fahrlinie in Meter um. */ export function linieInMeter(linie: Fahrlinie, massstab: Massstab): Fahrlinie { return { ...linie, punkte: linie.punkte.map((p) => inMeter(p, massstab)) }; } /** * Ein Massstabsbalken: eine runde Meterzahl und ihre Laenge in Zeichenpunkten. * * WARUM DAS IN DER FACHSCHICHT STEHT: Der Balken wird jetzt an zwei Stellen * gebraucht - auf dem Bildschirm in der Lageplanansicht und auf dem gedruckten * Blatt. Zwei Fassungen derselben Stufenwahl liefen auseinander, und ein * Balken, der auf Papier eine andere Zahl nennt als am Bildschirm, ist * schlimmer als keiner. * * DIE STUFEN SIND RUND, WEIL EIN BALKEN ABGELESEN WIRD: "50 m" laesst sich * halbieren und verdoppeln, "43,7 m" nicht. */ export interface Massstabsbalken { /** Beschriftung des Balkens in Metern. */ readonly meter: Meters; /** Laenge des Balkens in Zeichenpunkten. */ readonly laenge: number; } /** Runde Stufen, unter denen der Balken seine Beschriftung waehlt. */ const BALKENSTUFEN: readonly number[] = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]; export function massstabsbalken(meterJeZeichenpunkt: number, zielLaenge: number): Massstabsbalken { if (!isFiniteNumber(meterJeZeichenpunkt) || meterJeZeichenpunkt <= 0) { return { meter: 0, laenge: 0 }; } const roh = zielLaenge * meterJeZeichenpunkt; const meter = BALKENSTUFEN.find((stufe) => stufe >= roh) ?? BALKENSTUFEN[BALKENSTUFEN.length - 1]!; return { meter, laenge: meter / meterJeZeichenpunkt }; }