From 8bc472a272c6b2f747f9fc03fe1513957b5edabc Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 12:40:25 +0200 Subject: [PATCH] 2D-Plan: z-Anordnen (nach vorn/hinten holen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selektierte 2D-Zeichnungselemente lassen sich jetzt in der Zeichen- reihenfolge umsortieren — Kontextmenü „Ganz nach vorn / Nach vorn / Nach hinten / Ganz nach hinten". Reine Array-Umsortierung in project.drawings2d (spätere Position = optisch oben), Undo-fähig über setProject. reorderDrawings(ids, front|forward|backward|back). --- src/App.tsx | 52 +++++++++++++++++++ src/i18n/de.ts | 5 ++ src/i18n/en.ts | 5 ++ src/state/projectSlice.ts | 65 +++++++++++++++++++++++ src/state/reorderDrawings.test.ts | 86 +++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 src/state/reorderDrawings.test.ts diff --git a/src/App.tsx b/src/App.tsx index c5255d2..13cd5ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1820,6 +1820,7 @@ export default function App() { const setElementForeground = useStore((s) => s.setElementForeground); const setElementBackground = useStore((s) => s.setElementBackground); const updateDrawing2D = useStore((s) => s.updateDrawing2D); + const reorderDrawings = useStore((s) => s.reorderDrawings); const resizeElement = useStore((s) => s.resizeElement); const updateWall = useStore((s) => s.updateWall); const setWallThickness = useStore((s) => s.setWallThickness); @@ -3475,6 +3476,11 @@ export default function App() { union: () => applyBoolean(unionDrawings), difference: () => applyBoolean(differenceDrawings), intersect: () => applyBoolean(intersectionDrawings), + drawingSelectionCount: selectedDrawingIds.length, + bringToFront: () => reorderDrawings(selectedDrawingIds, "front"), + bringForward: () => reorderDrawings(selectedDrawingIds, "forward"), + sendBackward: () => reorderDrawings(selectedDrawingIds, "backward"), + sendToBack: () => reorderDrawings(selectedDrawingIds, "back"), })} onClose={() => setMenu(null)} /> @@ -3734,6 +3740,12 @@ interface MenuActions { union: () => void; difference: () => void; intersect: () => void; + /** Anzahl aktuell selektierter 2D-Zeichnungselemente (z-Anordnen-Menü). */ + drawingSelectionCount: number; + bringToFront: () => void; + bringForward: () => void; + sendBackward: () => void; + sendToBack: () => void; } /** @@ -3887,6 +3899,42 @@ function planMenuItems( }, ]; + // Z-Anordnen (nach vorn/hinten holen) — wirkt auf die aktuelle 2D-Auswahl, + // sortiert `drawings2d` um (Array-Reihenfolge = Zeichenreihenfolge). Aktiv + // ab 1 selektiertem Element; sonst deaktiviert mit erklärendem Tooltip. + const arrangeEnabled = a.drawingSelectionCount >= 1; + const arrangeTitle = arrangeEnabled ? undefined : t("ctx.arrange.disabled"); + const arrangeItems: ContextMenuItem[] = [ + { + label: t("ctx.arrange.front"), + icon: , + disabled: !arrangeEnabled, + title: arrangeTitle, + onClick: a.bringToFront, + }, + { + label: t("ctx.arrange.forward"), + icon: , + disabled: !arrangeEnabled, + title: arrangeTitle, + onClick: a.bringForward, + }, + { + label: t("ctx.arrange.backward"), + icon: , + disabled: !arrangeEnabled, + title: arrangeTitle, + onClick: a.sendBackward, + }, + { + label: t("ctx.arrange.back"), + icon: , + disabled: !arrangeEnabled, + title: arrangeTitle, + onClick: a.sendToBack, + }, + ]; + if (wallId) { return [ { @@ -3902,6 +3950,8 @@ function planMenuItems( { divider: true }, ...booleanItems, { divider: true }, + ...arrangeItems, + { divider: true }, { label: t("ctx.fit"), icon: , @@ -3912,6 +3962,8 @@ function planMenuItems( return [ ...booleanItems, { divider: true }, + ...arrangeItems, + { divider: true }, { label: t("ctx.fit"), icon: , diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 3b2a038..eb37a8a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -390,6 +390,11 @@ export const de = { "ctx.intersect": "Schneiden", "ctx.boolean.disabled": "Mindestens zwei geschlossene Flächen auswählen (Rechteck oder geschlossene Polylinie).", + "ctx.arrange.front": "Ganz nach vorn", + "ctx.arrange.forward": "Nach vorn", + "ctx.arrange.backward": "Nach hinten", + "ctx.arrange.back": "Ganz nach hinten", + "ctx.arrange.disabled": "Mindestens ein 2D-Element auswählen.", "ctx.noWallHit": "Keine Wand getroffen", "ctx.noWallHit.hint": "Rechtsklick auf eine Wand wählt sie und zeigt Wand-Aktionen.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 496b2f6..aed937c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -388,6 +388,11 @@ export const en: Record = { "ctx.intersect": "Intersect", "ctx.boolean.disabled": "Select at least two closed areas (rectangle or closed polyline).", + "ctx.arrange.front": "Bring to front", + "ctx.arrange.forward": "Bring forward", + "ctx.arrange.backward": "Send backward", + "ctx.arrange.back": "Send to back", + "ctx.arrange.disabled": "Select at least one 2D element.", "ctx.noWallHit": "No wall hit", "ctx.noWallHit.hint": "Right-clicking a wall selects it and shows wall actions.", diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index 71f1b7d..4ac186b 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -162,6 +162,18 @@ export interface ProjectSlice { * `hatchSource`), die kein eigenes Setter-Paar haben. */ updateDrawing2D: (id: string, patch: Partial) => void; + /** + * Sortiert die gewählten 2D-Zeichnungselemente in `drawings2d` um (z-Anordnen + * im Grundriss). Die Array-Reihenfolge IST die Zeichenreihenfolge — spätere + * Position = weiter oben/vorn (siehe `generatePlan.ts`). Unbekannte IDs + * werden ignoriert; die relative Reihenfolge der Auswahl bleibt erhalten. + * - "front"/"back": Auswahl ans Ende/an den Anfang des Arrays. + * - "forward"/"backward": Auswahl je einen Schritt Richtung Ende/Anfang. + */ + reorderDrawings: ( + ids: string[], + op: "front" | "back" | "forward" | "backward", + ) => void; /** * Skaliert die Geometrie eines Elements auf Zielbreite×-höhe (Meter) um einen * Ankerpunkt (fx/fy ∈ [0,1] relativ zur bbox; 0,0 = oben-links). @@ -828,6 +840,12 @@ export function createProjectSlice( drawings2d: p.drawings2d.map((d) => (d.id === id ? { ...d, ...patch } : d)), })), + reorderDrawings: (ids, op) => + setProject((p) => ({ + ...p, + drawings2d: reorderDrawings2D(p.drawings2d, ids, op), + })), + // ── Größe (Resize um Anker) ──────────────────────────────────────────── resizeElement: (kind, id, w, h, anchor) => setProject((p) => resizeElement(p, kind, id, w, h, anchor)), @@ -1031,6 +1049,53 @@ function setOverrideField( return { ...project, drawings2d: project.drawings2d.map(apply) }; } +/** + * Z-Anordnen im Grundriss: `drawings2d` IST die Zeichenreihenfolge (spätere + * Position = weiter oben, siehe `generatePlan.ts`). Unbekannte IDs werden + * ignoriert; reine, immutable Umsortierung. + * + * - "front"/"back": die gewählten Elemente wandern gesammelt ans Ende/an den + * Anfang des Arrays, ihre relative Reihenfolge untereinander bleibt erhalten. + * - "forward"/"backward": jedes gewählte Element rückt einzeln um eine + * Position Richtung Ende/Anfang vor. Bei "forward" von hinten nach vorn + * iteriert (sonst würde ein vorderes Element ein gerade verschobenes + * sofort wieder überholen); bei "backward" entsprechend von vorn nach + * hinten. Über die Array-Grenzen hinaus passiert nichts (kein Wrap). + */ +function reorderDrawings2D( + drawings: Drawing2D[], + ids: string[], + op: "front" | "back" | "forward" | "backward", +): Drawing2D[] { + const selected = new Set(ids.filter((id) => drawings.some((d) => d.id === id))); + if (selected.size === 0) return drawings; + + if (op === "front" || op === "back") { + const picked = drawings.filter((d) => selected.has(d.id)); + const rest = drawings.filter((d) => !selected.has(d.id)); + return op === "front" ? [...rest, ...picked] : [...picked, ...rest]; + } + + // "forward" / "backward": jedes gewählte Element um genau eine Position + // tauschen — Iterationsrichtung verhindert, dass mehrfach-selektierte + // Nachbarn sich gegenseitig überholen. + const arr = drawings.slice(); + if (op === "forward") { + for (let i = arr.length - 2; i >= 0; i--) { + if (selected.has(arr[i].id) && !selected.has(arr[i + 1].id)) { + [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]; + } + } + } else { + for (let i = 1; i < arr.length; i++) { + if (selected.has(arr[i].id) && !selected.has(arr[i - 1].id)) { + [arr[i], arr[i - 1]] = [arr[i - 1], arr[i]]; + } + } + } + return arr; +} + function mapCeiling( project: Project, ceilingId: string, diff --git a/src/state/reorderDrawings.test.ts b/src/state/reorderDrawings.test.ts new file mode 100644 index 0000000..3fc9ffc --- /dev/null +++ b/src/state/reorderDrawings.test.ts @@ -0,0 +1,86 @@ +/** + * Z-Anordnen von 2D-Zeichnungselementen (`reorderDrawings` in + * `src/state/projectSlice.ts`). Die Array-Reihenfolge in `drawings2d` IST die + * Zeichenreihenfolge im Grundriss (spätere Position = weiter oben, siehe + * `generatePlan.ts`) — „front"/„back"/„forward"/„backward" sortieren das + * Array um. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { getState, setState } from "./appStore"; +import { sampleProject } from "../model/sampleProject"; +import type { Drawing2D } from "../model/types"; + +function rect(id: string): Drawing2D { + return { + id, + type: "drawing2d", + levelId: "eg", + categoryCode: "A", + geom: { shape: "rect", min: { x: 0, y: 0 }, max: { x: 1, y: 1 } }, + }; +} + +beforeEach(() => { + setState({ + project: { + ...sampleProject, + drawings2d: [rect("A"), rect("B"), rect("C"), rect("D")], + }, + undoStack: [], + redoStack: [], + canUndo: false, + canRedo: false, + }); +}); + +function ids(): string[] { + return getState().project.drawings2d.map((d) => d.id); +} + +describe("reorderDrawings", () => { + it("front: schiebt die Auswahl ans Ende, Reihenfolge untereinander bleibt", () => { + getState().reorderDrawings(["B", "A"], "front"); + // Auswahl-Reihenfolge im Array bleibt A vor B (Array-Reihenfolge zählt, + // nicht die Aufruf-Reihenfolge der ids). + expect(ids()).toEqual(["C", "D", "A", "B"]); + }); + + it("back: schiebt die Auswahl an den Anfang", () => { + getState().reorderDrawings(["C"], "back"); + expect(ids()).toEqual(["C", "A", "B", "D"]); + }); + + it("forward: contiguous block überholt genau einen Nachbarn", () => { + getState().reorderDrawings(["B", "C"], "forward"); + expect(ids()).toEqual(["A", "D", "B", "C"]); + }); + + it("backward: contiguous block überholt genau einen Nachbarn rückwärts", () => { + getState().reorderDrawings(["B", "C"], "backward"); + expect(ids()).toEqual(["B", "C", "A", "D"]); + }); + + it("forward: letztes Element bleibt am Ende (kein Überlauf)", () => { + getState().reorderDrawings(["D"], "forward"); + expect(ids()).toEqual(["A", "B", "C", "D"]); + }); + + it("backward: erstes Element bleibt am Anfang (kein Überlauf)", () => { + getState().reorderDrawings(["A"], "backward"); + expect(ids()).toEqual(["A", "B", "C", "D"]); + }); + + it("unbekannte IDs werden ignoriert", () => { + getState().reorderDrawings(["nope"], "front"); + expect(ids()).toEqual(["A", "B", "C", "D"]); + }); + + it("ist ein normaler Undo-Schritt", () => { + getState().reorderDrawings(["A"], "front"); + expect(ids()).toEqual(["B", "C", "D", "A"]); + expect(getState().canUndo).toBe(true); + getState().undo(); + expect(ids()).toEqual(["A", "B", "C", "D"]); + }); +});