Files
DOSSIER-STANDALONE/src/geometry/kernel2d.parity.test.ts
T
karim 35299307d6 Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
2026-07-09 00:57:29 +02:00

1305 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { wallTypeThickness, type Opening, type Project, type Stair, type Wall, type WallType } from "../model/types";
import {
detectRooms,
pointInPolygon as rbPointInPolygon,
roomFromPointInside,
roomFromPointInsideFaces,
type WallSegment,
type WallFace,
} from "./roomBoundary";
import { wallReferenceOffset } from "../model/wall";
import {
doorSymbol,
openingCenter,
openingGapQuad,
openingInterval,
openingJambs,
wallAxisFrame,
wallAxisLength,
windowSymbol,
type DoorSymbol,
type WindowSymbol,
} from "./opening";
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 310 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));
}
});
});
});
// ── Slice 4: opening ──────────────────────────────────────────────────────────
// `opening.ts` ist model-gekoppelt (Wall/Opening/Project) — die geometrisch
// reinen Kerne wurden mit abgeflachter Signatur (Vec2/number) portiert
// (PORT_PLAN §1). `thickness`/`refOffset` werden hier — wie beim Rust-Port —
// bereits aufgeloest uebergeben (`wallTypeThickness`/`wallReferenceOffset`,
// nicht ueber `getWallType`/ein volles `Project` im WASM-Aufruf).
/** Zufaellige, NICHT entartete Wand (Laenge 0.5..15.5 m). */
function genWall(rng: () => number): Wall {
const start = v(rng);
const angle = rng() * Math.PI * 2;
const length = 0.5 + rng() * 15;
const end = { x: start.x + Math.cos(angle) * length, y: start.y + Math.sin(angle) * length };
const refs = ["left", "center", "right"] as const;
return {
id: "w1",
type: "wall",
floorId: "f1",
categoryCode: "20",
start,
end,
wallTypeId: "wt1",
height: 2.4,
referenceLine: refs[Math.floor(rng() * 3)],
};
}
/** Einschichtiger WallType mit zufaelliger Dicke (0.15..0.5 m). */
function genWallType(rng: () => number): WallType {
return { id: "wt1", name: "wt", layers: [{ componentId: "c1", thickness: 0.15 + rng() * 0.35 }] };
}
/** Minimales Project — `getWallType` liest nur `wallTypes`. */
function makeProject(wallType: WallType): Project {
return { wallTypes: [wallType] } as unknown as Project;
}
/** Zufaellige Oeffnung auf `wall`: position/width teils ausserhalb der Achse
* oder negativ (Strukturabdeckung fuer den null-Zweig von `openingInterval`). */
function genOpening(rng: () => number, wall: Wall, kind: "window" | "door"): Opening {
const axis = wallAxisLength(wall);
const position = (rng() * 1.6 - 0.3) * axis;
const width = rng() < 0.1 ? -0.5 + rng() * 0.5 : 0.3 + rng() * Math.max(0.3, axis);
const swings = ["left", "right"] as const;
const hinges = ["start", "end"] as const;
const dirs = ["in", "out"] as const;
return {
id: "o1",
type: "opening",
hostWallId: wall.id,
categoryCode: "21",
kind,
position,
width,
height: 1.2,
sillHeight: kind === "door" ? 0 : 0.8,
wingCount: rng() < 0.5 ? undefined : 1 + Math.floor(rng() * 4),
hinge: rng() < 0.3 ? undefined : hinges[Math.floor(rng() * 2)],
swing: rng() < 0.3 ? undefined : swings[Math.floor(rng() * 2)],
swingAngle: rng() < 0.3 ? undefined : 30 + rng() * 120,
openingDir: rng() < 0.3 ? undefined : dirs[Math.floor(rng() * 2)],
};
}
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 4: opening", () => {
it("wallAxisLength (Zufallswaende, rel 1e-9)", () => {
const rng = mulberry32(50);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_length_batch_json(JSON.stringify(qs))) as number[];
walls.forEach((wl, i) => {
expect(closeNum(w[i], wallAxisLength(wl)), `axisLen#${i}`).toBe(true);
});
});
it("openingInterval: Struktur exakt (null-Zweig inklusive) + Werte", () => {
const rng = mulberry32(51);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const w = JSON.parse(K.opening_interval_batch_json(JSON.stringify(qs))) as (
| { from: number; to: number }
| null
)[];
cases.forEach(({ wall, o }, i) => {
const t = openingInterval(wall, o);
expect(w[i] === null, `iv-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeNum(w[i]!.from, t.from), `iv-from#${i}`).toBe(true);
expect(closeNum(w[i]!.to, t.to), `iv-to#${i}`).toBe(true);
}
});
});
it("wallAxisFrame (u/n, rel 1e-9)", () => {
const rng = mulberry32(52);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_frame_batch_json(JSON.stringify(qs))) as { u: Vec2; n: Vec2 }[];
walls.forEach((wl, i) => {
const t = wallAxisFrame(wl);
expect(closeVec(w[i].u, t.u), `frame-u#${i}`).toBe(true);
expect(closeVec(w[i].n, t.n), `frame-n#${i}`).toBe(true);
});
});
it("openingJambs / openingCenter: Struktur exakt (null-Zweig) + Werte", () => {
const rng = mulberry32(53);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const wJ = JSON.parse(K.opening_jambs_batch_json(JSON.stringify(qs))) as (
| { jambStart: Vec2; jambEnd: Vec2 }
| null
)[];
const wC = JSON.parse(K.opening_center_batch_json(JSON.stringify(qs))) as (Vec2 | null)[];
cases.forEach(({ wall, o }, i) => {
const tJ = openingJambs(wall, o);
expect(wJ[i] === null, `jambs-null#${i}`).toBe(tJ === null);
if (tJ && wJ[i]) {
expect(closeVec(wJ[i]!.jambStart, tJ.jambStart), `jambs-start#${i}`).toBe(true);
expect(closeVec(wJ[i]!.jambEnd, tJ.jambEnd), `jambs-end#${i}`).toBe(true);
}
const tC = openingCenter(wall, o);
expect(wC[i] === null, `center-null#${i}`).toBe(tC === null);
if (tC && wC[i]) expect(closeVec(wC[i]!, tC), `center#${i}`).toBe(true);
});
});
it("openingGapQuad / windowSymbol: Struktur exakt + Werte (rel 1e-9)", () => {
const rng = mulberry32(54);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
const wallType = genWallType(rng);
wall.wallTypeId = wallType.id;
const project = makeProject(wallType);
const o = genOpening(rng, wall, "window");
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const glassCount = 1 + Math.floor(rng() * 3);
return { wall, o, project, total, refOffset, glassCount };
});
const qsGap = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
}));
const wGap = JSON.parse(K.opening_gap_quad_batch_json(JSON.stringify(qsGap))) as (Vec2[] | null)[];
cases.forEach((c, i) => {
const t = openingGapQuad(c.project, c.wall, c.o);
expect(wGap[i] === null, `gap-null#${i}`).toBe(t === null);
if (t && wGap[i]) expect(eqPts(wGap[i]!, t), `gap-pts#${i}`).toBe(true);
});
const qsWin = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
glassCount: c.glassCount,
wingCount: c.o.wingCount,
}));
const wWin = JSON.parse(K.window_symbol_batch_json(JSON.stringify(qsWin))) as (WindowSymbol | null)[];
cases.forEach((c, i) => {
const t = windowSymbol(c.project, c.wall, c.o, c.glassCount);
expect(wWin[i] === null, `win-null#${i}`).toBe(t === null);
if (t && wWin[i]) {
expect(eqPts(wWin[i]!.frame, t.frame), `win-frame#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.glassLines, t.glassLines), `win-glass#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.mullionLines, t.mullionLines), `win-mullion#${i}`).toBe(true);
}
});
});
it("doorSymbol: Struktur exakt + Werte (Default-Parameter inklusive)", () => {
const rng = mulberry32(55);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "door") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
swing: o.swing,
openingDir: o.openingDir,
hinge: o.hinge,
swingAngle: o.swingAngle,
}));
const w = JSON.parse(K.door_symbol_batch_json(JSON.stringify(qs))) as (DoorSymbol | null)[];
cases.forEach(({ wall, o }, i) => {
const t = doorSymbol(wall, o);
expect(w[i] === null, `door-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeVec(w[i]!.hinge, t.hinge), `door-hinge#${i}`).toBe(true);
expect(closeVec(w[i]!.openEnd, t.openEnd), `door-open#${i}`).toBe(true);
expect(closeVec(w[i]!.closedEnd, t.closedEnd), `door-closed#${i}`).toBe(true);
expect(closeNum(w[i]!.radius, t.radius), `door-radius#${i}`).toBe(true);
expect(closeVec(w[i]!.jambStart, t.jambStart), `door-jambStart#${i}`).toBe(true);
expect(closeVec(w[i]!.jambEnd, t.jambEnd), `door-jambEnd#${i}`).toBe(true);
expect(closeVec(w[i]!.normal, t.normal), `door-normal#${i}`).toBe(true);
}
});
});
it("Golden: entartetes Intervall (Breite 0 / ausserhalb der Achse) → null; wingCount-Clamp >4 → 3 Pfosten", () => {
const wall: Wall = {
id: "w",
type: "wall",
floorId: "f",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "wt",
height: 2.4,
referenceLine: "center",
};
const wallType: WallType = { id: "wt", name: "wt", layers: [{ componentId: "c", thickness: 0.24 }] };
const project = makeProject(wallType);
// Breite 0 → entartet.
const oZero: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 2,
width: 0,
height: 1.2,
sillHeight: 0.8,
};
const ivZero = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oZero.position, width: oZero.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivZero[0] === null).toBe(true);
expect(ivZero[0] === null).toBe(openingInterval(wall, oZero) === null);
// Vollstaendig ausserhalb der Achse.
const oOut: Opening = { ...oZero, position: 10, width: 1 };
const ivOut = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oOut.position, width: oOut.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivOut[0] === null).toBe(true);
expect(ivOut[0] === null).toBe(openingInterval(wall, oOut) === null);
// wingCount > 4 wird auf 4 geklemmt → 3 Mittelpfosten.
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const oWings: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 1,
width: 2,
height: 1.2,
sillHeight: 0.8,
wingCount: 9,
};
const wWin = JSON.parse(
K.window_symbol_batch_json(
JSON.stringify([
{
start: wall.start,
end: wall.end,
position: oWings.position,
width: oWings.width,
thickness: total,
refOffset,
glassCount: 1,
wingCount: oWings.wingCount,
},
]),
),
) as (WindowSymbol | null)[];
const tWin = windowSymbol(project, wall, oWings, 1);
expect(tWin).not.toBeNull();
expect(wWin[0]).not.toBeNull();
expect(wWin[0]!.mullionLines.length).toBe(tWin!.mullionLines.length);
expect(wWin[0]!.mullionLines.length).toBe(3);
expect(eqPairs(wWin[0]!.mullionLines, tWin!.mullionLines)).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);
}
});
});
});