ae18766b01
- roomArea: polygonArea/perimeter/centroid. - ceiling: normalizeOutline/isValidOutline/ceilingArea/outlineBBox/ outlineCentroid/pointInOutline (+ BBox-Struct, serde camelCase). - stair: defaultStepCount/stairGeometry (gerade/L/Wendel)/stairCut/stairBBox/ pointHitsStair (StairParams-Struct, strukturgleich; Nullguard ||1e-9 wie TS). - roomBoundary: detectRooms/roomFromPointInside(Faces)/pointInPolygon (planarer Graph, Half-Edge-Faces, Miter-Offset; WallSegment/WallFace). - Batch-Fassaden + Harness-Slices je Modul (Struktur exakt + Werte). Verifiziert: vitest 263/263 (33 Parity), tsc sauber, build:kernel2d sauber. Bekannte Teil-Deckung: detectRooms nur mit Rechtecken (1 Face) getestet — komplexe Topologie-Reihenfolge nicht mit Zufallsgraphen abgesichert.
981 lines
41 KiB
TypeScript
981 lines
41 KiB
TypeScript
// Differential-Paritaet: Rust-WASM-Kernel (`src-tauri/kernel2d`, Feature "web")
|
||
// gegen die TS-Referenz `kernel2d.ts` — auf identischen Eingaben muessen beide
|
||
// bis auf ein funktionsspezifisches Epsilon dasselbe liefern (siehe PORT_PLAN §5).
|
||
// Kern der Migration: beweist, dass der Port die Semantik bitnah erhaelt.
|
||
//
|
||
// VORAUSSETZUNG: `npm run build:kernel2d` muss vorher gelaufen sein (das Paket
|
||
// `src/engine/pkgKernel2d` ist git-ignoriert). Init synchron via `initSync` mit
|
||
// den WASM-Bytes aus `readFileSync` (kein fetch im Node-Lauf).
|
||
//
|
||
// Zwei Testklassen, NIE gemischt: Zufalls-Paritaet (naiv==naiv) und Golden
|
||
// (explizite Grenzfaelle). Robuste Praedikate sind hier NICHT im Spiel (v1).
|
||
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { fileURLToPath } from "node:url";
|
||
import { beforeAll, describe, expect, it } from "vitest";
|
||
|
||
import type { Vec2 } from "../model/types";
|
||
import {
|
||
circleCircleIntersect,
|
||
closestPointOnSegment,
|
||
extendSegment,
|
||
filletCorner,
|
||
isCCW,
|
||
joinChains,
|
||
lineCircleIntersect,
|
||
lineSegmentIntersect,
|
||
offsetPolyline,
|
||
offsetSegment,
|
||
pointSegmentDistance,
|
||
projectParam,
|
||
removeSegment,
|
||
segmentCircleIntersect,
|
||
segmentIntersect,
|
||
segmentPolylineHits,
|
||
signedArea,
|
||
splitAtIntersections,
|
||
splitClosedByChord,
|
||
splitPolylineAtParam,
|
||
splitSegmentByCutters,
|
||
trimPolyline,
|
||
trimSegment,
|
||
type Fillet,
|
||
type Hit,
|
||
} from "./kernel2d";
|
||
import {
|
||
ceilingArea,
|
||
isValidOutline,
|
||
normalizeOutline,
|
||
outlineBBox,
|
||
outlineCentroid,
|
||
pointInOutline,
|
||
} from "./ceiling";
|
||
import { centroid, perimeter, polygonArea } from "./roomArea";
|
||
import {
|
||
defaultStepCount,
|
||
pointHitsStair,
|
||
pointInPolygon as stairPointInPolygon,
|
||
stairBBox,
|
||
stairCut,
|
||
stairGeometry,
|
||
type StairGeometry as TSStairGeometry,
|
||
} from "./stair";
|
||
import type { Stair } from "../model/types";
|
||
import {
|
||
detectRooms,
|
||
pointInPolygon as rbPointInPolygon,
|
||
roomFromPointInside,
|
||
roomFromPointInsideFaces,
|
||
type WallSegment,
|
||
type WallFace,
|
||
} from "./roomBoundary";
|
||
|
||
type Poly = { pts: Vec2[]; closed: boolean };
|
||
|
||
// Das WASM-Paket ist git-ignoriert und wird nur von `npm run build:kernel2d`
|
||
// erzeugt. Fehlt es, wird die Suite SAUBER uebersprungen (statt Collection-Fehler),
|
||
// damit `vitest run` ohne vorherigen WASM-Build gruen bleibt. Darum dynamischer
|
||
// Import erst in `beforeAll` (kein statischer Top-Level-Import des Pakets).
|
||
type Batch = (json: string) => string;
|
||
const wasmPath = fileURLToPath(
|
||
new URL("../engine/pkgKernel2d/kernel2d_bg.wasm", import.meta.url),
|
||
);
|
||
const built = existsSync(wasmPath);
|
||
let K: Record<string, Batch>;
|
||
|
||
beforeAll(async () => {
|
||
if (!built) return;
|
||
const m = await import("../engine/pkgKernel2d/kernel2d.js");
|
||
(m as { initSync: (o: { module: Buffer }) => unknown }).initSync({
|
||
module: readFileSync(wasmPath),
|
||
});
|
||
K = m as unknown as Record<string, Batch>;
|
||
});
|
||
|
||
if (!built) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn(
|
||
"[kernel2d.parity] pkgKernel2d fehlt — `npm run build:kernel2d` fuer den Diff-Test noetig. Uebersprungen.",
|
||
);
|
||
}
|
||
|
||
// ── Seed-basierter RNG (mulberry32), deterministisch ─────────────────────────
|
||
function mulberry32(seed: number): () => number {
|
||
let s = seed >>> 0;
|
||
return () => {
|
||
s = (s + 0x6d2b79f5) | 0;
|
||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
/** Koordinate in [-100,100] m, mit Clustern nahe 0 und im 1e-6..1e-3-Bereich. */
|
||
function coord(rng: () => number): number {
|
||
const r = rng();
|
||
if (r < 0.15) return (rng() * 2 - 1) * 1e-3; // nahe 0
|
||
if (r < 0.25) return (rng() * 2 - 1) * 1e-6 + Math.round(rng() * 4 - 2); // Schwellen
|
||
return (rng() * 2 - 1) * 100;
|
||
}
|
||
const v = (rng: () => number): Vec2 => ({ x: coord(rng), y: coord(rng) });
|
||
|
||
/** Zwei sich garantiert schneidende Strecken um ein Zentrum c. */
|
||
function crossingSegs(rng: () => number) {
|
||
const c = v(rng);
|
||
const ang1 = rng() * Math.PI;
|
||
const ang2 = ang1 + 0.2 + rng() * (Math.PI - 0.4); // nicht (fast) parallel
|
||
const d1 = { x: Math.cos(ang1), y: Math.sin(ang1) };
|
||
const d2 = { x: Math.cos(ang2), y: Math.sin(ang2) };
|
||
const ext = () => 0.1 + rng() * 3;
|
||
return {
|
||
a1: { x: c.x - d1.x * ext(), y: c.y - d1.y * ext() },
|
||
a2: { x: c.x + d1.x * ext(), y: c.y + d1.y * ext() },
|
||
b1: { x: c.x - d2.x * ext(), y: c.y - d2.y * ext() },
|
||
b2: { x: c.x + d2.x * ext(), y: c.y + d2.y * ext() },
|
||
};
|
||
}
|
||
|
||
// ── Vergleichs-Helfer (Struktur zuerst, dann Werte mit op-Epsilon) ───────────
|
||
/** Abs-ODER-relative Toleranz: eng bei ~1, skaliert bei grossen Werten. */
|
||
function closeNum(a: number, b: number, rel = 1e-9): boolean {
|
||
return Math.abs(a - b) <= rel * Math.max(1, Math.abs(a), Math.abs(b));
|
||
}
|
||
function closeVec(a: Vec2, b: Vec2, rel = 1e-9): boolean {
|
||
return closeNum(a.x, b.x, rel) && closeNum(a.y, b.y, rel);
|
||
}
|
||
function closeHit(a: Hit, b: Hit): boolean {
|
||
return closeVec(a.point, b.point) && closeNum(a.t, b.t) && closeNum(a.s, b.s);
|
||
}
|
||
function eqPts(w: Vec2[], t: Vec2[]): boolean {
|
||
return w.length === t.length && t.every((p, i) => closeVec(w[i], p));
|
||
}
|
||
function eqPtsLists(w: Vec2[][], t: Vec2[][]): boolean {
|
||
return w.length === t.length && t.every((s, i) => eqPts(w[i], s));
|
||
}
|
||
function eqPolyList(w: Poly[], t: Poly[]): boolean {
|
||
return w.length === t.length && t.every((pl, i) => w[i].closed === pl.closed && eqPts(w[i].pts, pl.pts));
|
||
}
|
||
function eqPairs(w: [Vec2, Vec2][], t: [Vec2, Vec2][]): boolean {
|
||
return w.length === t.length && t.every((pr, i) => closeVec(w[i][0], pr[0]) && closeVec(w[i][1], pr[1]));
|
||
}
|
||
/** Ein paar lange Schneider-Segmente quer durch die BBox (als offene Cutter). */
|
||
function genCutters(rng: () => number): Poly[] {
|
||
const k = 1 + Math.floor(rng() * 3);
|
||
return Array.from({ length: k }, () => ({
|
||
pts: [
|
||
{ x: -80, y: (rng() * 2 - 1) * 60 },
|
||
{ x: 80, y: (rng() * 2 - 1) * 60 },
|
||
],
|
||
closed: false,
|
||
}));
|
||
}
|
||
function genPolyline(rng: () => number, minN: number, maxN: number): Vec2[] {
|
||
const n = minN + Math.floor(rng() * (maxN - minN + 1));
|
||
return Array.from({ length: n }, () => ({ x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 }));
|
||
}
|
||
|
||
const N = 300;
|
||
|
||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Zufall", () => {
|
||
it("projectParam / closestPointOnSegment / pointSegmentDistance", () => {
|
||
const rng = mulberry32(1);
|
||
const qs = Array.from({ length: N }, () => ({ p: v(rng), a: v(rng), b: v(rng) }));
|
||
|
||
const wProj = JSON.parse(K.project_param_batch_json(JSON.stringify(qs))) as number[];
|
||
const wClose = JSON.parse(K.closest_point_batch_json(JSON.stringify(qs))) as Vec2[];
|
||
const wDist = JSON.parse(K.point_segment_distance_batch_json(JSON.stringify(qs))) as number[];
|
||
|
||
qs.forEach((q, i) => {
|
||
expect(closeNum(wProj[i], projectParam(q.p, q.a, q.b)), `proj#${i}`).toBe(true);
|
||
expect(closeVec(wClose[i], closestPointOnSegment(q.p, q.a, q.b)), `close#${i}`).toBe(true);
|
||
expect(closeNum(wDist[i], pointSegmentDistance(q.p, q.a, q.b)), `dist#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("segmentIntersect / lineSegmentIntersect (Zufall + garantierte Kreuzungen)", () => {
|
||
const rng = mulberry32(2);
|
||
const qs = Array.from({ length: N }, (_, i) =>
|
||
i % 2 === 0
|
||
? { a1: v(rng), a2: v(rng), b1: v(rng), b2: v(rng) }
|
||
: crossingSegs(rng),
|
||
);
|
||
const wSeg = JSON.parse(K.segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
|
||
const wLine = JSON.parse(K.line_segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
|
||
|
||
let hits = 0;
|
||
qs.forEach((q, i) => {
|
||
const t = segmentIntersect(q.a1, q.a2, q.b1, q.b2);
|
||
// Struktur exakt: null ⇔ null.
|
||
expect(wSeg[i] === null, `seg-null#${i}`).toBe(t === null);
|
||
if (t && wSeg[i]) {
|
||
expect(closeHit(wSeg[i]!, t), `seg#${i}`).toBe(true);
|
||
hits++;
|
||
}
|
||
const tl = lineSegmentIntersect(q.a1, q.a2, q.b1, q.b2);
|
||
expect(wLine[i] === null, `line-null#${i}`).toBe(tl === null);
|
||
if (tl && wLine[i]) expect(closeHit(wLine[i]!, tl), `line#${i}`).toBe(true);
|
||
});
|
||
expect(hits, "keine einzige Kreuzung getroffen — Test waere aussagelos").toBeGreaterThan(50);
|
||
});
|
||
|
||
it("segmentPolylineHits (Struktur exakt + Werte)", () => {
|
||
const rng = mulberry32(3);
|
||
const qs = Array.from({ length: N }, () => {
|
||
const n = 3 + Math.floor(rng() * 18);
|
||
const pts = Array.from({ length: n }, () => v(rng));
|
||
// Langer Schneider quer durch die BBox.
|
||
return { a1: { x: -120, y: coord(rng) }, a2: { x: 120, y: coord(rng) }, pts, closed: rng() < 0.5 };
|
||
});
|
||
const wHits = JSON.parse(K.segment_polyline_hits_batch_json(JSON.stringify(qs))) as Hit[][];
|
||
qs.forEach((q, i) => {
|
||
const t = segmentPolylineHits(q.a1, q.a2, q.pts, q.closed);
|
||
expect(wHits[i].length, `hits-len#${i}`).toBe(t.length);
|
||
t.forEach((h, j) => expect(closeHit(wHits[i][j], h), `hit#${i}.${j}`).toBe(true));
|
||
});
|
||
});
|
||
|
||
it("lineCircleIntersect / segmentCircleIntersect (Struktur + Werte)", () => {
|
||
const rng = mulberry32(4);
|
||
const qs = Array.from({ length: N }, () => ({
|
||
a: v(rng),
|
||
b: v(rng),
|
||
center: v(rng),
|
||
r: 0.01 + rng() * 50,
|
||
}));
|
||
const wLine = JSON.parse(K.line_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
|
||
const wSeg = JSON.parse(K.segment_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
|
||
qs.forEach((q, i) => {
|
||
const tl = lineCircleIntersect(q.a, q.b, q.center, q.r);
|
||
expect(wLine[i].length, `lc-len#${i}`).toBe(tl.length);
|
||
tl.forEach((p, j) => expect(closeVec(wLine[i][j], p), `lc#${i}.${j}`).toBe(true));
|
||
const ts = segmentCircleIntersect(q.a, q.b, q.center, q.r);
|
||
expect(wSeg[i].length, `sc-len#${i}`).toBe(ts.length);
|
||
ts.forEach((p, j) => expect(closeVec(wSeg[i][j], p), `sc#${i}.${j}`).toBe(true));
|
||
});
|
||
});
|
||
|
||
it("circleCircleIntersect (Struktur + Werte)", () => {
|
||
const rng = mulberry32(5);
|
||
const qs = Array.from({ length: N }, () => ({
|
||
c1: v(rng),
|
||
r1: 0.01 + rng() * 50,
|
||
c2: v(rng),
|
||
r2: 0.01 + rng() * 50,
|
||
}));
|
||
const w = JSON.parse(K.circle_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
|
||
qs.forEach((q, i) => {
|
||
const t = circleCircleIntersect(q.c1, q.r1, q.c2, q.r2);
|
||
expect(w[i].length, `cc-len#${i}`).toBe(t.length);
|
||
t.forEach((p, j) => expect(closeVec(w[i][j], p), `cc#${i}.${j}`).toBe(true));
|
||
});
|
||
});
|
||
|
||
it("signedArea (rel 1e-9) / isCCW (Struktur exakt)", () => {
|
||
const rng = mulberry32(6);
|
||
const polys = Array.from({ length: N }, () => {
|
||
const n = 3 + Math.floor(rng() * 8);
|
||
return Array.from({ length: n }, () => v(rng));
|
||
});
|
||
const wArea = JSON.parse(K.signed_area_batch_json(JSON.stringify(polys))) as number[];
|
||
const wCcw = JSON.parse(K.is_ccw_batch_json(JSON.stringify(polys))) as boolean[];
|
||
polys.forEach((p, i) => {
|
||
const a = signedArea(p);
|
||
expect(Math.abs(wArea[i] - a) <= 1e-9 * Math.max(1, Math.abs(a)), `area#${i}`).toBe(true);
|
||
expect(wCcw[i], `ccw#${i}`).toBe(isCCW(p));
|
||
});
|
||
});
|
||
|
||
it("offsetSegment / offsetPolyline (Miter, Struktur + Werte rel 1e-9)", () => {
|
||
const rng = mulberry32(7);
|
||
const segs = Array.from({ length: N }, () => ({
|
||
a: v(rng),
|
||
b: v(rng),
|
||
d: (rng() * 2 - 1) * 5,
|
||
}));
|
||
const wSeg = JSON.parse(K.offset_segment_batch_json(JSON.stringify(segs))) as [Vec2, Vec2][];
|
||
segs.forEach((q, i) => {
|
||
const [a, b] = offsetSegment(q.a, q.b, q.d);
|
||
expect(closeVec(wSeg[i][0], a) && closeVec(wSeg[i][1], b), `offSeg#${i}`).toBe(true);
|
||
});
|
||
|
||
const polys = Array.from({ length: N }, () => {
|
||
const n = 3 + Math.floor(rng() * 10);
|
||
const pts = Array.from({ length: n }, () => v(rng));
|
||
return { pts, d: (rng() * 2 - 1) * 5, closed: rng() < 0.5 };
|
||
});
|
||
const wPoly = JSON.parse(K.offset_polyline_batch_json(JSON.stringify(polys))) as Vec2[][];
|
||
polys.forEach((q, i) => {
|
||
const t = offsetPolyline(q.pts, q.d, q.closed);
|
||
expect(wPoly[i].length, `offPoly-len#${i}`).toBe(t.length);
|
||
t.forEach((p, j) => expect(closeVec(wPoly[i][j], p), `offPoly#${i}.${j}`).toBe(true));
|
||
});
|
||
});
|
||
|
||
it("filletCorner (Struktur exakt + Werte, Winkel abs 1e-7)", () => {
|
||
const rng = mulberry32(8);
|
||
// Halb kontrolliert (garantiert gueltige Verrundung), halb Zufall (None-Paritaet).
|
||
const qs = Array.from({ length: N }, (_, i) => {
|
||
if (i % 2 === 0) {
|
||
const corner = v(rng);
|
||
const a0 = rng() * Math.PI * 2;
|
||
const half = 0.3 + rng() * 0.9; // theta = 2*half ∈ [0.6, 2.4] rad (weg von 0/π)
|
||
const legLen = 3 + rng() * 7;
|
||
const rMax = legLen * Math.tan(half);
|
||
const r = 0.1 + rng() * 0.8 * rMax;
|
||
const dir = (ang: number) => ({ x: Math.cos(ang), y: Math.sin(ang) });
|
||
const d1 = dir(a0 + half);
|
||
const d2 = dir(a0 - half);
|
||
return {
|
||
corner,
|
||
p1: { x: corner.x + d1.x * legLen, y: corner.y + d1.y * legLen },
|
||
p2: { x: corner.x + d2.x * legLen, y: corner.y + d2.y * legLen },
|
||
r,
|
||
};
|
||
}
|
||
return { corner: v(rng), p1: v(rng), p2: v(rng), r: 0.1 + rng() * 5 };
|
||
});
|
||
const w = JSON.parse(K.fillet_corner_batch_json(JSON.stringify(qs))) as (Fillet | null)[];
|
||
let valid = 0;
|
||
qs.forEach((q, i) => {
|
||
const t = filletCorner(q.corner, q.p1, q.p2, q.r);
|
||
expect(w[i] === null, `fil-null#${i}`).toBe(t === null);
|
||
if (t && w[i]) {
|
||
const f = w[i]!;
|
||
expect(closeVec(f.center, t.center), `fil-center#${i}`).toBe(true);
|
||
expect(closeVec(f.tangentA, t.tangentA), `fil-tA#${i}`).toBe(true);
|
||
expect(closeVec(f.tangentB, t.tangentB), `fil-tB#${i}`).toBe(true);
|
||
expect(closeNum(f.radius, t.radius), `fil-r#${i}`).toBe(true);
|
||
expect(Math.abs(f.startAngle - t.startAngle) <= 1e-7, `fil-sa#${i}`).toBe(true);
|
||
expect(Math.abs(f.endAngle - t.endAngle) <= 1e-7, `fil-ea#${i}`).toBe(true);
|
||
valid++;
|
||
}
|
||
});
|
||
expect(valid, "keine gueltige Verrundung — Test waere aussagelos").toBeGreaterThan(50);
|
||
});
|
||
|
||
it("splitSegmentByCutters / trimSegment (Struktur + Werte)", () => {
|
||
const rng = mulberry32(9);
|
||
const qs = Array.from({ length: N }, () => ({
|
||
a1: { x: -60, y: (rng() * 2 - 1) * 40 },
|
||
a2: { x: 60, y: (rng() * 2 - 1) * 40 },
|
||
cutters: genCutters(rng),
|
||
pick: { x: (rng() * 2 - 1) * 60, y: (rng() * 2 - 1) * 40 },
|
||
}));
|
||
const wSplit = JSON.parse(K.split_segment_by_cutters_batch_json(JSON.stringify(qs))) as [Vec2, Vec2][][];
|
||
const wTrim = JSON.parse(K.trim_segment_batch_json(JSON.stringify(qs))) as [Vec2, Vec2][][];
|
||
qs.forEach((q, i) => {
|
||
expect(eqPairs(wSplit[i], splitSegmentByCutters(q.a1, q.a2, q.cutters)), `split#${i}`).toBe(true);
|
||
expect(eqPairs(wTrim[i], trimSegment(q.a1, q.a2, q.cutters, q.pick)), `trim#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("trimPolyline / splitAtIntersections (offen+geschlossen, Struktur exakt)", () => {
|
||
const rng = mulberry32(10);
|
||
const qs = Array.from({ length: N }, () => ({
|
||
pts: genPolyline(rng, 4, 9),
|
||
closed: rng() < 0.5,
|
||
cutters: genCutters(rng),
|
||
pick: { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 },
|
||
others: genCutters(rng).map((c) => c.pts),
|
||
}));
|
||
const wTrim = JSON.parse(K.trim_polyline_batch_json(JSON.stringify(qs))) as Poly[][];
|
||
const wSplit = JSON.parse(
|
||
K.split_at_intersections_batch_json(
|
||
JSON.stringify(qs.map((q) => ({ targetPts: q.pts, closed: q.closed, others: q.others }))),
|
||
),
|
||
) as Vec2[][][];
|
||
qs.forEach((q, i) => {
|
||
expect(eqPolyList(wTrim[i], trimPolyline(q.pts, q.closed, q.cutters, q.pick)), `trimPoly#${i}`).toBe(true);
|
||
expect(eqPtsLists(wSplit[i], splitAtIntersections(q.pts, q.closed, q.others)), `splitAt#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("splitPolylineAtParam / splitClosedByChord / removeSegment", () => {
|
||
const rng = mulberry32(11);
|
||
const qs = Array.from({ length: N }, () => {
|
||
const pts = genPolyline(rng, 4, 9);
|
||
const n = pts.length;
|
||
const closed = rng() < 0.5;
|
||
return { pts, closed, edgeIndex: Math.floor(rng() * (closed ? n : n - 1)), t: rng() };
|
||
});
|
||
const wSap = JSON.parse(K.split_polyline_at_param_batch_json(JSON.stringify(qs))) as Vec2[][][];
|
||
const wRem = JSON.parse(
|
||
K.remove_segment_batch_json(
|
||
JSON.stringify(qs.map((q) => ({ pts: q.pts, closed: q.closed, edgeIndex: q.edgeIndex }))),
|
||
),
|
||
) as Poly[];
|
||
const chords = Array.from({ length: N }, () => {
|
||
const pts = genPolyline(rng, 4, 8);
|
||
const n = pts.length;
|
||
const i = Math.floor(rng() * n);
|
||
let j = Math.floor(rng() * n);
|
||
if (j === i) j = (j + 1) % n;
|
||
return { pts, i, ti: rng(), j, tj: rng() };
|
||
});
|
||
const wChord = JSON.parse(K.split_closed_by_chord_batch_json(JSON.stringify(chords))) as ([Vec2[], Vec2[]] | null)[];
|
||
qs.forEach((q, i) => {
|
||
expect(eqPtsLists(wSap[i], splitPolylineAtParam(q.pts, q.closed, q.edgeIndex, q.t)), `sap#${i}`).toBe(true);
|
||
const tr = removeSegment(q.pts, q.closed, q.edgeIndex);
|
||
expect(wRem[i].closed === tr.closed && eqPts(wRem[i].pts, tr.pts), `rem#${i}`).toBe(true);
|
||
});
|
||
chords.forEach((q, i) => {
|
||
const t = splitClosedByChord(q.pts, q.i, q.ti, q.j, q.tj);
|
||
expect((wChord[i] === null) === (t === null), `chord-null#${i}`).toBe(true);
|
||
if (t && wChord[i]) {
|
||
expect(eqPts(wChord[i]![0], t[0]) && eqPts(wChord[i]![1], t[1]), `chord#${i}`).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
it("extendSegment (start/end, null-Paritaet + Werte)", () => {
|
||
const rng = mulberry32(12);
|
||
const qs = Array.from({ length: N }, (_, k) => {
|
||
const c = { x: (rng() * 2 - 1) * 30, y: (rng() * 2 - 1) * 30 };
|
||
return {
|
||
a1: { x: c.x - 1, y: c.y },
|
||
a2: { x: c.x + 1, y: c.y },
|
||
end: (k % 2 === 0 ? "end" : "start") as "start" | "end",
|
||
cutters: genCutters(rng).concat([
|
||
{ pts: [{ x: c.x + 5, y: -50 }, { x: c.x + 5, y: 50 }], closed: false },
|
||
]),
|
||
};
|
||
});
|
||
const w = JSON.parse(K.extend_segment_batch_json(JSON.stringify(qs))) as ([Vec2, Vec2] | null)[];
|
||
qs.forEach((q, i) => {
|
||
const t = extendSegment(q.a1, q.a2, q.end, q.cutters);
|
||
expect((w[i] === null) === (t === null), `ext-null#${i}`).toBe(true);
|
||
if (t && w[i]) expect(closeVec(w[i]![0], t[0]) && closeVec(w[i]![1], t[1]), `ext#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("joinChains (mergeable + Zufall, Struktur exakt)", () => {
|
||
const rng = mulberry32(13);
|
||
const groups = Array.from({ length: N }, () => {
|
||
const base = genPolyline(rng, 4, 7);
|
||
const chains: Poly[] = [];
|
||
for (let k = 0; k < base.length - 1; k++) {
|
||
const seg = [base[k], base[k + 1]];
|
||
chains.push({ pts: rng() < 0.5 ? seg : [seg[1], seg[0]], closed: false });
|
||
}
|
||
if (rng() < 0.5) chains.push({ pts: genPolyline(rng, 2, 3), closed: false });
|
||
for (let k = chains.length - 1; k > 0; k--) {
|
||
const j = Math.floor(rng() * (k + 1));
|
||
[chains[k], chains[j]] = [chains[j], chains[k]];
|
||
}
|
||
return chains;
|
||
});
|
||
const w = JSON.parse(K.join_chains_batch_json(JSON.stringify(groups))) as Poly[][];
|
||
groups.forEach((g, i) => {
|
||
expect(eqPolyList(w[i], joinChains(g)), `join#${i}`).toBe(true);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Slice 1: roomArea / ceiling ───────────────────────────────────────────────
|
||
|
||
/** Zufaelliges konvexes Polygon mit n=3..10 Ecken in [-30,30]×[-30,30]. */
|
||
function genConvexPoly(rng: () => number, n: number): Vec2[] {
|
||
// Einfache Methode: Einheitswinkel sortieren + skalieren.
|
||
const angles = Array.from({ length: n }, () => rng() * 2 * Math.PI);
|
||
angles.sort((a, b) => a - b);
|
||
const rx = 1 + rng() * 20;
|
||
const ry = 1 + rng() * 20;
|
||
const cx = (rng() * 2 - 1) * 10;
|
||
const cy = (rng() * 2 - 1) * 10;
|
||
return angles.map((a) => ({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) }));
|
||
}
|
||
|
||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 1: roomArea + ceiling", () => {
|
||
it("polygonArea / perimeter / centroid (Zufallspolygone 3–10 Ecken, rel 1e-9)", () => {
|
||
const rng = mulberry32(20);
|
||
const polys = Array.from({ length: N }, () => {
|
||
const n = 3 + Math.floor(rng() * 8);
|
||
return genConvexPoly(rng, n);
|
||
});
|
||
const wArea = JSON.parse(K.polygon_area_batch_json(JSON.stringify(polys))) as number[];
|
||
const wPerim = JSON.parse(K.perimeter_batch_json(JSON.stringify(polys))) as number[];
|
||
const wCent = JSON.parse(K.centroid_batch_json(JSON.stringify(polys))) as Vec2[];
|
||
|
||
polys.forEach((p, i) => {
|
||
const a = polygonArea(p);
|
||
const pe = perimeter(p);
|
||
const c = centroid(p);
|
||
expect(closeNum(wArea[i], a), `area#${i}`).toBe(true);
|
||
expect(closeNum(wPerim[i], pe), `perim#${i}`).toBe(true);
|
||
expect(closeVec(wCent[i], c), `centroid#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("normalizeOutline: Struktur exakt (null↔null) + Werte + None-Paritaet", () => {
|
||
const rng = mulberry32(21);
|
||
// Haelfte: gueltige Polygone; Haelfte: degenerierte (< 3 Punkte oder kollinear).
|
||
const inputs = Array.from({ length: N }, (_, i) => {
|
||
if (i % 4 < 3) {
|
||
const n = 3 + Math.floor(rng() * 8);
|
||
return genConvexPoly(rng, n);
|
||
}
|
||
// Degeneriert: 0, 1 oder 2 Punkte.
|
||
const k = Math.floor(rng() * 3);
|
||
return Array.from({ length: k }, () => ({ x: rng() * 10, y: rng() * 10 }));
|
||
});
|
||
const w = JSON.parse(K.normalize_outline_batch_json(JSON.stringify(inputs))) as (Vec2[] | null)[];
|
||
inputs.forEach((p, i) => {
|
||
const t = normalizeOutline(p);
|
||
expect(w[i] === null, `norm-null#${i}`).toBe(t === null);
|
||
if (t !== null && w[i] !== null) {
|
||
const wp = w[i]!;
|
||
expect(wp.length, `norm-len#${i}`).toBe(t.length);
|
||
t.forEach((pt, j) => expect(closeVec(wp[j], pt), `norm-pt#${i}.${j}`).toBe(true));
|
||
}
|
||
});
|
||
});
|
||
|
||
it("isValidOutline / ceilingArea (Struktur exakt + Werte)", () => {
|
||
const rng = mulberry32(22);
|
||
const polys = Array.from({ length: N }, (_, i) => {
|
||
if (i % 5 < 4) return genConvexPoly(rng, 3 + Math.floor(rng() * 8));
|
||
return Array.from({ length: Math.floor(rng() * 3) }, () => ({ x: rng() * 5, y: rng() * 5 }));
|
||
});
|
||
const wValid = JSON.parse(K.is_valid_outline_batch_json(JSON.stringify(polys))) as boolean[];
|
||
const wArea = JSON.parse(K.ceiling_area_batch_json(JSON.stringify(polys))) as number[];
|
||
|
||
polys.forEach((p, i) => {
|
||
expect(wValid[i], `valid#${i}`).toBe(isValidOutline(p));
|
||
expect(closeNum(wArea[i], ceilingArea(p)), `ceilArea#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("outlineBBox / outlineCentroid (Werte rel 1e-9)", () => {
|
||
const rng = mulberry32(23);
|
||
const polys = Array.from({ length: N }, () => genConvexPoly(rng, 3 + Math.floor(rng() * 8)));
|
||
const wBBox = JSON.parse(K.outline_bbox_batch_json(JSON.stringify(polys))) as {
|
||
minX: number;
|
||
minY: number;
|
||
maxX: number;
|
||
maxY: number;
|
||
}[];
|
||
const wCent = JSON.parse(K.outline_centroid_batch_json(JSON.stringify(polys))) as Vec2[];
|
||
|
||
polys.forEach((p, i) => {
|
||
const bb = outlineBBox(p);
|
||
const c = outlineCentroid(p);
|
||
expect(closeNum(wBBox[i].minX, bb.minX), `bbox-minX#${i}`).toBe(true);
|
||
expect(closeNum(wBBox[i].minY, bb.minY), `bbox-minY#${i}`).toBe(true);
|
||
expect(closeNum(wBBox[i].maxX, bb.maxX), `bbox-maxX#${i}`).toBe(true);
|
||
expect(closeNum(wBBox[i].maxY, bb.maxY), `bbox-maxY#${i}`).toBe(true);
|
||
expect(closeVec(wCent[i], c), `outCentroid#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("pointInOutline (Struktur exakt: true↔true, Zufallspunkte innen+aussen)", () => {
|
||
const rng = mulberry32(24);
|
||
const qs = Array.from({ length: N }, () => {
|
||
const n = 3 + Math.floor(rng() * 8);
|
||
const outline = genConvexPoly(rng, n);
|
||
// Schwerpunkt des Polygons als garantiert innerer Punkt.
|
||
const cx = outline.reduce((s, p) => s + p.x, 0) / outline.length;
|
||
const cy = outline.reduce((s, p) => s + p.y, 0) / outline.length;
|
||
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
|
||
return { p, outline };
|
||
});
|
||
const w = JSON.parse(K.point_in_outline_batch_json(JSON.stringify(qs))) as boolean[];
|
||
qs.forEach((q, i) => {
|
||
expect(w[i], `pio#${i}`).toBe(pointInOutline(q.p, q.outline));
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Slice 2: stair ────────────────────────────────────────────────────────────
|
||
|
||
/** Erzeugt einen einfachen Stair-Stub mit geometrisch relevanten Feldern. */
|
||
function genStair(
|
||
rng: () => number,
|
||
shape: "straight" | "L" | "spiral",
|
||
): Stair & { totalRise: number } {
|
||
const start = { x: (rng() * 2 - 1) * 10, y: (rng() * 2 - 1) * 10 };
|
||
const angle = rng() * Math.PI * 2;
|
||
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
|
||
const runLength = 1 + rng() * 5;
|
||
const width = 0.8 + rng() * 1.5;
|
||
const stepCount = 3 + Math.floor(rng() * 12);
|
||
const totalRise = 0.5 + rng() * 3;
|
||
const up = rng() < 0.5 ? true : false;
|
||
const base: Stair = {
|
||
id: "test",
|
||
type: "stair",
|
||
floorId: "f1",
|
||
categoryCode: "40",
|
||
shape,
|
||
start,
|
||
dir,
|
||
runLength,
|
||
width,
|
||
stepCount,
|
||
up,
|
||
};
|
||
if (shape === "L") {
|
||
return { ...base, run2Length: 1 + rng() * 4, turn: rng() < 0.5 ? 1 : -1, totalRise };
|
||
}
|
||
if (shape === "spiral") {
|
||
return {
|
||
...base,
|
||
center: { x: start.x + rng() * 2, y: start.y + rng() * 2 },
|
||
radius: 0.5 + rng() * 2,
|
||
sweep: 90 + rng() * 270,
|
||
totalRise,
|
||
};
|
||
}
|
||
return { ...base, totalRise };
|
||
}
|
||
|
||
/** Prueft Tritt-Listen-Gleichheit: Laenge, dann je Tritt index/baseRise/topRise/pts. */
|
||
function eqTreads(
|
||
w: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[],
|
||
t: TSStairGeometry["treads"],
|
||
): boolean {
|
||
if (w.length !== t.length) return false;
|
||
return t.every((tr, i) => {
|
||
const wtr = w[i];
|
||
if (wtr.index !== tr.index) return false;
|
||
if (!closeNum(wtr.topRise, tr.topRise)) return false;
|
||
if (!closeNum(wtr.baseRise, tr.baseRise)) return false;
|
||
if (!eqPts(wtr.pts, tr.pts)) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 2: stair", () => {
|
||
it("defaultStepCount (Struktur exakt + Werte)", () => {
|
||
const rng = mulberry32(30);
|
||
const qs = Array.from({ length: N }, () => ({
|
||
totalRise: 0.1 + rng() * 5,
|
||
runLength: rng() * 8,
|
||
}));
|
||
const w = JSON.parse(K.default_step_count_batch_json(JSON.stringify(qs))) as number[];
|
||
qs.forEach((q, i) => {
|
||
expect(w[i], `dsc#${i}`).toBe(defaultStepCount(q.totalRise, q.runLength));
|
||
});
|
||
});
|
||
|
||
it("stairGeometry gerade: Tritte + Werte (Struktur exakt, rel 1e-9)", () => {
|
||
const rng = mulberry32(31);
|
||
const qs = Array.from({ length: 100 }, () => {
|
||
const s = genStair(rng, "straight");
|
||
return { stair: s, totalRise: s.totalRise };
|
||
});
|
||
const wGeos = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(qs))) as {
|
||
treads: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[];
|
||
landing: Vec2[] | null;
|
||
riserHeight: number;
|
||
treadDepth: number;
|
||
runLine: Vec2[];
|
||
totalRise: number;
|
||
}[];
|
||
qs.forEach((q, i) => {
|
||
const t = stairGeometry(q.stair, q.totalRise);
|
||
const w = wGeos[i];
|
||
expect(eqTreads(w.treads, t.treads), `treads#${i}`).toBe(true);
|
||
expect(w.landing === null, `landing-null#${i}`).toBe(t.landing === null);
|
||
expect(closeNum(w.riserHeight, t.riserHeight), `riser#${i}`).toBe(true);
|
||
expect(closeNum(w.treadDepth, t.treadDepth), `depth#${i}`).toBe(true);
|
||
expect(eqPts(w.runLine, t.runLine), `runLine#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("stairGeometry L + spiral: Struktur exakt + Werte", () => {
|
||
const rng = mulberry32(32);
|
||
const lqs = Array.from({ length: 50 }, () => {
|
||
const s = genStair(rng, "L");
|
||
return { stair: s, totalRise: s.totalRise };
|
||
});
|
||
const spiralqs = Array.from({ length: 50 }, () => {
|
||
const s = genStair(rng, "spiral");
|
||
return { stair: s, totalRise: s.totalRise };
|
||
});
|
||
const wL = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(lqs))) as {
|
||
treads: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[];
|
||
landing: Vec2[] | null;
|
||
riserHeight: number;
|
||
treadDepth: number;
|
||
runLine: Vec2[];
|
||
}[];
|
||
lqs.forEach((q, i) => {
|
||
const t = stairGeometry(q.stair, q.totalRise);
|
||
const w = wL[i];
|
||
expect(eqTreads(w.treads, t.treads), `L-treads#${i}`).toBe(true);
|
||
expect(w.landing !== null, `L-landing#${i}`).toBe(t.landing !== null);
|
||
if (t.landing && w.landing) expect(eqPts(w.landing, t.landing), `L-landingPts#${i}`).toBe(true);
|
||
expect(eqPts(w.runLine, t.runLine), `L-runLine#${i}`).toBe(true);
|
||
});
|
||
const wSp = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(spiralqs))) as typeof wL;
|
||
spiralqs.forEach((q, i) => {
|
||
const t = stairGeometry(q.stair, q.totalRise);
|
||
const w = wSp[i];
|
||
expect(eqTreads(w.treads, t.treads), `sp-treads#${i}`).toBe(true);
|
||
expect(eqPts(w.runLine, t.runLine), `sp-runLine#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("stairCut (Struktur exakt: Indizes + breakLine-null)", () => {
|
||
const rng = mulberry32(33);
|
||
const qs = Array.from({ length: 100 }, () => {
|
||
const s = genStair(rng, "straight");
|
||
const geo = stairGeometry(s, s.totalRise);
|
||
const cutRise = rng() * s.totalRise * 1.1;
|
||
return { geo, cutRise };
|
||
});
|
||
const w = JSON.parse(K.stair_cut_batch_json(JSON.stringify(qs))) as {
|
||
belowIndices: number[];
|
||
aboveIndices: number[];
|
||
breakLine: [Vec2, Vec2][] | null;
|
||
}[];
|
||
qs.forEach((q, i) => {
|
||
const t = stairCut(q.geo, q.cutRise);
|
||
expect(w[i].belowIndices.length, `cut-below-len#${i}`).toBe(t.belowIndices.length);
|
||
expect(w[i].aboveIndices.length, `cut-above-len#${i}`).toBe(t.aboveIndices.length);
|
||
expect(w[i].belowIndices, `cut-below#${i}`).toEqual(t.belowIndices);
|
||
expect(w[i].aboveIndices, `cut-above#${i}`).toEqual(t.aboveIndices);
|
||
expect(w[i].breakLine === null, `cut-break-null#${i}`).toBe(t.breakLine === null);
|
||
});
|
||
});
|
||
|
||
it("stairBBox (Werte rel 1e-9)", () => {
|
||
const rng = mulberry32(34);
|
||
const geos = Array.from({ length: 100 }, () => {
|
||
const s = genStair(rng, "straight");
|
||
return stairGeometry(s, s.totalRise);
|
||
});
|
||
const w = JSON.parse(K.stair_bbox_batch_json(JSON.stringify(geos))) as {
|
||
minX: number; minY: number; maxX: number; maxY: number;
|
||
}[];
|
||
geos.forEach((g, i) => {
|
||
const t = stairBBox(g);
|
||
expect(closeNum(w[i].minX, t.minX), `bbox-minX#${i}`).toBe(true);
|
||
expect(closeNum(w[i].minY, t.minY), `bbox-minY#${i}`).toBe(true);
|
||
expect(closeNum(w[i].maxX, t.maxX), `bbox-maxX#${i}`).toBe(true);
|
||
expect(closeNum(w[i].maxY, t.maxY), `bbox-maxY#${i}`).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("pointInPolygon / pointHitsStair (Struktur exakt)", () => {
|
||
const rng = mulberry32(35);
|
||
const pipQs = Array.from({ length: N }, () => {
|
||
const n = 3 + Math.floor(rng() * 8);
|
||
const poly = genConvexPoly(rng, n);
|
||
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
|
||
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
|
||
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
|
||
return { p, poly };
|
||
});
|
||
const wPip = JSON.parse(K.point_in_polygon_batch_json(JSON.stringify(pipQs))) as boolean[];
|
||
pipQs.forEach((q, i) => {
|
||
expect(wPip[i], `pip#${i}`).toBe(stairPointInPolygon(q.p, q.poly));
|
||
});
|
||
|
||
const phsQs = Array.from({ length: 50 }, () => {
|
||
const s = genStair(rng, "straight");
|
||
const geo = stairGeometry(s, s.totalRise);
|
||
const p = { x: s.start.x + rng() * 6 - 1, y: s.start.y + (rng() * 2 - 1) * 2 };
|
||
return { p, geo };
|
||
});
|
||
const wPhs = JSON.parse(K.point_hits_stair_batch_json(JSON.stringify(phsQs))) as boolean[];
|
||
phsQs.forEach((q, i) => {
|
||
expect(wPhs[i], `phs#${i}`).toBe(pointHitsStair(q.p, q.geo));
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Slice 3: roomBoundary ─────────────────────────────────────────────────────
|
||
|
||
/** Erzeugt eine Menge rechtwinkliger Wandsegmente, die ein einfaches Rechteck
|
||
* (oder L-Form) bilden. */
|
||
function genRectWalls(rng: () => number): WallSegment[] {
|
||
const x0 = (rng() * 2 - 1) * 5;
|
||
const y0 = (rng() * 2 - 1) * 5;
|
||
const w = 2 + rng() * 5;
|
||
const h = 2 + rng() * 5;
|
||
const t = 0.1 + rng() * 0.3;
|
||
return [
|
||
{ a: { x: x0, y: y0 }, b: { x: x0 + w, y: y0 }, thickness: t },
|
||
{ a: { x: x0 + w, y: y0 }, b: { x: x0 + w, y: y0 + h }, thickness: t },
|
||
{ a: { x: x0 + w, y: y0 + h }, b: { x: x0, y: y0 + h }, thickness: t },
|
||
{ a: { x: x0, y: y0 + h }, b: { x: x0, y: y0 }, thickness: t },
|
||
];
|
||
}
|
||
|
||
/** Erzeugt WallFace-Liste aus einem geschlossenen Polygon. */
|
||
function polyToFaces(pts: Vec2[]): WallFace[] {
|
||
const n = pts.length;
|
||
return Array.from({ length: n }, (_, i) => ({ a: pts[i], b: pts[(i + 1) % n] }));
|
||
}
|
||
|
||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBoundary", () => {
|
||
it("rbPointInPolygon (roomBoundary-Variante, kein Nenner-Guard, Struktur exakt)", () => {
|
||
const rng = mulberry32(40);
|
||
const qs = Array.from({ length: N }, () => {
|
||
const n = 3 + Math.floor(rng() * 8);
|
||
const poly = genConvexPoly(rng, n);
|
||
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
|
||
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
|
||
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
|
||
return { p, poly };
|
||
});
|
||
const w = JSON.parse(K.rb_point_in_polygon_batch_json(JSON.stringify(qs))) as boolean[];
|
||
qs.forEach((q, i) => {
|
||
expect(w[i], `rbpip#${i}`).toBe(rbPointInPolygon(q.p, q.poly));
|
||
});
|
||
});
|
||
|
||
it("detectRooms — Rechteck-Grundriss: gleiche Anzahl + Reihenfolge Raeume", () => {
|
||
const rng = mulberry32(41);
|
||
const qs = Array.from({ length: 50 }, () => ({
|
||
walls: genRectWalls(rng),
|
||
gapTol: 0.05,
|
||
minArea: 0.05,
|
||
offsetToInner: true,
|
||
}));
|
||
const w = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(qs))) as Vec2[][][];
|
||
qs.forEach((q, i) => {
|
||
const t = detectRooms(q.walls, { gapTol: q.gapTol, minArea: q.minArea, offsetToInner: q.offsetToInner });
|
||
// Gleiche Anzahl Raeume.
|
||
expect(w[i].length, `dr-count#${i}`).toBe(t.length);
|
||
// Je Raum: gleiche Punktanzahl (Reihenfolge-abhaengig, aber deterministisch).
|
||
t.forEach((room, j) => {
|
||
expect(w[i][j]?.length, `dr-roomLen#${i}.${j}`).toBe(room.length);
|
||
room.forEach((pt, k) => expect(closeVec(w[i][j][k], pt), `dr-pt#${i}.${j}.${k}`).toBe(true));
|
||
});
|
||
});
|
||
});
|
||
|
||
it("detectRooms ohne Offset + degeneriert (< 3 Waende → leer)", () => {
|
||
const rng = mulberry32(42);
|
||
const qs = Array.from({ length: 30 }, () => ({
|
||
walls: genRectWalls(rng),
|
||
gapTol: 0.05,
|
||
minArea: 0.05,
|
||
offsetToInner: false,
|
||
}));
|
||
const w = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(qs))) as Vec2[][][];
|
||
qs.forEach((q, i) => {
|
||
const t = detectRooms(q.walls, { gapTol: q.gapTol, minArea: q.minArea, offsetToInner: q.offsetToInner });
|
||
expect(w[i].length, `drno-count#${i}`).toBe(t.length);
|
||
});
|
||
// Degeneriert: < 3 Waende → leer.
|
||
const deg = [{ walls: [{ a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, thickness: 0.2 }], gapTol: 0.05, minArea: 0.05, offsetToInner: true }];
|
||
const wd = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(deg))) as Vec2[][][];
|
||
expect(wd[0].length).toBe(detectRooms(deg[0].walls, {}).length);
|
||
expect(wd[0].length).toBe(0);
|
||
});
|
||
|
||
it("roomFromPointInside — Schwerpunkt als Saatpunkt findet den Raum (nicht null)", () => {
|
||
const rng = mulberry32(43);
|
||
const qs = Array.from({ length: 50 }, () => {
|
||
const walls = genRectWalls(rng);
|
||
// Mittelpunkt des Rechtecks als Saatpunkt.
|
||
const xs = walls.flatMap((w) => [w.a.x, w.b.x]);
|
||
const ys = walls.flatMap((w) => [w.a.y, w.b.y]);
|
||
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||
const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
|
||
return { point: { x: cx, y: cy }, walls, gapTol: 0.05, offsetToInner: true };
|
||
});
|
||
const w = JSON.parse(K.room_from_point_inside_batch_json(JSON.stringify(qs))) as (Vec2[] | null)[];
|
||
qs.forEach((q, i) => {
|
||
const t = roomFromPointInside(q.point, q.walls, { gapTol: q.gapTol, offsetToInner: q.offsetToInner });
|
||
expect(w[i] === null, `rfpi-null#${i}`).toBe(t === null);
|
||
if (t !== null && w[i] !== null) {
|
||
expect(w[i]!.length, `rfpi-len#${i}`).toBe(t.length);
|
||
t.forEach((pt, j) => expect(closeVec(w[i]![j], pt), `rfpi-pt#${i}.${j}`).toBe(true));
|
||
}
|
||
});
|
||
});
|
||
|
||
it("roomFromPointInsideFaces — Struktur exakt (null↔null, Laenge, Punkte)", () => {
|
||
const rng = mulberry32(44);
|
||
const qs = Array.from({ length: 30 }, () => {
|
||
const n = 4 + Math.floor(rng() * 4);
|
||
const poly = genConvexPoly(rng, n);
|
||
const faces = polyToFaces(poly);
|
||
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
|
||
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
|
||
const inOrOut = rng() < 0.7 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 40, y: (rng() * 2 - 1) * 40 };
|
||
return { point: inOrOut, wallFaces: faces, gapTol: 0.05 };
|
||
});
|
||
const w = JSON.parse(K.room_from_point_inside_faces_batch_json(JSON.stringify(qs))) as (Vec2[] | null)[];
|
||
qs.forEach((q, i) => {
|
||
const t = roomFromPointInsideFaces(q.point, q.wallFaces, q.gapTol);
|
||
expect(w[i] === null, `rfpif-null#${i}`).toBe(t === null);
|
||
if (t !== null && w[i] !== null) {
|
||
expect(w[i]!.length, `rfpif-len#${i}`).toBe(t.length);
|
||
t.forEach((pt, j) => expect(closeVec(w[i]![j], pt), `rfpif-pt#${i}.${j}`).toBe(true));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
|
||
it("parallele/kollineare Strecken → null (beide)", () => {
|
||
const qs = [
|
||
{ a1: { x: 0, y: 0 }, a2: { x: 2, y: 0 }, b1: { x: 0, y: 1 }, b2: { x: 2, y: 1 } }, // parallel
|
||
{ a1: { x: 0, y: 0 }, a2: { x: 4, y: 0 }, b1: { x: 1, y: 0 }, b2: { x: 3, y: 0 } }, // kollinear
|
||
{ a1: { x: 0, y: 0 }, a2: { x: 0, y: 0 }, b1: { x: 1, y: 1 }, b2: { x: 2, y: 2 } }, // Null-Laenge
|
||
];
|
||
const w = JSON.parse(K.segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
|
||
qs.forEach((q, i) => {
|
||
const t = segmentIntersect(q.a1, q.a2, q.b1, q.b2);
|
||
expect(w[i] === null, `#${i}`).toBe(t === null);
|
||
if (t && w[i]) expect(closeHit(w[i]!, t)).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("tangentiale Gerade → genau 1 Punkt; konzentrische/getrennte Kreise → leer", () => {
|
||
const lc = [{ a: { x: -1, y: 1 }, b: { x: 1, y: 1 }, center: { x: 0, y: 0 }, r: 1 }]; // Tangente
|
||
const wlc = JSON.parse(K.line_circle_intersect_batch_json(JSON.stringify(lc))) as Vec2[][];
|
||
expect(wlc[0].length).toBe(lineCircleIntersect(lc[0].a, lc[0].b, lc[0].center, lc[0].r).length);
|
||
expect(wlc[0].length).toBe(1);
|
||
|
||
const cc = [
|
||
{ c1: { x: 0, y: 0 }, r1: 1, c2: { x: 0, y: 0 }, r2: 2 }, // konzentrisch → leer
|
||
{ c1: { x: 0, y: 0 }, r1: 1, c2: { x: 5, y: 0 }, r2: 1 }, // getrennt → leer
|
||
{ c1: { x: 0, y: 0 }, r1: 2, c2: { x: 1, y: 0 }, r2: 1 }, // innen tangential → 1
|
||
];
|
||
const wcc = JSON.parse(K.circle_circle_intersect_batch_json(JSON.stringify(cc))) as Vec2[][];
|
||
cc.forEach((q, i) => {
|
||
const t = circleCircleIntersect(q.c1, q.r1, q.c2, q.r2);
|
||
expect(wcc[i].length, `cc#${i}`).toBe(t.length);
|
||
t.forEach((p, j) => expect(closeVec(wcc[i][j], p)).toBe(true));
|
||
});
|
||
});
|
||
|
||
it("degeneriertes Null-Flaeche-Polygon (kollinear) → Flaeche 0, nicht CCW", () => {
|
||
const polys = [[{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }]];
|
||
const wArea = JSON.parse(K.signed_area_batch_json(JSON.stringify(polys))) as number[];
|
||
const wCcw = JSON.parse(K.is_ccw_batch_json(JSON.stringify(polys))) as boolean[];
|
||
expect(closeNum(wArea[0], signedArea(polys[0]))).toBe(true);
|
||
expect(Math.abs(wArea[0])).toBeLessThan(1e-12);
|
||
expect(wCcw[0]).toBe(isCCW(polys[0]));
|
||
});
|
||
|
||
it("Offset L-Ecke (Gehrung) und Fillet rechter Winkel / kollinear→null / zu gross→null", () => {
|
||
const off = [{ pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }], d: 0.5, closed: false }];
|
||
const wOff = JSON.parse(K.offset_polyline_batch_json(JSON.stringify(off))) as Vec2[][];
|
||
const tOff = offsetPolyline(off[0].pts, off[0].d, off[0].closed);
|
||
expect(wOff[0].length).toBe(tOff.length);
|
||
tOff.forEach((p, j) => expect(closeVec(wOff[0][j], p)).toBe(true));
|
||
|
||
const fil = [
|
||
{ corner: { x: 0, y: 0 }, p1: { x: 5, y: 0 }, p2: { x: 0, y: 5 }, r: 1 }, // rechter Winkel
|
||
{ corner: { x: 0, y: 0 }, p1: { x: 1, y: 0 }, p2: { x: -1, y: 0 }, r: 0.5 }, // kollinear → null
|
||
{ corner: { x: 0, y: 0 }, p1: { x: 0.1, y: 0 }, p2: { x: 0, y: 0.1 }, r: 10 }, // zu gross → null
|
||
];
|
||
const wFil = JSON.parse(K.fillet_corner_batch_json(JSON.stringify(fil))) as (Fillet | null)[];
|
||
fil.forEach((q, i) => {
|
||
const t = filletCorner(q.corner, q.p1, q.p2, q.r);
|
||
expect(wFil[i] === null, `golden-fil#${i}`).toBe(t === null);
|
||
if (t && wFil[i]) {
|
||
expect(closeVec(wFil[i]!.center, t.center)).toBe(true);
|
||
expect(closeVec(wFil[i]!.tangentA, t.tangentA)).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
});
|