Spaces:
Running
Running
Download packages/shared/src/rules/interpreter.ts from Bryan-HSLU/FP_POC: direct link, hf CLI and curl.
- Browser
- Download file 22.5 kB
-
https://huggingface.co/spaces/Bryan-HSLU/FP_POC/resolve/main/packages/shared/src/rules/interpreter.ts
- Command line
-
hf download hf://spaces/Bryan-HSLU/FP_POC/packages/shared/src/rules/interpreter.ts
-
curl -L -o interpreter.ts https://huggingface.co/spaces/Bryan-HSLU/FP_POC/resolve/main/packages/shared/src/rules/interpreter.ts
22.5 kB
| /** | |
| * Deklarativer Regel-Interpreter (TS-Seite, Live-Feedback im Viewer). | |
| * | |
| * Liest dasselbe Regel-JSON (data/rules/) wie der Python-Interpreter und | |
| * füllt den constraintReport (Plan-Schema): je Regel Status der | |
| * Konfidenz-Ampel ok | knapp | verletzt | nicht-geprueft plus Marge in cm. | |
| * «knapp» = erfüllt, aber Marge < Messunsicherheit (estimatedError_cm; | |
| * 0 nach Nutzerbestätigung) → speist den Next-Steps-Leitfaden. | |
| * | |
| * ⚠️ PARITÄT: 1:1 gespiegelt in fp_engines/rules/interpreter.py. Jede | |
| * Änderung beidseitig + Fixtures aktualisieren (goldener Paritätstest). | |
| */ | |
| import { | |
| containmentViolation, | |
| distPointToSegment, | |
| frontDir, | |
| pointInPolygon, | |
| quadDistance, | |
| roundCm, | |
| separation, | |
| type Quad, | |
| type Vec2, | |
| } from "./geometry.ts"; | |
| import type { Scene, SceneObject } from "./scene.ts"; | |
| export interface Rule { | |
| id: string; | |
| roomType: string; | |
| appliesTo: string; | |
| type: string; | |
| severity: string; | |
| params?: Record<string, number | string>; | |
| normProfile?: string; | |
| profilOverrides?: Record<string, Record<string, number | string>>; | |
| hinweis?: string; | |
| } | |
| export interface RuleResult { | |
| ruleId: string; | |
| status: "ok" | "knapp" | "verletzt" | "nicht-geprueft"; | |
| margin_cm: number | null; | |
| placements: string[]; | |
| hinweis?: string; | |
| } | |
| export interface ConstraintReport { | |
| hard: { ok: boolean; summary: { erfuellt: number; knapp: number; verletzt: number } }; | |
| results: RuleResult[]; | |
| softScore: { stil: number; ergonomie: number; relation: number }; | |
| } | |
| const num = (params: Record<string, number | string>, key: string, fallback: number): number => { | |
| const v = params[key]; | |
| return typeof v === "number" ? v : fallback; | |
| }; | |
| const str = (params: Record<string, number | string>, key: string, fallback: string): string => { | |
| const v = params[key]; | |
| return typeof v === "string" ? v : fallback; | |
| }; | |
| function effectiveParams(rule: Rule, normProfile: string): Record<string, number | string> { | |
| // Normprofil-Overlay: Profilwerte überschreiben die Basis (Norm-Regelsatz-v0). | |
| return { ...(rule.params ?? {}), ...(rule.profilOverrides?.[normProfile] ?? {}) }; | |
| } | |
| function matches(obj: SceneObject, appliesTo: string): boolean { | |
| return appliesTo === "*" || obj.funktionsTyp === appliesTo; | |
| } | |
| function massiveWalls(scene: Scene): Scene["walls"] { | |
| return scene.walls.filter((w) => w.kind === "massiv"); | |
| } | |
| /** Rechteck-Zone vor der Front eines Objekts (depth tief, width breit, anliegend). */ | |
| function frontZone(obj: SceneObject, depth: number, width: number): Quad { | |
| const f = frontDir(obj.yawDeg); | |
| // Rechtsvektor = Front um −90° gedreht: (f.z, −f.x) | |
| const rx = f[1]; | |
| const rz = -f[0]; | |
| const frontCenter: Vec2 = [ | |
| obj.center[0] + (f[0] * obj.d) / 2, | |
| obj.center[1] + (f[1] * obj.d) / 2, | |
| ]; | |
| const hw = width / 2; | |
| const c1: Vec2 = [frontCenter[0] - rx * hw, frontCenter[1] - rz * hw]; | |
| const c2: Vec2 = [frontCenter[0] + rx * hw, frontCenter[1] + rz * hw]; | |
| const c3: Vec2 = [c2[0] + f[0] * depth, c2[1] + f[1] * depth]; | |
| const c4: Vec2 = [c1[0] + f[0] * depth, c1[1] + f[1] * depth]; | |
| return [c1, c2, c3, c4]; | |
| } | |
| /** Zone vor einer Öffnung, ins Rauminnere extrudiert (Seite via Polygon-Test). */ | |
| function openingZone(scene: Scene, opening: Scene["openings"][number], depth: number): Quad | null { | |
| const wall = scene.walls.find((w) => w.id === opening.hostWall); | |
| if (!wall) return null; | |
| const dx = wall.end[0] - wall.start[0]; | |
| const dz = wall.end[1] - wall.start[1]; | |
| const len = Math.sqrt(dx * dx + dz * dz); | |
| if (len === 0) return null; | |
| const ux = dx / len; | |
| const uz = dz / len; | |
| const a: Vec2 = [wall.start[0] + ux * opening.offset, wall.start[1] + uz * opening.offset]; | |
| const b: Vec2 = [a[0] + ux * opening.width, a[1] + uz * opening.width]; | |
| for (const sgn of [1, -1]) { | |
| const nx = -uz * sgn; | |
| const nz = ux * sgn; | |
| const quad: Quad = [ | |
| a, | |
| b, | |
| [b[0] + nx * depth, b[1] + nz * depth], | |
| [a[0] + nx * depth, a[1] + nz * depth], | |
| ]; | |
| const cx = (quad[0][0] + quad[2][0]) / 2; | |
| const cz = (quad[0][1] + quad[2][1]) / 2; | |
| if (pointInPolygon([cx, cz], scene.floor)) return quad; | |
| } | |
| return null; | |
| } | |
| /** Marge einer Zone: muss im Raum liegen und frei von anderen Objekten sein. */ | |
| function zoneMargin( | |
| scene: Scene, | |
| zone: Quad, | |
| excludeIds: string[], | |
| offenders: string[], | |
| thresholdM: number, | |
| heightFilter: ((o: SceneObject) => boolean) | null = null, | |
| ): number | null { | |
| let margin: number | null = null; | |
| const contain = containmentViolation(zone, scene.floor); | |
| if (contain < 0) margin = contain; | |
| for (const o of scene.objects) { | |
| if (excludeIds.includes(o.id)) continue; | |
| if (heightFilter && !heightFilter(o)) continue; | |
| const sep = separation(zone, o.quad); | |
| if (margin === null || sep < margin) margin = sep; | |
| if (sep < thresholdM && o.isPlacement && !offenders.includes(o.id)) offenders.push(o.id); | |
| } | |
| return margin; | |
| } | |
| type Evaluator = ( | |
| scene: Scene, | |
| params: Record<string, number | string>, | |
| appliesTo: string, | |
| offenders: string[], | |
| ) => number | null; | |
| /** Wandobjekte über Bodenobjekten (Spiegel über Lavabo) kollidieren nicht: | |
| * Kollision nur, wenn sich die Höhenintervalle [unterkante, oberkante] | |
| * überlappen (Berührung = getrennt). */ | |
| function vertikalGetrennt(a: SceneObject, b: SceneObject): boolean { | |
| const aLo = a.mountHeight ?? 0; | |
| const bLo = b.mountHeight ?? 0; | |
| return aLo + a.h <= bLo || bLo + b.h <= aLo; | |
| } | |
| // --- Verkehrsweg-Freiraum (circulation), 1:1 zu interpreter.py --------------- | |
| // Ganzzahlige Raster/Erosion-Analyse → bit-identisch zu Python. Float nur bei | |
| // Zell-Mittelpunkten und der Schluss-Marge (gleiche Formeln/Reihenfolge). | |
| const CIRC_CELL = 0.05; // Rasterweite (m). Läuft NICHT im Solver-Hot-Path (nur_hart), daher fein. | |
| const CIRC_MAX_CELLS = 20000; // Schutz: riesige Räume vergröbern statt Speicher sprengen. | |
| // Feste Nachbarschaftsreihenfolge – Determinismus/Parität (BFS-Erst-Treffer!). | |
| const CIRC_NB: [number, number][] = [ | |
| [1, 0], | |
| [-1, 0], | |
| [0, 1], | |
| [0, -1], | |
| ]; | |
| const evaluators: Record<string, Evaluator | null> = { | |
| collision(scene, _params, _appliesTo, offenders) { | |
| let margin: number | null = null; | |
| const track = (value: number, ids: SceneObject[], thresholdM: number): void => { | |
| if (margin === null || value < margin) margin = value; | |
| if (value < thresholdM) { | |
| for (const o of ids) { | |
| if (o.isPlacement && !offenders.includes(o.id)) offenders.push(o.id); | |
| } | |
| } | |
| }; | |
| const thresholdM = sceneThreshold(scene); | |
| for (const o of scene.objects) { | |
| const contain = containmentViolation(o.quad, scene.floor); | |
| if (contain < 0) track(contain, [o], thresholdM); | |
| } | |
| for (let i = 0; i < scene.objects.length; i++) { | |
| for (let j = i + 1; j < scene.objects.length; j++) { | |
| const a = scene.objects[i] as SceneObject; | |
| const b = scene.objects[j] as SceneObject; | |
| if (vertikalGetrennt(a, b)) continue; | |
| track(separation(a.quad, b.quad), [a, b], thresholdM); | |
| } | |
| } | |
| return margin; | |
| }, | |
| "wall-distance"(scene, params, appliesTo, offenders) { | |
| const minDist = num(params, "minDist", 0); | |
| let margin: number | null = null; | |
| for (const o of scene.objects.filter((x) => matches(x, appliesTo))) { | |
| let dist = Infinity; | |
| for (const w of massiveWalls(scene)) { | |
| const d = distPointToSegment(o.center, w.start, w.end); | |
| if (d < dist) dist = d; | |
| } | |
| if (dist === Infinity) continue; | |
| const m = dist - minDist; | |
| if (margin === null || m < margin) margin = m; | |
| if (m < sceneThreshold(scene) && o.isPlacement) offenders.push(o.id); | |
| } | |
| return margin; | |
| }, | |
| "object-distance"(scene, params, appliesTo, offenders) { | |
| const minDist = num(params, "minDist", 0); | |
| const target = str(params, "target", "*"); | |
| const measure = str(params, "measure", "edge"); | |
| let margin: number | null = null; | |
| for (const a of scene.objects.filter((x) => matches(x, appliesTo))) { | |
| for (const b of scene.objects.filter((x) => matches(x, target))) { | |
| if (a.id === b.id) continue; | |
| const dist = | |
| measure === "center" | |
| ? Math.sqrt((a.center[0] - b.center[0]) ** 2 + (a.center[1] - b.center[1]) ** 2) | |
| : quadDistance(a.quad, b.quad); | |
| const m = dist - minDist; | |
| if (margin === null || m < margin) margin = m; | |
| if (m < sceneThreshold(scene)) { | |
| for (const o of [a, b]) { | |
| if (o.isPlacement && !offenders.includes(o.id)) offenders.push(o.id); | |
| } | |
| } | |
| } | |
| } | |
| return margin; | |
| }, | |
| clearance(scene, params, appliesTo, offenders) { | |
| const depth = num(params, "depth", 0.6); | |
| let margin: number | null = null; | |
| for (const o of scene.objects.filter((x) => matches(x, appliesTo))) { | |
| const width = num(params, "width", o.w); | |
| const zone = frontZone(o, depth, width); | |
| const m = zoneMargin(scene, zone, [o.id], offenders, sceneThreshold(scene)); | |
| if (m !== null && (margin === null || m < margin)) margin = m; | |
| if (m !== null && m < sceneThreshold(scene) && o.isPlacement && !offenders.includes(o.id)) { | |
| offenders.push(o.id); | |
| } | |
| } | |
| return margin; | |
| }, | |
| "door-swing"(scene, params, _appliesTo, offenders) { | |
| const radius = num(params, "radius", 0.9); | |
| let margin: number | null = null; | |
| for (const opening of scene.openings.filter((o) => o.type === "door")) { | |
| // v0-Näherung: Rechteck Türbreite × radius statt Viertelkreis (konservativ, | |
| // identisch einfach in TS & Python). Echte Schwenkrichtung kommt mit M3. | |
| const zone = openingZone(scene, opening, radius); | |
| if (!zone) continue; | |
| const m = zoneMargin(scene, zone, [], offenders, sceneThreshold(scene)); | |
| if (m !== null && (margin === null || m < margin)) margin = m; | |
| } | |
| return margin; | |
| }, | |
| "keep-clear"(scene, params, _appliesTo, offenders) { | |
| const depth = num(params, "depth", 0.3); | |
| const maxH = num(params, "maxObjektHoehe", 0.9); | |
| let margin: number | null = null; | |
| for (const opening of scene.openings.filter((o) => o.type === "window")) { | |
| const zone = openingZone(scene, opening, depth); | |
| if (!zone) continue; | |
| // Nur hohe Objekte verstellen ein Fenster: Gesamthöhe = Montagehöhe + Korpus. | |
| const m = zoneMargin( | |
| scene, | |
| zone, | |
| [], | |
| offenders, | |
| sceneThreshold(scene), | |
| (o) => (o.mountHeight ?? 0) + o.h > maxH, | |
| ); | |
| if (m !== null && (margin === null || m < margin)) margin = m; | |
| } | |
| return margin; | |
| }, | |
| "host-binding"(scene, params, appliesTo, offenders) { | |
| const mount = str(params, "mount", "boden"); | |
| let margin: number | null = null; | |
| for (const o of scene.objects.filter((x) => matches(x, appliesTo))) { | |
| let m: number | null = null; | |
| if (mount === "wand") { | |
| let dist = Infinity; | |
| for (const w of massiveWalls(scene)) { | |
| let d = Infinity; | |
| for (let i = 0; i < 4; i++) { | |
| const corner = o.quad[i] as Vec2; | |
| const dd = distPointToSegment(corner, w.start, w.end); | |
| if (dd < d) d = dd; | |
| } | |
| if (d < dist) dist = d; | |
| } | |
| const maxGap = num(params, "maxWandabstand", 0.05); | |
| m = dist === Infinity ? -maxGap : maxGap - dist; | |
| } | |
| // Höhenfenster (z.B. Lavabo-Oberkante 0.85–0.95): mountHeight = UNTERKANTE | |
| // über Boden (Plan-Schema) → geprüfte Oberkante = mountHeight + Korpushöhe. | |
| const minH = params["minHoehe"]; | |
| const maxH = params["maxHoehe"]; | |
| if (typeof minH === "number" && typeof maxH === "number") { | |
| const hk = o.mountHeight; | |
| const hm = hk === null ? -minH : Math.min(hk + o.h - minH, maxH - (hk + o.h)); | |
| m = m === null ? hm : Math.min(m, hm); | |
| } | |
| if (m !== null) { | |
| if (margin === null || m < margin) margin = m; | |
| if (m < sceneThreshold(scene) && o.isPlacement) offenders.push(o.id); | |
| } | |
| } | |
| return margin; | |
| }, | |
| connection(scene, params, appliesTo, offenders) { | |
| const typ = str(params, "anschluss", "wasser"); | |
| const maxDist = num(params, "maxDist", 1.0); | |
| let margin: number | null = null; | |
| for (const o of scene.objects.filter((x) => matches(x, appliesTo))) { | |
| let dist = Infinity; | |
| for (const fp of scene.fixpoints.filter((f) => f.type === typ)) { | |
| // Punkt-zu-Footprint: 0, wenn der Fixpunkt im/auf dem Footprint liegt. | |
| let d: number; | |
| if (pointInPolygon(fp.position, o.quad)) { | |
| d = 0; | |
| } else { | |
| d = Infinity; | |
| for (let i = 0; i < 4; i++) { | |
| const dd = distPointToSegment( | |
| fp.position, | |
| o.quad[i] as Vec2, | |
| o.quad[(i + 1) % 4] as Vec2, | |
| ); | |
| if (dd < d) d = dd; | |
| } | |
| } | |
| if (d < dist) dist = d; | |
| } | |
| const m = dist === Infinity ? -maxDist : maxDist - dist; | |
| if (margin === null || m < margin) margin = m; | |
| if (m < sceneThreshold(scene) && o.isPlacement) offenders.push(o.id); | |
| } | |
| return margin; | |
| }, | |
| // Verkehrsweg-Freiraum v0 (Grid/Erosion) – 1:1-Spiegel von | |
| // fp_engines/rules/interpreter._eval_circulation (siehe Docstring dort). | |
| // Ganzzahlig (Grid/BFS/Union-Find) → bit-identisches Urteil zu Python. | |
| // v0 ohne Verursacher-Zuordnung → nur scene + params (Evaluator-Typ erlaubt weniger Args). | |
| circulation(scene, params) { | |
| const minWidth = num(params, "minWidth", 0.9); | |
| const floor = scene.floor; | |
| const xs = floor.map((p) => p[0]); | |
| const zs = floor.map((p) => p[1]); | |
| const x0 = Math.min(...xs); | |
| const x1 = Math.max(...xs); | |
| const z0 = Math.min(...zs); | |
| const z1 = Math.max(...zs); | |
| let cell = CIRC_CELL; | |
| let nx = Math.floor((x1 - x0) / cell) + 1; | |
| let nz = Math.floor((z1 - z0) / cell) + 1; | |
| while (nx * nz > CIRC_MAX_CELLS) { | |
| cell *= 2; | |
| nx = Math.floor((x1 - x0) / cell) + 1; | |
| nz = Math.floor((z1 - z0) / cell) + 1; | |
| } | |
| const boxes: [number, number, number, number][] = []; | |
| for (const o of scene.objects) { | |
| if (o.mount === "wand") continue; | |
| const qx = o.quad.map((c) => c[0]); | |
| const qz = o.quad.map((c) => c[1]); | |
| boxes.push([Math.min(...qx), Math.max(...qx), Math.min(...qz), Math.max(...qz)]); | |
| } | |
| const cx = (i: number): number => x0 + (i + 0.5) * cell; | |
| const cz = (j: number): number => z0 + (j + 0.5) * cell; | |
| // Flache typisierte Arrays (Index k = i*nz+j); Werte identisch zur 2D-Liste | |
| // in interpreter.py, nur TS-ergonomischer (Element-Typ number/boolean). | |
| const idx = (i: number, j: number): number => i * nz + j; | |
| const free = new Uint8Array(nx * nz); | |
| let nFree = 0; | |
| for (let i = 0; i < nx; i++) { | |
| const px = cx(i); | |
| for (let j = 0; j < nz; j++) { | |
| const pz = cz(j); | |
| if (!pointInPolygon([px, pz], floor)) continue; | |
| let blocked = false; | |
| for (const [xa, xb, za, zb] of boxes) { | |
| if (xa <= px && px <= xb && za <= pz && pz <= zb) { | |
| blocked = true; | |
| break; | |
| } | |
| } | |
| if (!blocked) { | |
| free[idx(i, j)] = 1; | |
| nFree++; | |
| } | |
| } | |
| } | |
| if (nFree === 0) return null; | |
| const inf = nx + nz + 1; | |
| const dist = new Int32Array(nx * nz); | |
| const queue: number[] = []; | |
| for (let i = 0; i < nx; i++) { | |
| for (let j = 0; j < nz; j++) { | |
| const k = idx(i, j); | |
| if (free[k]) dist[k] = inf; | |
| else queue.push(k); | |
| } | |
| } | |
| let head = 0; | |
| while (head < queue.length) { | |
| const c = queue[head] as number; | |
| head++; | |
| const i = Math.floor(c / nz); | |
| const j = c % nz; | |
| for (const [di, dj] of CIRC_NB) { | |
| const ni = i + di; | |
| const nj = j + dj; | |
| if (ni >= 0 && ni < nx && nj >= 0 && nj < nz) { | |
| const nk = idx(ni, nj); | |
| const dc = dist[c] as number; | |
| if ((dist[nk] as number) > dc + 1) { | |
| dist[nk] = dc + 1; | |
| queue.push(nk); | |
| } | |
| } | |
| } | |
| } | |
| const cellAt = (px: number, pz: number): number => { | |
| let ci = Math.floor((px - x0) / cell); | |
| let cj = Math.floor((pz - z0) / cell); | |
| ci = ci < 0 ? 0 : ci > nx - 1 ? nx - 1 : ci; | |
| cj = cj < 0 ? 0 : cj > nz - 1 ? nz - 1 : cj; | |
| return idx(ci, cj); | |
| }; | |
| const nearestFree = (px: number, pz: number): number | null => { | |
| const start = cellAt(px, pz); | |
| if (free[start]) return start; | |
| const seen = new Set<number>([start]); | |
| const bfs: number[] = [start]; | |
| let h = 0; | |
| while (h < bfs.length) { | |
| const c = bfs[h] as number; | |
| h++; | |
| const i = Math.floor(c / nz); | |
| const j = c % nz; | |
| for (const [di, dj] of CIRC_NB) { | |
| const ni = i + di; | |
| const nj = j + dj; | |
| if (ni >= 0 && ni < nx && nj >= 0 && nj < nz) { | |
| const nk = idx(ni, nj); | |
| if (!seen.has(nk)) { | |
| if (free[nk]) return nk; | |
| seen.add(nk); | |
| bfs.push(nk); | |
| } | |
| } | |
| } | |
| } | |
| return null; | |
| }; | |
| const doors = scene.openings.filter((o) => o.type === "door"); | |
| const anchors: number[] = []; | |
| for (const d of doors) { | |
| const wall = scene.walls.find((w) => w.id === d.hostWall); | |
| if (!wall) continue; | |
| const sx = wall.start[0]; | |
| const sz = wall.start[1]; | |
| const ex = wall.end[0]; | |
| const ez = wall.end[1]; | |
| const wl = Math.sqrt((ex - sx) ** 2 + (ez - sz) ** 2); | |
| if (wl === 0) continue; | |
| const ux = (ex - sx) / wl; | |
| const uz = (ez - sz) / wl; | |
| const mid = d.offset + d.width / 2; | |
| const mx = sx + ux * mid; | |
| const mz = sz + uz * mid; | |
| // Anker ins KORRIDOR-INNERE schieben (~minWidth/2), vorbei am Türpfosten: | |
| // misst den Innenraum-Korridor, nicht die fixe Türbreite (1:1 zu Python). | |
| for (const sgn of [1, -1]) { | |
| const innenX = -uz * sgn; | |
| const innenZ = ux * sgn; | |
| if (pointInPolygon([mx + innenX * 0.1, mz + innenZ * 0.1], floor)) { | |
| const a = nearestFree(mx + innenX * (minWidth / 2), mz + innenZ * (minWidth / 2)); | |
| if (a !== null) anchors.push(a); | |
| break; | |
| } | |
| } | |
| } | |
| if (doors.length === 1) { | |
| let best: number | null = null; | |
| let bestC = -1; | |
| for (let i = 0; i < nx; i++) { | |
| for (let j = 0; j < nz; j++) { | |
| const k = idx(i, j); | |
| const clearance = dist[k] as number; | |
| if (free[k] && clearance > bestC) { | |
| bestC = clearance; | |
| best = k; | |
| } | |
| } | |
| } | |
| if (best !== null) anchors.push(best); | |
| } | |
| if (anchors.length < 2) return null; | |
| let maxLvl = 0; | |
| const buckets: number[][] = []; | |
| for (let i = 0; i < nx; i++) { | |
| for (let j = 0; j < nz; j++) { | |
| const k = idx(i, j); | |
| if (free[k]) { | |
| const lvl = dist[k] as number; | |
| (buckets[lvl] ??= []).push(k); | |
| if (lvl > maxLvl) maxLvl = lvl; | |
| } | |
| } | |
| } | |
| const parent = new Map<number, number>(); | |
| const find = (x: number): number => { | |
| let r = x; | |
| while ((parent.get(r) as number) !== r) { | |
| parent.set(r, parent.get(parent.get(r) as number) as number); | |
| r = parent.get(r) as number; | |
| } | |
| return r; | |
| }; | |
| const active = new Set<number>(); | |
| let bottleneck = -1; | |
| for (let lvl = maxLvl; lvl >= 0; lvl--) { | |
| for (const c of buckets[lvl] ?? []) { | |
| parent.set(c, c); | |
| active.add(c); | |
| const ci = Math.floor(c / nz); | |
| const cj = c % nz; | |
| for (const [di, dj] of CIRC_NB) { | |
| const ni = ci + di; | |
| const nj = cj + dj; | |
| if (ni >= 0 && ni < nx && nj >= 0 && nj < nz) { | |
| const nb = idx(ni, nj); | |
| if (active.has(nb)) { | |
| const ra = find(c); | |
| const rb = find(nb); | |
| if (ra !== rb) parent.set(ra, rb); | |
| } | |
| } | |
| } | |
| } | |
| if (anchors.every((a) => active.has(a)) && new Set(anchors.map((a) => find(a))).size === 1) { | |
| bottleneck = lvl; | |
| break; | |
| } | |
| } | |
| if (bottleneck < 0) return -minWidth; | |
| return 2 * bottleneck * cell - minWidth; | |
| }, | |
| relation: null, | |
| }; | |
| /** Schwelle in Metern, unterhalb derer Beteiligte als «offenders» gelistet werden. */ | |
| function sceneThreshold(scene: Scene): number { | |
| return scene.margeCm / 100; | |
| } | |
| export function evaluateRules(scene: Scene, rules: Rule[]): ConstraintReport { | |
| const results: RuleResult[] = []; | |
| for (const rule of rules) { | |
| if (rule.roomType !== "alle" && rule.roomType !== scene.roomType) continue; | |
| if (rule.normProfile && rule.normProfile !== scene.normProfile) continue; | |
| const evaluator = evaluators[rule.type]; | |
| if (evaluator === undefined) throw new Error(`Unbekannter Regel-Typ: ${rule.type}`); | |
| if (evaluator === null) { | |
| results.push({ | |
| ruleId: rule.id, | |
| status: "nicht-geprueft", | |
| margin_cm: null, | |
| placements: [], | |
| ...(rule.hinweis !== undefined && { hinweis: rule.hinweis }), | |
| }); | |
| continue; | |
| } | |
| const offenders: string[] = []; | |
| const marginM = evaluator( | |
| scene, | |
| effectiveParams(rule, scene.normProfile), | |
| rule.appliesTo, | |
| offenders, | |
| ); | |
| let status: RuleResult["status"]; | |
| let marginCm: number | null = null; | |
| if (marginM === null) { | |
| status = "ok"; // Regel hat keine anwendbaren Objekte → trivial erfüllt. | |
| } else { | |
| marginCm = roundCm(marginM); | |
| if (marginCm < 0) status = "verletzt"; | |
| else if (marginCm < scene.margeCm) status = "knapp"; | |
| else status = "ok"; | |
| } | |
| results.push({ | |
| ruleId: rule.id, | |
| status, | |
| margin_cm: marginCm, | |
| placements: status === "ok" ? [] : offenders, | |
| ...(rule.hinweis !== undefined && { hinweis: rule.hinweis }), | |
| }); | |
| } | |
| const hardIds = new Set(rules.filter((r) => r.severity === "hard").map((r) => r.id)); | |
| const summary = { erfuellt: 0, knapp: 0, verletzt: 0 }; | |
| for (const r of results) { | |
| if (!hardIds.has(r.ruleId)) continue; | |
| if (r.status === "ok") summary.erfuellt += 1; | |
| else if (r.status === "knapp") summary.knapp += 1; | |
| else if (r.status === "verletzt") summary.verletzt += 1; | |
| } | |
| return { | |
| hard: { ok: summary.verletzt === 0, summary }, | |
| results, | |
| softScore: { stil: 0, ergonomie: 0, relation: 0 }, | |
| }; | |
| } | |