FP_POC / apps /web /src /dressing.test.ts
github-actions[bot]
Space-Deploy aus GitHub main
9de795a
Raw History Blame Contribute Delete
8.54 kB
import { footprint, overlapDepth, type Vec2 } from "@fp/shared/rules";
import { describe, expect, it } from "vitest";
import type { DressingItem, KatalogItem, Plan, Room } from "./api";
import { sceneDressing } from "./dressing";
// ── Inline-Fixtures (leichtgewichtig; nur die von der Engine gelesenen Felder) ──
const FLOOR: Vec2[] = [
[0, 0],
[3, 0],
[3, 2.4],
[0, 2.4],
];
const room = {
roomType: "bad",
shell: {
walls: [
{ id: "w1", start: [0, 0], end: [3, 0] },
{ id: "w2", start: [3, 0], end: [3, 2.4] },
{ id: "w3", start: [3, 2.4], end: [0, 2.4] },
{ id: "w4", start: [0, 2.4], end: [0, 0] },
],
floor: { polygon: FLOOR },
},
openings: [{ type: "door", hostWall: "w1", offset: 1.9, width: 0.8 }],
} as unknown as Room;
const lavabo: KatalogItem = {
id: "lav",
funktionsTyp: "lavabo",
masse: { w: 0.6, d: 0.45, h: 0.15 },
} as unknown as KatalogItem;
const blocker: KatalogItem = {
id: "blk",
funktionsTyp: "schrank",
masse: { w: 1.2, d: 1.2, h: 0.5 },
} as unknown as KatalogItem;
const catalog = [lavabo, blocker];
/** Plan mit zwei Lavabos an der Rückwand (Anker für Oberflächen-Deko). */
const plan = {
placements: [
{ id: "p1", catalogItemId: "lav", pose: { pos: [0.7, 2.15], yawDeg: 180 }, mountHeight: 0.8 },
{ id: "p2", catalogItemId: "lav", pose: { pos: [2.3, 2.15], yawDeg: 180 }, mountHeight: 0.8 },
],
meta: { seed: 1 },
} as unknown as Plan;
/** Plan, dessen vier grosse Objekte alle Raumecken belegen (Kollisions-Test). */
const planEckenVoll = {
placements: [
{ id: "b1", catalogItemId: "blk", pose: { pos: [0.6, 0.6], yawDeg: 0 } },
{ id: "b2", catalogItemId: "blk", pose: { pos: [2.4, 0.6], yawDeg: 0 } },
{ id: "b3", catalogItemId: "blk", pose: { pos: [2.4, 1.8], yawDeg: 0 } },
{ id: "b4", catalogItemId: "blk", pose: { pos: [0.6, 1.8], yawDeg: 0 } },
],
meta: { seed: 1 },
} as unknown as Plan;
const item = (
id: string,
funktionsTyp: string,
anchorTypes: string[],
platzierung: DressingItem["platzierung"],
klasse: DressingItem["klasse"],
masse: { w: number; d: number; h: number },
): DressingItem => ({
id,
schemaVersion: "0.1.0",
name: id,
roomTypes: ["bad"],
klasse,
funktionsTyp,
anchorTypes,
platzierung,
masse,
achsenTags: { temperatur: 0.4 },
attributTags: [],
assetStatus: "placeholder",
});
const seife = item("seife", "seifenspender", ["lavabo"], "auf_oberflaeche", "D", {
w: 0.07,
d: 0.07,
h: 0.18,
});
// Anker "badmoebel" existiert im Plan NICHT → darf nie platziert werden.
const handtuch = item("handtuch", "handtuchstapel", ["badmoebel"], "auf_oberflaeche", "D", {
w: 0.3,
d: 0.22,
h: 0.14,
});
const pflanze = item("pflanze", "badpflanze", ["ecke"], "freie_ecke", "B", {
w: 0.24,
d: 0.24,
h: 0.55,
});
const items = [seife, handtuch, pflanze];
const warm = { styleVector: { temperatur: 1 } };
const kuehl = { styleVector: { temperatur: -1 } };
describe("sceneDressing – Determinismus", () => {
it("liefert bei gleichem Seed exakt dieselben Platzierungen", () => {
const a = sceneDressing(room, plan, catalog, warm, items, 1);
const b = sceneDressing(room, plan, catalog, warm, items, 1);
expect(a).toEqual(b);
expect(a.length).toBeGreaterThan(0);
});
it("variiert die Platzierung mit dem Seed (aber bleibt deterministisch)", () => {
const varianten = new Set<string>();
for (let s = 1; s <= 8; s++) {
varianten.add(JSON.stringify(sceneDressing(room, plan, catalog, warm, items, s)));
}
expect(varianten.size).toBeGreaterThan(1);
});
});
describe("sceneDressing – stilabhängige Auswahl", () => {
it("kippt Variante & Farbe je nach Stilprofil (warm ↔ kühl)", () => {
const w = sceneDressing(room, plan, catalog, warm, items, 1).find(
(p) => p.dressingItemId === "seife",
);
const k = sceneDressing(room, plan, catalog, kuehl, items, 1).find(
(p) => p.dressingItemId === "seife",
);
expect(w?.variante).toBe("warm");
expect(k?.variante).toBe("kuehl");
expect(w?.farbe).not.toBe(k?.farbe);
});
});
describe("sceneDressing – Anker-Bindung", () => {
it("platziert nur Deko mit vorhandenem Anker (badmoebel fehlt → weg)", () => {
const ids = sceneDressing(room, plan, catalog, warm, items, 1).map((p) => p.dressingItemId);
expect(ids).toContain("seife");
expect(ids).toContain("pflanze");
expect(ids).not.toContain("handtuch");
});
});
describe("sceneDressing – Klasse-B-Kollision", () => {
it("überlappt mit Bodenobjekten (Klasse B) kein Möbel-Footprint", () => {
const placementQuads = plan.placements.map((p) =>
footprint([p.pose.pos[0], p.pose.pos[1]], lavabo.masse.w, lavabo.masse.d, p.pose.yawDeg),
);
// Nur Klasse B (Boden) muss kollisionsfrei sein; Klasse D sitzt bewusst AUF
// den Möbeln und überlappt sie in der Draufsicht.
const bodenDeko = sceneDressing(room, plan, catalog, warm, items, 4).filter(
(p) => p.klasse === "B",
);
expect(bodenDeko.length).toBeGreaterThan(0);
for (const platz of bodenDeko) {
const fp = footprint(
[platz.pos[0], platz.pos[2]],
platz.masse.w,
platz.masse.d,
platz.yawDeg,
);
for (const q of placementQuads) expect(overlapDepth(fp, q)).toBeNull();
}
});
it("lässt Bodenobjekte weg, wenn alle Ecken belegt sind", () => {
const ids = sceneDressing(room, planEckenVoll, catalog, warm, [pflanze], 1).map(
(p) => p.dressingItemId,
);
expect(ids).not.toContain("pflanze");
});
});
describe("sceneDressing – Regressions-Guard (Plan bleibt unberührt)", () => {
it("verändert den übergebenen Plan nicht (nur lesend)", () => {
const snapshot = JSON.parse(JSON.stringify(plan));
sceneDressing(room, plan, catalog, warm, items, 7);
expect(JSON.parse(JSON.stringify(plan))).toEqual(snapshot);
});
});
// ── an_decke (Deckenleuchte über einem Möbel-Anker) ────────────────────────
describe("sceneDressing – an_decke (Deckenleuchte)", () => {
const esstisch: KatalogItem = {
id: "tisch",
funktionsTyp: "esstisch",
masse: { w: 1.6, d: 0.9, h: 0.75 },
} as unknown as KatalogItem;
const katalogMitTisch = [...catalog, esstisch];
const planMitTisch = {
placements: [
...plan.placements,
{ id: "p3", catalogItemId: "tisch", pose: { pos: [1.5, 1.2], yawDeg: 30 } },
],
meta: { seed: 1 },
} as unknown as Plan;
const pendelleuchte = item("pendel", "pendelleuchte", ["esstisch"], "an_decke", "C", {
w: 0.35,
d: 0.35,
h: 0.5,
});
it("hängt mittig über dem Anker, Oberkante an der Wandhöhe (raummodell height)", () => {
const raumMitHoehe = {
...room,
shell: {
...room.shell,
walls: room.shell.walls.map((w: unknown) => ({ ...(w as object), height: 2.6 })),
},
} as unknown as Room;
const platz = sceneDressing(
raumMitHoehe,
planMitTisch,
katalogMitTisch,
warm,
[pendelleuchte],
1,
).find((p) => p.dressingItemId === "pendel");
expect(platz).toBeDefined();
expect(platz?.pos[0]).toBeCloseTo(1.5);
expect(platz?.pos[2]).toBeCloseTo(1.2);
// Oberkante (pos.y + h/2) sitzt an der Deckenhöhe.
expect((platz?.pos[1] ?? 0) + 0.5 / 2).toBeCloseTo(2.6);
});
it("fällt ohne Wandhöhe im Raummodell auf 2.4 m Deckenhöhe zurück", () => {
const platz = sceneDressing(room, planMitTisch, katalogMitTisch, warm, [pendelleuchte], 1).find(
(p) => p.dressingItemId === "pendel",
);
expect((platz?.pos[1] ?? 0) + 0.5 / 2).toBeCloseTo(2.4);
});
it("wird ohne passenden Anker im Raum nicht platziert", () => {
const ohneTisch = sceneDressing(room, plan, catalog, warm, [pendelleuchte], 1);
expect(ohneTisch.find((p) => p.dressingItemId === "pendel")).toBeUndefined();
});
it("ist deterministisch (gleicher Seed ⇒ gleiche Platzierung)", () => {
const a = sceneDressing(room, planMitTisch, katalogMitTisch, warm, [pendelleuchte], 3);
const b = sceneDressing(room, planMitTisch, katalogMitTisch, warm, [pendelleuchte], 3);
expect(a).toEqual(b);
});
it("verändert den übergebenen Plan nicht (nur lesend)", () => {
const snapshot = JSON.parse(JSON.stringify(planMitTisch));
sceneDressing(room, planMitTisch, katalogMitTisch, warm, [pendelleuchte], 2);
expect(JSON.parse(JSON.stringify(planMitTisch))).toEqual(snapshot);
});
});