Spaces:
Sleeping
Sleeping
Download apps/web/src/dressing.test.ts from Bryan-HSLU/FP_POC: direct link, hf CLI and curl.
- Browser
- Download file 8.54 kB
-
https://huggingface.co/spaces/Bryan-HSLU/FP_POC/resolve/main/apps/web/src/dressing.test.ts
- Command line
-
hf download hf://spaces/Bryan-HSLU/FP_POC/apps/web/src/dressing.test.ts
-
curl -L -o dressing.test.ts https://huggingface.co/spaces/Bryan-HSLU/FP_POC/resolve/main/apps/web/src/dressing.test.ts
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); | |
| }); | |
| }); | |