Spaces:
Running
Running
File size: 12,842 Bytes
9de795a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | /** Reiner Geometrie-Kern der 2D-Grundriss-Ansicht (testbar, ohne DOM).
*
* Welt → SVG: Grundriss in der x/z-Ebene (y-up, Meter). x → Bildschirm-X
* (rechts), z → Bildschirm-Y (unten) – ein gewöhnlicher Aufriss von oben,
* spiegelfrei. Footprints kommen aus `footprint()` von @fp/shared/rules, also
* exakt der Solver-/Interpreter-Konvention (keine eigene Rotationsmathematik).
*/
import { footprint, frontDir, pointInPolygon, type Vec2 } from "@fp/shared/rules";
export interface PlanTransform {
scale: number;
offsetX: number;
offsetY: number;
}
/** Passt die Bounding-Box des Polygons mittig in eine quadratische SVG der
* Kantenlänge `size` ein (Rand `pad`). Gleicher Massstab in x und z. */
export function computeTransform(polygon: Vec2[], size: number, pad: number): PlanTransform {
const xs = polygon.map((p) => p[0]);
const zs = polygon.map((p) => p[1]);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minZ = Math.min(...zs);
const maxZ = Math.max(...zs);
const breite = Math.max(maxX - minX, 1e-6);
const tiefe = Math.max(maxZ - minZ, 1e-6);
const innen = size - 2 * pad;
const scale = innen / Math.max(breite, tiefe);
// zentrieren: ungenutzte Achse bekommt halben Rest als zusätzlichen Offset.
const offsetX = pad + (innen - breite * scale) / 2 - minX * scale;
const offsetY = pad + (innen - tiefe * scale) / 2 - minZ * scale;
return { scale, offsetX, offsetY };
}
/** Welt-Punkt (x,z) → SVG-Punkt (x rechts, z runter). */
export function toScreen(p: Vec2, t: PlanTransform): Vec2 {
return [p[0] * t.scale + t.offsetX, p[1] * t.scale + t.offsetY];
}
/** SVG-Punkt → Welt (x,z); Umkehrung von toScreen (für Drag&Drop). */
export function toWorld(p: Vec2, t: PlanTransform): Vec2 {
return [(p[0] - t.offsetX) / t.scale, (p[1] - t.offsetY) / t.scale];
}
/** SVG-`points`-String des (rotierten) Footprints eines Objekts. */
export function footprintPoints(
center: Vec2,
w: number,
d: number,
yawDeg: number,
t: PlanTransform,
): string {
return footprint(center, w, d, yawDeg)
.map((ecke) => {
const [sx, sy] = toScreen(ecke, t);
return `${round(sx)},${round(sy)}`;
})
.join(" ");
}
/** Einheits-Normale einer Wand, die ins Rauminnere zeigt (für Türschwenk). */
export function innwardNormal(start: Vec2, end: Vec2, floor: Vec2[]): Vec2 {
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const len = Math.hypot(dx, dz) || 1;
const ux = dx / len;
const uz = dz / len;
const mid: Vec2 = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
for (const sgn of [1, -1]) {
const n: Vec2 = [-uz * sgn, ux * sgn];
if (pointInPolygon([mid[0] + n[0] * 0.1, mid[1] + n[1] * 0.1], floor)) return entnull(n);
}
return entnull([-uz, ux]);
}
/** −0 → +0 (sonst stolpern Vergleiche; auf das Rendern hat es keinen Einfluss). */
function entnull(v: Vec2): Vec2 {
return [v[0] === 0 ? 0 : v[0], v[1] === 0 ? 0 : v[1]];
}
/** Wand als gefülltes Rechteck (Polygon in Welt) – Dicke mittig auf der
* Wandachse verteilt (je halbe Dicke nach beiden Seiten). */
export function wallQuad(start: Vec2, end: Vec2, thickness: number): Vec2[] {
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const len = Math.hypot(dx, dz) || 1;
const nx = -dz / len; // Wand-Normale (Einheit)
const nz = dx / len;
const h = thickness / 2;
return [
[start[0] + nx * h, start[1] + nz * h],
[end[0] + nx * h, end[1] + nz * h],
[end[0] - nx * h, end[1] - nz * h],
[start[0] - nx * h, start[1] - nz * h],
];
}
/** Schnittpunkt zweier Geraden (Punkt + Richtung). `null` bei (nahezu) parallel. */
function schnittGeraden(p1: Vec2, d1: Vec2, p2: Vec2, d2: Vec2): Vec2 | null {
const denom = d1[0] * d2[1] - d1[1] * d2[0];
if (Math.abs(denom) < 1e-9) return null;
const wx = p2[0] - p1[0];
const wz = p2[1] - p1[1];
const s = (wx * d2[1] - wz * d2[0]) / denom;
return [p1[0] + d1[0] * s, p1[1] + d1[1] * s];
}
/**
* Nach aussen versetztes Polygon (CAD-Wand-Aussenkante): jeder Eckpunkt wird
* entlang der **Winkelhalbierenden** um `thickness` aus dem Raum heraus versetzt
* (Gehrung/Miter über den Schnitt der beiden versetzten Kanten-Geraden).
*
* ⚠️ Das Raumpolygon (`floor.polygon`) ist semantisch die **INNENKANTE** des
* Raums – die nutzbare Fläche, bis an die der Solver Objekte platziert. Die
* Wand liegt deshalb **ausserhalb** dieser Linie: Innenkante der Wand = Polygon-
* Linie, die Wandstärke wächst nach aussen. So decken sich die dargestellte
* Raumfläche und die Solver-Realität (Bryan 2026-07-13).
*
* Ergebnis-Index i gehört zu `polygon[i]`, sodass benachbarte Wände sich
* denselben Aussen-Eckpunkt teilen (keine Lücke/Überlappung). Kollineare Ecken
* (180°, kein echter Knick) fallen auf den reinen Normalenversatz zurück.
*/
export function aussenPolygon(polygon: Vec2[], thickness: number): Vec2[] {
const n = polygon.length;
return polygon.map((curr, i) => {
const prev = polygon[(i - 1 + n) % n] as Vec2;
const next = polygon[(i + 1) % n] as Vec2;
// Aussen-Normale = negierte Innen-Normale (aus dem Raum heraus).
const nPrevIn = innwardNormal(prev, curr, polygon);
const nNextIn = innwardNormal(curr, next, polygon);
const nPrev: Vec2 = [-nPrevIn[0], -nPrevIn[1]];
const nNext: Vec2 = [-nNextIn[0], -nNextIn[1]];
const p1: Vec2 = [curr[0] + nPrev[0] * thickness, curr[1] + nPrev[1] * thickness];
const d1: Vec2 = [curr[0] - prev[0], curr[1] - prev[1]];
const p2: Vec2 = [curr[0] + nNext[0] * thickness, curr[1] + nNext[1] * thickness];
const d2: Vec2 = [next[0] - curr[0], next[1] - curr[1]];
return schnittGeraden(p1, d1, p2, d2) ?? p1;
});
}
/**
* Wand-«Bänder» einer Wand als gefüllte Polygone (Welt) im CAD-Look: die
* **Innenkante** liegt exakt auf der Wandachse (= Raumpolygon-Linie, nutzbare
* Raumfläche), die Stärke wächst per `nAussen` nach **aussen**. Pro Öffnung wird
* das Band ausgespart (Segmentierung via `wandLuecken`) – ein Band je
* Voll-Segment.
*
* An den Wand-Enden (Raumecken) werden – falls übergeben – die gehrungs-
* versetzten Aussenpunkte `aussenStart`/`aussenEnd` aus {@link aussenPolygon}
* verwendet, damit die Ecken sauber schliessen. Für Öffnungs-Laibungen mitten
* in der Wand (kein Eck) genügt der senkrechte Normalenversatz – er liegt auf
* derselben Aussenkanten-Geraden wie die Gehrungsecke.
*
* @param start Wand-Startpunkt (Welt, auf der Innenkante = Polygon-Linie)
* @param end Wand-Endpunkt (Welt, auf der Innenkante = Polygon-Linie)
* @param nAussen Einheits-Normale aus dem Raum heraus (= −innwardNormal)
* @param thickness Wandstärke (m)
* @param oeffnungen Öffnungen dieser Wand (offset/width entlang der Achse)
* @param aussenStart optionaler Gehrungs-Aussenpunkt am Start (sonst Normalversatz)
* @param aussenEnd optionaler Gehrungs-Aussenpunkt am Ende (sonst Normalversatz)
* @returns je Voll-Segment ein Polygon [innenA, innenB, aussenB, aussenA]
*/
export function wandBaender(
start: Vec2,
end: Vec2,
nAussen: Vec2,
thickness: number,
oeffnungen: { offset: number; width: number }[],
aussenStart?: Vec2,
aussenEnd?: Vec2,
): Vec2[][] {
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const len = Math.hypot(dx, dz) || 1;
const u: Vec2 = [dx / len, dz / len];
const EPS = 1e-6;
const aussenVersatz = (p: Vec2): Vec2 => [
p[0] + nAussen[0] * thickness,
p[1] + nAussen[1] * thickness,
];
const { segmente } = wandLuecken(len, oeffnungen);
return segmente.map(({ a, b }) => {
const innenA: Vec2 = [start[0] + u[0] * a, start[1] + u[1] * a];
const innenB: Vec2 = [start[0] + u[0] * b, start[1] + u[1] * b];
const aussenA: Vec2 = a <= EPS ? (aussenStart ?? aussenVersatz(innenA)) : aussenVersatz(innenA);
const aussenB: Vec2 =
b >= len - EPS ? (aussenEnd ?? aussenVersatz(innenB)) : aussenVersatz(innenB);
return [innenA, innenB, aussenB, aussenA];
});
}
/** Euklidische Distanz (Meter) zwischen zwei Weltpunkten – für das Messwerkzeug. */
export function distanz(a: Vec2, b: Vec2): number {
return Math.hypot(b[0] - a[0], b[1] - a[1]);
}
/**
* Bewegungsflächen-Rechteck (Clearance) vor einem Objekt, in Weltkoordinaten.
*
* ⚠️ Deckungsgleich mit `frontZone()` im Regel-Interpreter (@fp/shared/rules) –
* dieselbe Zone, die die Ampel für `type:"clearance"`-Regeln prüft. Die Front
* ist lokal +z (= `frontDir(yaw)`), die Zone liegt bündig an der Objekt-Front,
* `breite` breit (um die Front zentriert) und `tiefe` tief nach vorn. Wird die
* Interpreter-Zone geändert, muss diese Funktion mitgezogen werden.
*
* @param center Objektzentrum (Welt, x/z)
* @param d Objekttiefe (m) – die Front sitzt bei center + frontDir·d/2
* @param yawDeg Objekt-Yaw (Grad)
* @param tiefe Zonentiefe nach vorn (m, = clearance-param `depth`)
* @param breite Zonenbreite (m, = param `width`; fehlt er, Objektbreite `w`)
* @returns 4 Welt-Eckpunkte (c1,c2,c3,c4) gegen den Uhrzeigersinn wie footprint
*/
export function clearanceRect(
center: Vec2,
d: number,
yawDeg: number,
tiefe: number,
breite: number,
): [Vec2, Vec2, Vec2, Vec2] {
const f = frontDir(yawDeg);
// Rechtsvektor = Front um −90° gedreht: (f.z, −f.x) – exakt wie im Interpreter.
const rx = f[1];
const rz = -f[0];
const frontCenter: Vec2 = [center[0] + (f[0] * d) / 2, center[1] + (f[1] * d) / 2];
const hw = breite / 2;
const c1: Vec2 = [frontCenter[0] - rx * hw, frontCenter[1] - rz * hw];
const c2: Vec2 = [frontCenter[0] + rx * hw, frontCenter[1] + rz * hw];
const c3: Vec2 = [c2[0] + f[0] * tiefe, c2[1] + f[1] * tiefe];
const c4: Vec2 = [c1[0] + f[0] * tiefe, c1[1] + f[1] * tiefe];
return [c1, c2, c3, c4];
}
/** Öffnungs-Intervall entlang der Wandachse (Meter ab Wand-Start). */
export interface Luecke {
a: number;
b: number;
}
/**
* Zerlegt eine Wand (Achslänge `laenge`) an ihren Öffnungen in Teilstücke.
*
* Für die 2D-Darstellung: die Wandfüllung wird NUR über den `segmente`-Intervallen
* gezeichnet, in den `luecken` bleibt sie ausgespart (echte Öffnung in der
* Wandstärke, keine Linie obendrauf). Robust bei mehreren/überlappenden
* Öffnungen: Intervalle werden auf [0, laenge] geclampt, überlappende gemerged,
* degenerierte (≤1 mm) verworfen. Analog zu `wandSegmente` in `raum3d.ts`, aber
* rein auf die Wandachse projiziert (2D).
*/
export function wandLuecken(
laenge: number,
oeffnungen: { offset: number; width: number }[],
): { segmente: Luecke[]; luecken: Luecke[] } {
const EPS = 1e-3;
const clamp = (v: number): number => Math.min(laenge, Math.max(0, v));
const roh = oeffnungen
.map((o) => ({ a: clamp(o.offset), b: clamp(o.offset + o.width) }))
.filter((o) => o.b - o.a > EPS)
.sort((x, y) => x.a - y.a);
const luecken: Luecke[] = [];
for (const o of roh) {
const last = luecken[luecken.length - 1];
if (last && o.a <= last.b + 1e-9) last.b = Math.max(last.b, o.b);
else luecken.push({ ...o });
}
const segmente: Luecke[] = [];
let cursor = 0;
for (const o of luecken) {
if (o.a - cursor > EPS) segmente.push({ a: cursor, b: o.a });
cursor = o.b;
}
if (laenge - cursor > EPS) segmente.push({ a: cursor, b: laenge });
return { segmente, luecken };
}
/** Alle Wand-Endpunkte (Ecken) als Snap-Kandidaten fürs Messwerkzeug. */
export function wandEcken(walls: { start: Vec2; end: Vec2 }[]): Vec2[] {
const ecken: Vec2[] = [];
for (const w of walls) {
ecken.push([w.start[0], w.start[1]], [w.end[0], w.end[1]]);
}
return ecken;
}
/** Snappt einen Weltpunkt auf die nächste Ecke, falls sie näher als
* `schwelle` (Meter) liegt – sonst den Punkt unverändert. */
export function naechsteEcke(welt: Vec2, ecken: Vec2[], schwelle: number): Vec2 {
let beste: Vec2 = welt;
let min = schwelle;
for (const e of ecken) {
const dd = distanz(welt, e);
if (dd <= min) {
min = dd;
beste = e;
}
}
return beste;
}
/** Yaw (Grad, gerastet) aus der Zeiger-Richtung relativ zum Objektzentrum.
* Front = lokal +z ⇒ frontDir(yaw) = (sin,cos) ⇒ yaw = atan2(dx, dz). */
export function yawAusZeiger(center: Vec2, welt: Vec2, schrittGrad = 15): number {
const dx = welt[0] - center[0];
const dz = welt[1] - center[1];
if (dx === 0 && dz === 0) return 0;
const deg = (Math.atan2(dx, dz) * 180) / Math.PI;
const gerastet = Math.round(deg / schrittGrad) * schrittGrad;
return ((gerastet % 360) + 360) % 360;
}
/** Auf ein Raster (Meter) runden – für gerastetes Ziehen (Default 5 cm). */
export function rasten(v: number, schritt = 0.05): number {
return Math.round(v / schritt) * schritt;
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
|