"""Solver-Invariante ⭐ (Property-Test, Engineering-Grundlagen §3): Jeder gelieferte Plan hat 0 ❌ im constraintReport – über viele Seeds und beide Test-Räume (Sample-Bad + echtes L-WC R1). Zusätzlich: jeder Plan validiert gegen das Plan-Schema (Vertrag 2) und ist seed-deterministisch. """ import copy import json import math from pathlib import Path from typing import Any import pytest from jsonschema import Draft202012Validator, FormatChecker from referencing import Registry, Resource from fp_engines.baseline import baseline_auswahl from fp_engines.rules.geometry import footprint, overlap_depth from fp_engines.solver import NoFeasiblePlacement, _tuer_korridore, solve REPO_ROOT = Path(__file__).resolve().parents[3] FIXTURES = REPO_ROOT / "packages" / "shared" / "fixtures" / "artefakte" SCHEMAS = REPO_ROOT / "packages" / "shared" / "schemas" def _load(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) def _rules(room_type: str) -> Any: return _load(REPO_ROOT / "data" / "rules" / "basis.json") + _load( REPO_ROOT / "data" / "rules" / f"{room_type}.json" ) def _catalog(room_type: str) -> Any: return _load(REPO_ROOT / "data" / "catalog" / f"{room_type}.json") CATALOG = _catalog("bad") RULES = _rules("bad") # (room_name, roomType) – Bad-Räume + Wohnzimmer (M5, freie Boden-Platzierung). ROOMS = ["raummodell.bad-sample", "raummodell.r1-wc"] ALLE_RAEUME = [ ("raummodell.bad-sample", "bad"), ("raummodell.r1-wc", "bad"), ("raummodell.wohnen-sample", "wohnen"), ] # Registry über ALLE Schemas: plan.schema.json referenziert seit Welle 3 # katalog-item.schema.json#/$defs/farbSlug (Cross-File-$ref) – der Validator muss # die anderen Verträge kennen, um ihn aufzulösen (Muster aus test_schemas.py). _REGISTRY = Registry().with_resources( (f.name, Resource.from_contents(_load(f))) for f in SCHEMAS.glob("*.schema.json") ) PLAN_VALIDATOR = Draft202012Validator( _load(SCHEMAS / "plan.schema.json"), registry=_REGISTRY, format_checker=FormatChecker(), ) def _solve(room_name: str, seed: int, room_type: str = "bad") -> dict[str, Any]: room = _load(FIXTURES / f"{room_name}.json") catalog = _catalog(room_type) rules = _rules(room_type) sel = baseline_auswahl(room, catalog) return solve( room, sel["auswahl"], sel["relationaleAbsichten"], catalog, rules, seed=seed, created_at="2026-06-11T12:00:00Z", ) @pytest.mark.parametrize(("room_name", "room_type"), ALLE_RAEUME) @pytest.mark.parametrize("seed", range(12)) def test_solver_invariante_0_verletzt_alle_raeume( room_name: str, room_type: str, seed: int ) -> None: plan = _solve(room_name, seed, room_type) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 assert plan["constraintReport"]["hard"]["ok"] is True @pytest.mark.parametrize("room_name", ROOMS) @pytest.mark.parametrize("seed", range(12)) def test_solver_invariante_0_verletzt(room_name: str, seed: int) -> None: plan = _solve(room_name, seed) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 assert plan["constraintReport"]["hard"]["ok"] is True @pytest.mark.parametrize("room_name", ROOMS) def test_plan_validiert_gegen_schema(room_name: str) -> None: plan = _solve(room_name, 1) errors = [f"{e.json_path}: {e.message}" for e in PLAN_VALIDATOR.iter_errors(plan)] assert errors == [] def test_determinismus_gleicher_seed_gleicher_plan() -> None: assert _solve("raummodell.bad-sample", 5) == _solve("raummodell.bad-sample", 5) def test_varianten_verschiedene_seeds() -> None: """«Würfeln»: verschiedene Seeds liefern (im grossen Raum) verschiedene Posen.""" posen = { json.dumps([p["pose"] for p in _solve("raummodell.bad-sample", s)["placements"]]) for s in range(6) } assert len(posen) > 1 def test_farben_durchgereicht_bis_placement() -> None: """Welle 3: `farben` (itemId→Slug) landet als `placement.farbe` im Plan – nur für gefärbte Items, Rest bleibt ohne Feld; Plan bleibt schema-valide.""" room = _load(FIXTURES / "raummodell.bad-sample.json") sel = baseline_auswahl(room, CATALOG) ziel_id = sel["auswahl"][0] slug = next(c for c in CATALOG if c["id"] == ziel_id)["farbVarianten"][0] plan = solve( room, sel["auswahl"], sel["relationaleAbsichten"], CATALOG, RULES, seed=1, farben={ziel_id: slug}, created_at="2026-06-11T12:00:00Z", ) gefaerbt = [p for p in plan["placements"] if p["catalogItemId"] == ziel_id] assert gefaerbt and all(p["farbe"] == slug for p in gefaerbt) # Nur das gewählte Item trägt eine Farbe – die übrigen bleiben ohne Feld. assert all("farbe" not in p for p in plan["placements"] if p["catalogItemId"] != ziel_id) assert [e.message for e in PLAN_VALIDATOR.iter_errors(plan)] == [] # Ohne farben bleibt jedes placement farblos (unveränderter Pfad). ohne = _solve("raummodell.bad-sample", 1) assert all("farbe" not in p for p in ohne["placements"]) def test_p1_pflicht_im_grossen_bad_komplett() -> None: plan = _solve("raummodell.bad-sample", 1) typen = { next(c["funktionsTyp"] for c in CATALOG if c["id"] == p["catalogItemId"]) for p in plan["placements"] } assert {"wc", "lavabo", "dusche"} <= typen def test_no_feasible_placement_ist_ehrlich() -> None: """Auswahl, die nie passt (Dusche im 1.56-m²-WC) → Exception statt Regelbruch.""" room = _load(FIXTURES / "raummodell.r1-wc.json") dusche = next(c["id"] for c in CATALOG if c["funktionsTyp"] == "dusche") with pytest.raises(NoFeasiblePlacement): solve(room, [dusche], [], CATALOG, RULES, seed=1) # --- M5 Wohnen: freie Boden-Platzierung ------------------------------------- WOHNEN_CATALOG = _catalog("wohnen") def _typ_von(plan: dict[str, Any], catalog: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: by_id = {c["id"]: c for c in catalog} return {by_id[p["catalogItemId"]]["funktionsTyp"]: p for p in plan["placements"]} def test_wohnen_p1_kernmoebel_platziert() -> None: """Sofa + Esstisch + TV-Möbel landen im Plan (P1-Kern Wohnzimmer).""" plan = _solve("raummodell.wohnen-sample", 1, "wohnen") typen = set(_typ_von(plan, WOHNEN_CATALOG)) assert {"sofa", "esstisch", "tvmoebel"} <= typen def test_wohnen_freie_platzierung_findet_statt() -> None: """Beweis, dass NICHT alles wandgesnapped ist: der Couchtisch steht frei im Raum (Abstand zur nächsten Wand > 0.3 m) ODER ist gemäss Relation nah am Sofa – beides nur dank der Boden-Kandidaten (vorher nur Wand möglich).""" from fp_engines.rules.geometry import dist_point_to_polygon_boundary room = _load(FIXTURES / "raummodell.wohnen-sample.json") floor = [tuple(p) for p in room["shell"]["floor"]["polygon"]] # Seed 0 platziert den Couchtisch deterministisch frei vor dem Sofa. plan = _solve("raummodell.wohnen-sample", 0, "wohnen") posen = _typ_von(plan, WOHNEN_CATALOG) assert "couchtisch" in posen, "Couchtisch sollte bei diesem Seed platziert sein" ct = posen["couchtisch"]["pose"]["pos"] wandabstand = dist_point_to_polygon_boundary((ct[0], ct[1]), floor) sofa = posen["sofa"]["pose"]["pos"] center_dist = ((ct[0] - sofa[0]) ** 2 + (ct[1] - sofa[1]) ** 2) ** 0.5 assert wandabstand > 0.3 or center_dist <= 1.3 # --- Erweiterte Relations-Grammatik (weiche Constraints) -------------------- _WOHNEN_ROOM = _load(FIXTURES / "raummodell.wohnen-sample.json") _WOHNEN_RULES = _rules("wohnen") _SOFA = "bbbbbbbb-0001-4000-8000-000000000001" # P1, boden _COUCHTISCH = "bbbbbbbb-0009-4000-8000-000000000009" # P2, boden _SIDEBOARD = "bbbbbbbb-0015-4000-8000-000000000015" # P2, boden, d 0.42 def _solve_wohnen(auswahl: list[str], absichten: list[dict[str, Any]], seed: int) -> dict[str, Any]: return solve( _WOHNEN_ROOM, auswahl, absichten, WOHNEN_CATALOG, _WOHNEN_RULES, seed=seed, created_at="2026-06-11T12:00:00Z", ) def _pos(plan: dict[str, Any], item_id: str) -> tuple[float, float]: pl = next(p for p in plan["placements"] if p["catalogItemId"] == item_id) return (pl["pose"]["pos"][0], pl["pose"]["pos"][1]) def _yaw(plan: dict[str, Any], item_id: str) -> float: pl = next(p for p in plan["placements"] if p["catalogItemId"] == item_id) return float(pl["pose"]["yawDeg"]) def test_against_wall_platziert_an_wand() -> None: """against-wall ⇒ das Boden-Item steht mit dem Rücken an einer Wand.""" from fp_engines.rules.geometry import dist_point_to_polygon_boundary plan = _solve_wohnen([_SIDEBOARD], [{"itemId": _SIDEBOARD, "relation": "against-wall"}], 1) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 pos = _pos(plan, _SIDEBOARD) floor = [tuple(p) for p in _WOHNEN_ROOM["shell"]["floor"]["polygon"]] tiefe = next(c["masse"]["d"] for c in WOHNEN_CATALOG if c["id"] == _SIDEBOARD) # Wand-Kandidat: Zentrum liegt halbe Tiefe von der Wand (+ Snap-Toleranz). assert dist_point_to_polygon_boundary(pos, floor) <= tiefe / 2 + 0.06 def test_facing_richtet_front_zum_ziel() -> None: """facing:sofa ⇒ die Front (lokal +z) des Couchtischs zeigt zum Sofa.""" from fp_engines.rules.geometry import front_dir plan = _solve_wohnen( [_SOFA, _COUCHTISCH], [{"itemId": _COUCHTISCH, "relation": "facing:sofa"}], 1 ) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 ct = _pos(plan, _COUCHTISCH) sofa = _pos(plan, _SOFA) f = front_dir(_yaw(plan, _COUCHTISCH)) laenge = ((sofa[0] - ct[0]) ** 2 + (sofa[1] - ct[1]) ** 2) ** 0.5 richtung = ((sofa[0] - ct[0]) / laenge, (sofa[1] - ct[1]) / laenge) # Front zeigt zum Sofa: Skalarprodukt deutlich positiv (best-orientierte Pose). assert f[0] * richtung[0] + f[1] * richtung[1] > 0.5 def test_group_bringt_mitglieder_nah_zusammen() -> None: """group: ⇒ Objekte gleicher Gruppe landen nah beieinander (Sitzgruppe).""" absichten = [ {"itemId": _SOFA, "relation": "group:sitzgruppe"}, {"itemId": _COUCHTISCH, "relation": "group:sitzgruppe"}, ] plan = _solve_wohnen([_SOFA, _COUCHTISCH], absichten, 4) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 sofa = _pos(plan, _SOFA) ct = _pos(plan, _COUCHTISCH) abstand = ((sofa[0] - ct[0]) ** 2 + (sofa[1] - ct[1]) ** 2) ** 0.5 assert abstand <= 2.0 def test_relationen_erhalten_invariante_ueber_seeds() -> None: """Alle neuen Relationen kombiniert: 0 ❌ über viele Seeds (Solver-Invariante).""" absichten = [ {"itemId": _SOFA, "relation": "against-wall"}, {"itemId": _SOFA, "relation": "facing:couchtisch"}, {"itemId": _COUCHTISCH, "relation": "group:sitzgruppe"}, {"itemId": _SIDEBOARD, "relation": "corner"}, {"itemId": _SIDEBOARD, "relation": "group:sitzgruppe"}, ] for seed in range(8): plan = _solve_wohnen([_SOFA, _COUCHTISCH, _SIDEBOARD], absichten, seed) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 assert plan["constraintReport"]["hard"]["ok"] is True def test_kaputte_relation_wird_ignoriert_nicht_fatal() -> None: """Unbekannte/kaputte Relation ⇒ kein Fehler, gültiger Plan (robuste Ebene).""" plan = _solve_wohnen( [_SOFA, _COUCHTISCH], [{"itemId": _COUCHTISCH, "relation": "voll-kaputt:???"}], 1 ) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 def test_stil_score_durchgereicht_erhaelt_invariante() -> None: """styleVector durchgereicht ⇒ Stil ordnet nur, Plan bleibt normkonform.""" style = {"styleVector": {"raumgefuehl": 0.9}} plan = solve( _WOHNEN_ROOM, [_SOFA, _COUCHTISCH, _SIDEBOARD], [], WOHNEN_CATALOG, _WOHNEN_RULES, seed=2, style_profile=style, created_at="2026-06-11T12:00:00Z", ) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 # --- Kurator-Pipeline v2: weiche Anordnung (wandIndex/prioritaet/relationen) --- def _dist_point_segment(p: tuple[float, float], a: list[float], b: list[float]) -> float: ax, az = a[0], a[1] bx, bz = b[0], b[1] dx, dz = bx - ax, bz - az laenge2 = dx * dx + dz * dz t = 0.0 if laenge2 == 0 else max(0.0, min(1.0, ((p[0] - ax) * dx + (p[1] - az) * dz) / laenge2)) return math.hypot(p[0] - (ax + t * dx), p[1] - (az + t * dz)) def _nearest_wall(pos: tuple[float, float], walls: list[dict[str, Any]]) -> int: return min( range(len(walls)), key=lambda i: _dist_point_segment(pos, walls[i]["start"], walls[i]["end"]), ) def _solve_anordnung( auswahl: list[str], absichten: list[dict[str, Any]], anordnung: list[dict[str, Any]], seed: int, room: dict[str, Any] | None = None, ) -> dict[str, Any]: return solve( room or _WOHNEN_ROOM, auswahl, absichten, WOHNEN_CATALOG, _WOHNEN_RULES, seed=seed, anordnung=anordnung, created_at="2026-06-11T12:00:00Z", ) def test_wandindex_platziert_an_gewuenschter_wand() -> None: """anordnung.wandIndex ⇒ das Item landet (wenn zulässig) an genau dieser Wand.""" walls = _WOHNEN_ROOM["shell"]["walls"] for wi in range(len(walls)): plan = _solve_anordnung([_SIDEBOARD], [], [{"itemId": _SIDEBOARD, "wandIndex": wi}], 2) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 assert _nearest_wall(_pos(plan, _SIDEBOARD), walls) == wi def test_wandindex_fallback_bei_unmoeglicher_wand() -> None: """Keine zulässige Wand-Pose an der Wunschwand ⇒ weicher Fallback (kein Fehler).""" room = copy.deepcopy(_WOHNEN_ROOM) room["shell"]["walls"][0]["kind"] = "offen" # Wand 0 hat keine Wand-Kandidaten mehr plan = _solve_anordnung( [_SIDEBOARD], [], [{"itemId": _SIDEBOARD, "wandIndex": 0}], 1, room=room ) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 # Trotz unerfüllbarem Wandwunsch wird das (optionale) Item platziert. assert any(p["catalogItemId"] == _SIDEBOARD for p in plan["placements"]) def test_prioritaet_ordnet_innerhalb_p2() -> None: """Kleinere prioritaet ⇒ zuerst platziert (stabile Reihenfolge in der Klasse).""" def _reihenfolge(anordnung: list[dict[str, Any]]) -> list[str]: plan = _solve_anordnung([_COUCHTISCH, _SIDEBOARD], [], anordnung, 3) return [p["catalogItemId"] for p in plan["placements"]] vor = _reihenfolge( [{"itemId": _SIDEBOARD, "prioritaet": 1}, {"itemId": _COUCHTISCH, "prioritaet": 2}] ) assert vor.index(_SIDEBOARD) < vor.index(_COUCHTISCH) nach = _reihenfolge( [{"itemId": _SIDEBOARD, "prioritaet": 2}, {"itemId": _COUCHTISCH, "prioritaet": 1}] ) assert nach.index(_COUCHTISCH) < nach.index(_SIDEBOARD) def test_anordnung_relationen_werden_konsumiert() -> None: """anordnung.relationen speisen die weiche Ebene (against-wall ⇒ an die Wand).""" from fp_engines.rules.geometry import dist_point_to_polygon_boundary plan = _solve_anordnung( [_SIDEBOARD], [], [{"itemId": _SIDEBOARD, "relationen": ["against-wall"]}], 1 ) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 floor = [tuple(p) for p in _WOHNEN_ROOM["shell"]["floor"]["polygon"]] tiefe = next(c["masse"]["d"] for c in WOHNEN_CATALOG if c["id"] == _SIDEBOARD) assert dist_point_to_polygon_boundary(_pos(plan, _SIDEBOARD), floor) <= tiefe / 2 + 0.06 def test_anordnung_relationen_ueberschreiben_absichten() -> None: """Kollidieren absichten und anordnung.relationen je Item, gewinnt die anordnung.""" from fp_engines.rules.geometry import dist_point_to_polygon_boundary # absichten wollen das Sideboard ans Sofa binden; anordnung erzwingt against-wall. plan = _solve_anordnung( [_SOFA, _SIDEBOARD], [{"itemId": _SIDEBOARD, "relation": "pair-with:" + _SOFA}], [{"itemId": _SIDEBOARD, "relationen": ["against-wall"]}], 1, ) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 floor = [tuple(p) for p in _WOHNEN_ROOM["shell"]["floor"]["polygon"]] tiefe = next(c["masse"]["d"] for c in WOHNEN_CATALOG if c["id"] == _SIDEBOARD) assert dist_point_to_polygon_boundary(_pos(plan, _SIDEBOARD), floor) <= tiefe / 2 + 0.06 def test_anordnung_erhaelt_invariante_ueber_seeds() -> None: """Kombinierte anordnung (wandIndex + prioritaet + relationen): 0 ❌ über Seeds.""" anordnung = [ {"itemId": _SOFA, "wandIndex": 0, "prioritaet": 1, "relationen": ["against-wall"]}, {"itemId": _COUCHTISCH, "prioritaet": 2, "relationen": ["near:sofa:1.3"]}, {"itemId": _SIDEBOARD, "wandIndex": 2, "prioritaet": 3, "relationen": ["corner"]}, ] for seed in range(8): plan = _solve_anordnung([_SOFA, _COUCHTISCH, _SIDEBOARD], [], anordnung, seed) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 assert plan["constraintReport"]["hard"]["ok"] is True # Solvability-Regression: alle solve()-fähigen Raum-Fixtures × Seeds 0–3, jeweils # mit der (neuen) Baseline-`anordnung` durchgereicht – beweist, dass die weiche # Anordnungs-Ebene die Feasibility nirgends bricht. _REGRESSION_RAEUME = [ ("raummodell.bad-sample", "bad"), ("raummodell.bad-gross", "bad"), ("raummodell.gaeste-wc", "bad"), ("raummodell.r1-wc", "bad"), ("raummodell.wohnen-sample", "wohnen"), ("raummodell.wohnen-l", "wohnen"), ("raummodell.wohnen-lang", "wohnen"), ] @pytest.mark.parametrize(("room_name", "room_type"), _REGRESSION_RAEUME) @pytest.mark.parametrize("seed", range(4)) def test_solvability_regression_mit_anordnung(room_name: str, room_type: str, seed: int) -> None: room = _load(FIXTURES / f"{room_name}.json") catalog = _catalog(room_type) rules = _rules(room_type) sel = baseline_auswahl(room, catalog) plan = solve( room, sel["auswahl"], sel["relationaleAbsichten"], catalog, rules, seed=seed, anordnung=sel["anordnung"], created_at="2026-06-11T12:00:00Z", ) assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 assert plan["constraintReport"]["hard"]["ok"] is True def test_tuer_korridor_frei_von_optionalen() -> None: """Verkehrsweg: optionale (P2/P3) Objekte stehen nicht im Tür-Zugangsstreifen.""" room = _load(FIXTURES / "raummodell.bad-sample.json") plan = _solve("raummodell.bad-sample", 3) by_id = {c["id"]: c for c in CATALOG} korridore = _tuer_korridore(room) assert korridore, "Sample-Bad hat eine Tür → mindestens ein Korridor" for p in plan["placements"]: item = by_id[p["catalogItemId"]] if item["priorityClass"] == "P1" or item.get("mount") == "wand": continue quad = footprint( (p["pose"]["pos"][0], p["pose"]["pos"][1]), item["masse"]["w"], item["masse"]["d"], p["pose"]["yawDeg"], ) assert all(overlap_depth(quad, z) is None for z in korridore), item["funktionsTyp"] # --- Objekt-Ebenen: Mehrfach-Instanzen (mengen, ADR-0014, Welle A) ----------- def test_solver_platziert_vier_stuehle_near_esstisch() -> None: """mengen={stuhl:4} → 4 eigenständige Placements (eindeutige IDs), 0 ❌, jede Instanz nahe am Esstisch (near:esstisch gilt je Instanz).""" room = _load(FIXTURES / "raummodell.wohnen-sample.json") catalog = _catalog("wohnen") rules = _rules("wohnen") esstisch = next(c["id"] for c in catalog if c["funktionsTyp"] == "esstisch") stuhl = next(c["id"] for c in catalog if c["funktionsTyp"] == "stuhl") plan = solve( room, [esstisch, stuhl], [{"itemId": stuhl, "relation": "near:esstisch:1.3"}], catalog, rules, seed=1, mengen={stuhl: 4}, created_at="2026-06-11T12:00:00Z", ) stuhl_pl = [p for p in plan["placements"] if p["catalogItemId"] == stuhl] assert len(stuhl_pl) == 4 assert len({p["id"] for p in stuhl_pl}) == 4 # eindeutige placement-ids assert plan["constraintReport"]["hard"]["summary"]["verletzt"] == 0 et = next(p for p in plan["placements"] if p["catalogItemId"] == esstisch)["pose"]["pos"] assert all(math.dist(et, p["pose"]["pos"]) <= 1.3 + 1e-6 for p in stuhl_pl) def test_solver_mengen_default_eins_unveraendert() -> None: """Ohne mengen (bzw. anzahl 1) exakt wie bisher – Determinismus unberührt.""" room = _load(FIXTURES / "raummodell.wohnen-sample.json") catalog = _catalog("wohnen") rules = _rules("wohnen") esstisch = next(c["id"] for c in catalog if c["funktionsTyp"] == "esstisch") ohne = solve(room, [esstisch], [], catalog, rules, seed=1, created_at="2026-06-11T12:00:00Z") eins = solve( room, [esstisch], [], catalog, rules, seed=1, mengen={esstisch: 1}, created_at="2026-06-11T12:00:00Z", ) assert ohne == eins def test_mehrfach_instanzen_verteilen_sich_um_den_anker() -> None: """4 Stühle mit «near:esstisch» stehen rund um den Tisch, nicht auf einem Haufen. Ohne Sektor-Bonus zieht der near-Score jede Instanz auf denselben kürzesten Abstand – die Stühle klumpten dann auf der nächstgelegenen Tischseite. Geprüft wird die Streuung der Winkel um den Tisch: bei gleichmässiger Verteilung liegen mindestens zwei Stühle auf gegenüberliegenden Hälften. """ room = _load(FIXTURES / "raummodell.wohnen-sample.json") catalog = _catalog("wohnen") rules = _rules("wohnen") by_id = {c["id"]: c for c in catalog} tisch = next(c for c in catalog if c["funktionsTyp"] == "esstisch") stuhl = next(c for c in catalog if c["funktionsTyp"] == "stuhl") plan = solve( room, [tisch["id"], stuhl["id"]], [{"itemId": stuhl["id"], "relation": "near:esstisch:1.3"}], catalog, rules, seed=1, created_at="2026-08-04T12:00:00Z", mengen={stuhl["id"]: 4}, ) posen = {"esstisch": None, "stuhl": []} for p in plan["placements"]: typ = by_id[p["catalogItemId"]]["funktionsTyp"] if typ == "esstisch": posen["esstisch"] = (p["pose"]["pos"][0], p["pose"]["pos"][1]) elif typ == "stuhl": posen["stuhl"].append((p["pose"]["pos"][0], p["pose"]["pos"][1])) assert posen["esstisch"] is not None assert len(posen["stuhl"]) >= 2, "mindestens zwei Stühle sollten platziert sein" tx, tz = posen["esstisch"] winkel = [math.degrees(math.atan2(sz - tz, sx - tx)) % 360 for sx, sz in posen["stuhl"]] # Grösster Winkelabstand zwischen zwei Stühlen: bei Klumpenbildung nahe 0°. spanne = max( min(abs(a - b), 360 - abs(a - b)) for i, a in enumerate(winkel) for b in winkel[i + 1 :] ) assert spanne > 90.0, f"Stühle klumpen (grösste Winkeldifferenz nur {spanne:.0f}°)"