diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index baabde8..5dc9ddf 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -37,6 +37,9 @@ import { pointSegDistPx, marqueeHit, marqueeHitDrawings, + marqueeHitCeilings, + marqueeHitRooms, + marqueeHitStairs, } from "./planView/geometry"; import { polyUsesPattern, @@ -208,6 +211,10 @@ export interface MarqueeSelection { wallIds: string[]; /** Getroffene 2D-Zeichenelemente (gleiche window/crossing-Konvention). */ drawingIds: string[]; + /** Getroffene Decken/Räume/Treppen (gleiche window/crossing-Konvention). */ + ceilingIds: string[]; + roomIds: string[]; + stairIds: string[]; /** * Zieh-Richtung: `false` = links→rechts (positiv Δx) → nur VOLLSTÄNDIG * eingeschlossene Wände; `true` = rechts→links (negativ Δx) → auch berührte/ @@ -1825,6 +1832,9 @@ export const PlanView = forwardRef( onMarquee({ wallIds: marqueeHit(plan.primitives, rect, crossing), drawingIds: marqueeHitDrawings(plan.primitives, rect, crossing), + ceilingIds: marqueeHitCeilings(plan.primitives, rect, crossing), + roomIds: marqueeHitRooms(plan.primitives, rect, crossing), + stairIds: marqueeHitStairs(plan.primitives, rect, crossing), crossing, }); } diff --git a/src/plan/planView/geometry.ts b/src/plan/planView/geometry.ts index d8eb726..3be21f2 100644 --- a/src/plan/planView/geometry.ts +++ b/src/plan/planView/geometry.ts @@ -222,24 +222,56 @@ export function marqueeHit( rect: ModelRect, crossing: boolean, ): string[] { - // Polygone je Wand sammeln (nur Primitive mit Wandbezug zählen für die Auswahl). - const byWall = new Map(); + return marqueeHitByField(prims, "wallId", rect, crossing); +} + +/** + * Generische Variante von {@link marqueeHit} für ein beliebiges Polygon-ID- + * Feld (`ceilingId`/`roomId`/`stairId`/`columnId`/`roofId`/…) — dieselbe + * window/crossing-Konvention, nur auf ein anderes Element bezogen. Deckt + * Nutzer-Report: „Auswahlrahmen kann Decken/Räume/Treppen nicht anwählen" + * (bisher nur Wände über `marqueeHit`, alles andere fehlte komplett). + */ +function marqueeHitByField( + prims: Primitive[], + field: "wallId" | "ceilingId" | "roomId" | "stairId" | "columnId" | "roofId", + rect: ModelRect, + crossing: boolean, +): string[] { + const byId = new Map(); for (const p of prims) { - if (p.kind !== "polygon" || p.wallId == null) continue; - const list = byWall.get(p.wallId); + if (p.kind !== "polygon") continue; + const id = p[field]; + if (id == null) continue; + const list = byId.get(id); if (list) list.push(p.pts); - else byWall.set(p.wallId, [p.pts]); + else byId.set(id, [p.pts]); } const out: string[] = []; - for (const [wallId, polys] of byWall) { + for (const [id, polys] of byId) { const hit = crossing ? polys.some((poly) => polyTouchesRect(poly, rect)) : polys.every((poly) => polyEnclosedByRect(poly, rect)); - if (hit) out.push(wallId); + if (hit) out.push(id); } return out; } +/** Vom Auswahl-Rechteck getroffene Decken-IDs (gleiche Konvention wie {@link marqueeHit}). */ +export function marqueeHitCeilings(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] { + return marqueeHitByField(prims, "ceilingId", rect, crossing); +} + +/** Vom Auswahl-Rechteck getroffene Raum-IDs (gleiche Konvention wie {@link marqueeHit}). */ +export function marqueeHitRooms(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] { + return marqueeHitByField(prims, "roomId", rect, crossing); +} + +/** Vom Auswahl-Rechteck getroffene Treppen-IDs (gleiche Konvention wie {@link marqueeHit}). */ +export function marqueeHitStairs(prims: Primitive[], rect: ModelRect, crossing: boolean): string[] { + return marqueeHitByField(prims, "stairId", rect, crossing); +} + /** * Vom Auswahl-Rechteck getroffene 2D-Zeichenelement-IDs (gleiche window/crossing- * Konvention wie {@link marqueeHit}). Sammelt je Drawing2D ALLE seine Punkte aus diff --git a/src/plan/planView/marquee.test.ts b/src/plan/planView/marquee.test.ts new file mode 100644 index 0000000..613c65f --- /dev/null +++ b/src/plan/planView/marquee.test.ts @@ -0,0 +1,68 @@ +/** + * `marqueeHitCeilings`/`marqueeHitRooms`/`marqueeHitStairs` — Nutzer-Report: + * "Auswahlrahmen kann nicht alle Elemente anwählen, z.B. Decken, Räume und + * Treppen nicht". Vorher las das Auswahl-Rechteck nur `wallId`/`drawingId` + * aus den Polygon-Primitiven; die anderen ID-Felder (die dieselben + * Primitive längst tragen) wurden ignoriert. + */ + +import { describe, it, expect } from "vitest"; +import { marqueeHit, marqueeHitCeilings, marqueeHitRooms, marqueeHitStairs } from "./geometry"; +import type { Primitive } from "../generatePlan"; + +const NO_HATCH = { pattern: "none" as const, color: "#000" }; + +function poly(id: Partial>, pts: { x: number; y: number }[]): Primitive { + return { + kind: "polygon", + pts, + fill: "none", + stroke: "#000", + strokeWidthMm: 0.18, + hatch: NO_HATCH, + ...id, + } as Primitive; +} + +const square = (x0: number, y0: number, x1: number, y1: number) => [ + { x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }, +]; + +describe("Marquee — Decken/Räume/Treppen", () => { + it("marqueeHitCeilings findet eine vollständig eingeschlossene Decke (window)", () => { + const prims = [poly({ ceilingId: "c1" }, square(1, 1, 3, 3))]; + const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 }; + expect(marqueeHitCeilings(prims, rect, false)).toEqual(["c1"]); + }); + + it("marqueeHitRooms ignoriert einen Raum, der nur teilweise im Fenster liegt (window, kein crossing)", () => { + const prims = [poly({ roomId: "r1" }, square(1, 1, 10, 10))]; + const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 }; + expect(marqueeHitRooms(prims, rect, false)).toEqual([]); + // Dieselbe Geometrie im Crossing-Modus (rechts→links) trifft trotzdem. + expect(marqueeHitRooms(prims, rect, true)).toEqual(["r1"]); + }); + + it("marqueeHitStairs findet eine Treppe über mehrere Tritt-Polygone (alle müssen im Fenster liegen)", () => { + const prims = [ + poly({ stairId: "s1" }, square(1, 1, 2, 2)), + poly({ stairId: "s1" }, square(2, 1, 3, 2)), + ]; + const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 }; + expect(marqueeHitStairs(prims, rect, false)).toEqual(["s1"]); + }); + + it("Wände (marqueeHit) und Decken/Räume/Treppen sind unabhängig voneinander auswertbar", () => { + const prims = [ + poly({ wallId: "w1" }, square(0, 0, 1, 1)), + poly({ ceilingId: "c1" }, square(1, 1, 2, 2)), + poly({ roomId: "r1" }, square(2, 2, 3, 3)), + poly({ stairId: "s1" }, square(3, 3, 4, 4)), + ]; + const rect = { minX: 0, minY: 0, maxX: 5, maxY: 5 }; + expect(marqueeHit(prims, rect, false)).toEqual(["w1"]); + expect(marqueeHitCeilings(prims, rect, false)).toEqual(["c1"]); + expect(marqueeHitRooms(prims, rect, false)).toEqual(["r1"]); + expect(marqueeHitStairs(prims, rect, false)).toEqual(["s1"]); + }); +}); diff --git a/src/state/useMouseSelectionHandlers.ts b/src/state/useMouseSelectionHandlers.ts index 0dc7846..e75620f 100644 --- a/src/state/useMouseSelectionHandlers.ts +++ b/src/state/useMouseSelectionHandlers.ts @@ -331,12 +331,14 @@ export function useMouseSelectionHandlers(opts: { const onPlanMarquee = (sel: MarqueeSelection) => { setSelectedWallIds(sel.wallIds); setSelectedDrawingIds(sel.drawingIds ?? []); - // Decken/Öffnungen/Treppen/Räume/Extrusionen/Dächer sind (noch) nicht Teil - // des Marquee → beim Aufziehen abwählen. - setSelectedCeilingIds([]); + setSelectedCeilingIds(sel.ceilingIds ?? []); + setSelectedStairIds(sel.stairIds ?? []); + setSelectedRoomIds(sel.roomIds ?? []); + // Öffnungen/Extrusionen/Stützen/Dächer/Kontext-Objekte sind (noch) nicht + // Teil des Marquee (Nutzer-Report betraf nur Decken/Räume/Treppen; Stütze/ + // Dach liessen sich mit derselben `marqueeHitByField`-Mechanik ergänzen, + // falls gewünscht) → beim Aufziehen abwählen. setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]);