The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts has been updated successfully. (file state is current in your context — no need to Read it back)

        const subject = "#" + groupKey;
        return [
            setScene,
            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
            {
                type: "sendCommand",
                cmd: "start_placement_mode",
                args: [
                    {
                        subject,
                        allowAllDragPoints: true,
                        dragPoints: this.placementDragPoints(subject),
                    },
                ],
            },
        ];
    }

        const subject = "#" + groupKey;
        const dragPoints = this.placementDragPoints(subject);
        const reactions: UIReaction[] = [
            setScene,
            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
        ];
        // No docking rule for this class → no drag points. fire's placement mode dereferences
        // dragPoints[0], so skip start_placement_mode entirely instead of handing it an empty array.
        if (dragPoints.length > 0) {
            reactions.push({
                type: "sendCommand",
                cmd: "start_placement_mode",
                args: [{ subject, allowAllDragPoints: true, dragPoints }],
            });
        }
        return reactions;
    }

+        const dragPoints = this.placementDragPoints(subject);

+        const reactions: UIReaction[] = [

+        // No docking rule for this class → no drag points. fire's placement mode dereferences

+        // dragPoints[0], so skip start_placement_mode entirely instead of handing it an empty array.

-                        allowAllDragPoints: true,

-                        dragPoints: this.placementDragPoints(subject),

+                args: [{ subject, allowAllDragPoints: true, dragPoints }],

// The merge (on snap) or move (free) event for a moving group.


Guard 2 — den „Platzieren"-Menüeintrag. Ich lese den Kopf von `getContextForSelection`:


360	    private getContextForSelection(event: any): UIReaction[] {
361	        if (!event.pointed?.length) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
362	        this.replay();
363	        const pickedId = event.pointed[0].entity.id;
364	        const groupSelector = this.groupSelectorOf(pickedId);
365	        if (!groupSelector) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
366
367	        // The single picked element (for "re-place this element", as opposed to the whole group).
368	        const elementRoot = this.groups.elementRootOf(pickedId);
369
370	        // Right-click on something already in the selection keeps the whole set (so it doesn't drop a
371	        // multi-select or an escalation); a fresh target selects just that element. The escalate entry
372	        // only shows for a single-pick selection that can still go higher.
373	        const chain = this.groups.selectableAncestors(pickedId);
374	        if (!this.selection.some((p) => p.pickedId === pickedId)) this.selection = [{ pickedId, level: 0 }];
375	        const level = this.selection.find((p) => p.pickedId === pickedId)?.level ?? 0;
376	        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
377	        const canEscalate = this.selection.length === 1 && level < chain.length - 1;

    private getContextForSelection(event: any): UIReaction[] {
        if (!event.pointed?.length) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
        this.replay();
        const pickedId = event.pointed[0].entity.id;
        const groupSelector = this.groupSelectorOf(pickedId);
        if (!groupSelector) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];

        // The single picked element (for "re-place this element", as opposed to the whole group).
        const elementRoot = this.groups.elementRootOf(pickedId);

        // Right-click on something already in the selection keeps the whole set (so it doesn't drop a
        // multi-select or an escalation); a fresh target selects just that element. The escalate entry
        // only shows for a single-pick selection that can still go higher.
        const chain = this.groups.selectableAncestors(pickedId);
        if (!this.selection.some((p) => p.pickedId === pickedId)) this.selection = [{ pickedId, level: 0 }];
        const level = this.selection.find((p) => p.pickedId === pickedId)?.level ?? 0;
        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
        const canEscalate = this.selection.length === 1 && level < chain.length - 1;


Genau  Guard, um den Crash zu vermeiden' Im Storybook --> Der Configurtaor hat dort doch eine Produktuaswhal --> kann die nicht dei Matrix halten, ganz normal


Edit

        const canEscalate = this.selection.length === 1 && level < chain.length - 1;

        return [

        const canEscalate = this.selection.length === 1 && level < chain.length - 1;
        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode
        // dereferences dragPoints[0] and would crash on an empty array).
        const dragPoints = this.placementDragPoints(groupSelector);

        return [


        const canEscalate = this.selection.length === 1 && level < chain.length - 1;

        return [

        const canEscalate = this.selection.length === 1 && level < chain.length - 1;
        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode
        // dereferences dragPoints[0] and would crash on an empty array).
        const dragPoints = this.placementDragPoints(groupSelector);

        return [

const level = this.selection.find((p) => p.pickedId === pickedId)?.level ?? 0;

const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);

+        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode

+        // dereferences dragPoints[0] and would crash on an empty array).

+        const dragPoints = this.placementDragPoints(groupSelector);

{ type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },


                    {
                        label: "Platzieren",
                        reactions: [
                            {
                                type: "sendCommand",
                                cmd: "start_placement_mode",
                                args: [
                                    {
                                        subject: groupSelector,
                                        allowAllDragPoints: true,
                                        dragPoints: this.placementDragPoints(groupSelector),
                                    },
                                ],
                            },
                        ],
                    },

                    ...(dragPoints.length > 0
                        ? ([
                              {
                                  label: "Platzieren",
                                  reactions: [
                                      {
                                          type: "sendCommand",
                                          cmd: "start_placement_mode",
                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],
                                      },
                                  ],
                              },
                          ] as MenuEntry[])
                        : []),


                    {
                        label: "Platzieren",
                        reactions: [
                            {
                                type: "sendCommand",
                                cmd: "start_placement_mode",
                                args: [
                                    {
                                        subject: groupSelector,
                                        allowAllDragPoints: true,
                                        dragPoints: this.placementDragPoints(groupSelector),
                                    },
                                ],
                            },
                        ],
                    },

                    ...(dragPoints.length > 0
                        ? ([
                              {
                                  label: "Platzieren",
                                  reactions: [
                                      {
                                          type: "sendCommand",
                                          cmd: "start_placement_mode",
                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],
                                      },
                                  ],
                              },
                          ] as MenuEntry[])
                        : []),

reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "rotate" } }],

-                        label: "Platzieren",

-                                type: "sendCommand",

-                                cmd: "start_placement_mode",

-                                args: [

-                                        subject: groupSelector,

-                                        allowAllDragPoints: true,

-                                        dragPoints: this.placementDragPoints(groupSelector),

+                    ...(dragPoints.length > 0

+                                  label: "Platzieren",

+                                  reactions: [

+                                          type: "sendCommand",

+                                          cmd: "start_placement_mode",

+                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],

+                          ] as MenuEntry[])


Guard fertig (beide Stellen). Jetzt die Matrix in die ProductConfigurator-Story. Ich sehe sie mir an:


1	import type { Meta, StoryObj } from "@storybook/vue3";
2	import { onMounted, ref } from "vue";
3	import ProductConfigurator from "./ProductConfigurator.vue";
4	import ProductBrowser from "../building-blocks/ProductBrowser.vue";
5
6	const meta: Meta<typeof ProductConfigurator> = {
7	    title: "Widgets/Visual",
8	    component: ProductConfigurator,
9	    parameters: { layout: "fullscreen" },
10	};
11
12	export default meta;
13
14	type Story = StoryObj<typeof ProductConfigurator>;
15
16	export const Configurator: Story = {
17	    render: () => ({
18	        components: { ProductConfigurator, ProductBrowser },
19	        setup() {
20	            const catalog = ref<unknown>(null);
21	            onMounted(async () => {
22	                catalog.value = await fetch("/products/catalog.json").then((r) => r.json());
23	            });
24	            return { catalog };
25	        },
26	        template: `
27	            <div style="height:100vh">
28	                <ProductConfigurator>
29	                    <template #catalog="{ select }">
30	                        <ProductBrowser
31	                            v-if="catalog"
32	                            :data="catalog"
33	                            image-base-path="/products/"
34	                            @child-clicked="select"
35	                        />
36	                    </template>
37	                </ProductConfigurator>
38	            </div>
39	        `,
40	    }),
41	};
42

import type { Meta, StoryObj } from "@storybook/vue3";
import { onMounted, ref } from "vue";
import ProductConfigurator from "./ProductConfigurator.vue";
import ProductBrowser from "../building-blocks/ProductBrowser.vue";

const meta: Meta<typeof ProductConfigurator> = {
    title: "Widgets/Visual",
    component: ProductConfigurator,
    parameters: { layout: "fullscreen" },
};

export default meta;

type Story = StoryObj<typeof ProductConfigurator>;

export const Configurator: Story = {
    render: () => ({
        components: { ProductConfigurator, ProductBrowser },
        setup() {
            const catalog = ref<unknown>(null);
            onMounted(async () => {
                catalog.value = await fetch("/products/catalog.json").then((r) => r.json());
            });
            return { catalog };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator>
                    <template #catalog="{ select }">
                        <ProductBrowser
                            v-if="catalog"
                            :data="catalog"
                            image-base-path="/products/"
                            @child-clicked="select"
                        />
                    </template>
                </ProductConfigurator>
            </div>
        `,
    }),
};


Jetzt die Matrix in die Story. Ich füge die (aus deiner gezeigten allround-Matrix, `:`→`-`) als Konstante ein und reiche sie als `:docking-rules` durch:


import ProductBrowser from "../building-blocks/ProductBrowser.vue";

const meta: Meta<typeof ProductConfigurator> = {

import ProductBrowser from "../building-blocks/ProductBrowser.vue";
import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";

// Beispiel-Docking-Regeln — normalerweise vom cpq-Client (geko) geliefert. Klassen-Keys wie die
// `class-…` Tags an den Bauteilen (":" → "-"). Hier nur schutzzaun_allround; basic snappt nicht.
const DOCKING_RULES: DockingRules = {
    "schutzzaun_allround-steher": {
        "schutzzaun_allround-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270", "CS_OUT_R"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270", "CS_OUT_L"],
        },
    },
    "schutzzaun_allround-element": {
        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
        "schutzzaun_allround-tuer": {
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
        },
    },
    "schutzzaun_allround-tuer": {
        "schutzzaun_allround-element": {
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
        },
        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
    },
};

const meta: Meta<typeof ProductConfigurator> = {


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.stories.ts has been updated successfully. (file state is current in your context — no need to Read it back)

import ProductBrowser from "../building-blocks/ProductBrowser.vue";
import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";

// Beispiel-Docking-Regeln — normalerweise vom cpq-Client (geko) geliefert. Klassen-Keys wie die
// `class-…` Tags an den Bauteilen (":" → "-"). Hier nur schutzzaun_allround; basic snappt nicht.
const DOCKING_RULES: DockingRules = {
    "schutzzaun_allround-steher": {
        "schutzzaun_allround-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270", "CS_OUT_R"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270", "CS_OUT_L"],
        },
    },
    "schutzzaun_allround-element": {
        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
        "schutzzaun_allround-tuer": {
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
        },
    },
    "schutzzaun_allround-tuer": {
        "schutzzaun_allround-element": {
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
        },
        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
    },
};

const meta: Meta<typeof ProductConfigurator> = {

import type { Meta, StoryObj } from "@storybook/vue3";
import { onMounted, ref } from "vue";
import ProductConfigurator from "./ProductConfigurator.vue";
import ProductBrowser from "../building-blocks/ProductBrowser.vue";

const meta: Meta<typeof ProductConfigurator> = {
    title: "Widgets/Visual",
    component: ProductConfigurator,
    parameters: { layout: "fullscreen" },
};

export default meta;

type Story = StoryObj<typeof ProductConfigurator>;

export const Configurator: Story = {
    render: () => ({
        components: { ProductConfigurator, ProductBrowser },
        setup() {
            const catalog = ref<unknown>(null);
            onMounted(async () => {
                catalog.value = await fetch("/products/catalog.json").then((r) => r.json());
            });
            return { catalog };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator>
                    <template #catalog="{ select }">
                        <ProductBrowser
                            v-if="catalog"
                            :data="catalog"
                            image-base-path="/products/"
                            @child-clicked="select"
                        />
                    </template>
                </ProductConfigurator>
            </div>
        `,
    }),
};

+import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";

+// Beispiel-Docking-Regeln — normalerweise vom cpq-Client (geko) geliefert. Klassen-Keys wie die

+// `class-…` Tags an den Bauteilen (":" → "-"). Hier nur schutzzaun_allround; basic snappt nicht.

+        "schutzzaun_allround-element": {

+        "schutzzaun_allround-steher": {


            return { catalog };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator>

            return { catalog, dockingRules: DOCKING_RULES };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator :docking-rules="dockingRules">


            return { catalog, dockingRules: DOCKING_RULES };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator :docking-rules="dockingRules">

import type { Meta, StoryObj } from "@storybook/vue3";
import { onMounted, ref } from "vue";
import ProductConfigurator from "./ProductConfigurator.vue";
import ProductBrowser from "../building-blocks/ProductBrowser.vue";
import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";

// Beispiel-Docking-Regeln — normalerweise vom cpq-Client (geko) geliefert. Klassen-Keys wie die
// `class-…` Tags an den Bauteilen (":" → "-"). Hier nur schutzzaun_allround; basic snappt nicht.
const DOCKING_RULES: DockingRules = {
    "schutzzaun_allround-steher": {
        "schutzzaun_allround-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270", "CS_OUT_R"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270", "CS_OUT_L"],
        },
    },
    "schutzzaun_allround-element": {
        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
        "schutzzaun_allround-tuer": {
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
        },
    },
    "schutzzaun_allround-tuer": {
        "schutzzaun_allround-element": {
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
        },
        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
    },
};

const meta: Meta<typeof ProductConfigurator> = {
    title: "Widgets/Visual",
    component: ProductConfigurator,
    parameters: { layout: "fullscreen" },
};

export default meta;

type Story = StoryObj<typeof ProductConfigurator>;

export const Configurator: Story = {
    render: () => ({
        components: { ProductConfigurator, ProductBrowser },
        setup() {
            const catalog = ref<unknown>(null);
            onMounted(async () => {
                catalog.value = await fetch("/products/catalog.json").then((r) => r.json());
            });
            return { catalog };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator>
                    <template #catalog="{ select }">
                        <ProductBrowser
                            v-if="catalog"
                            :data="catalog"
                            image-base-path="/products/"
                            @child-clicked="select"
                        />
                    </template>
                </ProductConfigurator>
            </div>
        `,
    }),
};

+            return { catalog, dockingRules: DOCKING_RULES };

+                <ProductConfigurator :docking-rules="dockingRules">


Guard + Story-Matrix fertig. Build zum Prüfen:


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
pnpm --filter @ad-cpq/common-frontend build 2>&1 | grep -iE 'error TS|built|success' | grep -v 'ProductBrowser.vue' | head -10
echo "EXIT=${PIPESTATUS[0]}"

Build to verify guard and story compile


[vite:dts] Declaration files built in 1037ms.
✓ built in 2.42s
EXIT=


Fertig, Build grün (nur die vorbestehenden ProductBrowser-Fehler).

**Guard** (`DefaultCommandProcessor`): An beiden `start_placement_mode`-Stellen wird jetzt geprüft — bei leeren dragPoints (keine Regel) wird der Placement-Mode nicht gestartet:
- `handleDetachElement`: kein `start_placement_mode`, das Teil bleibt liegen.
- `getContextForSelection`: der „Platzieren"-Menüeintrag entfällt.

Damit crasht fire nicht mehr, auch ohne Matrix (wie in `Fire3d.stories`).

**ProductConfigurator-Story**: Die allround-Matrix (deine gezeigte, `:`→`-`) liegt als `DOCKING_RULES` in der Story und geht als `:docking-rules` an den Configurator — der ganz normale Client-Weg.

**Zum Testen im Storybook:**
- Story **„Widgets/Visual → Configurator"** öffnen (nicht die Fire3d-Story).
- Ein **allround**-Bauteil aus der Produktauswahl wählen (Steher/Element/Tür) → beim Platzieren greift die Matrix, es snappt klassen-spezifisch.
- basic-Teile snappen dort nicht (keine Regel in der Beispiel-Matrix), crashen aber auch nicht.

HMR übernimmt die Änderungen beim Reload. Alles uncommitted im Working Tree.


ok --
advanced geht
basic :: keine rules
da ist das menüttem platzieren gar nicht erst erreichbar. So gedacht?


Ja, genau so gedacht — direkte Folge deiner Wahl „Kein Placement-Mode": ohne Regel keine dragPoints → der Guard lässt den „Platzieren"-Eintrag weg, statt fire mit leerem `dragPoints[0]` zum Absturz zu bringen.

Zur Einordnung: dass **basic** hier keine Rules hat, liegt nur an der **Beispiel-Matrix** in der Story — die deckt bewusst nur `schutzzaun_allround` ab. In der echten Nutzung liefert der Client (geko) die Matrix für alle Klassen, dann hätte basic seine eigenen Regeln und „Platzieren" wäre da.

Falls die UX später stören sollte (der Eintrag fehlt kommentarlos, statt sichtbar-aber-inaktiv zu sein), gäbe es zwei Alternativen — beides bewusste Änderungen, kein Muss:
- „Platzieren" ausgegraut/disabled mit Tooltip („kein Andocken für diese Klasse").
- die andere Fallback-Variante: „Platzieren" bleibt, mit einem Anker-DragPoint ohne DropPoints → freies Ablegen ohne Snap.

Für jetzt ist das Verhalten konsistent und crash-frei. Passt so, solange basic bewusst (noch) keine Rules hat.


ok -- setzt bitte mal regen für basic rein
den - menü kommt niocht Test können wir in den anderen Stories machen


ok -- setzt bitte mal regen für basic rein den - menü kommt niocht Test können wir in den anderen Stories machen


Ich leite die basic-Regeln aus der bisherigen L/R-Logik ab (basic hat keinen Steher — Element/Tür docken direkt aneinander). Ich hänge sie an die Matrix an:


Edit

        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
    },
    // basic: kein Steher — Element und Tür docken direkt links/rechts aneinander (bisherige L/R-Logik).
    "schutzzaun_basic-element": {
        "schutzzaun_basic-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
        },
        "schutzzaun_basic-tuer": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
        },
    },
    "schutzzaun_basic-tuer": {
        "schutzzaun_basic-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
        },
    },
};


        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
    },
    // basic: kein Steher — Element und Tür docken direkt links/rechts aneinander (bisherige L/R-Logik).
    "schutzzaun_basic-element": {
        "schutzzaun_basic-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
        },
        "schutzzaun_basic-tuer": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
        },
    },
    "schutzzaun_basic-tuer": {
        "schutzzaun_basic-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
        },
    },
};

import type { Meta, StoryObj } from "@storybook/vue3";
import { onMounted, ref } from "vue";
import ProductConfigurator from "./ProductConfigurator.vue";
import ProductBrowser from "../building-blocks/ProductBrowser.vue";
import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";

// Beispiel-Docking-Regeln — normalerweise vom cpq-Client (geko) geliefert. Klassen-Keys wie die
// `class-…` Tags an den Bauteilen (":" → "-"). Hier nur schutzzaun_allround; basic snappt nicht.
const DOCKING_RULES: DockingRules = {
    "schutzzaun_allround-steher": {
        "schutzzaun_allround-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270", "CS_OUT_R"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270", "CS_OUT_L"],
        },
    },
    "schutzzaun_allround-element": {
        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
        "schutzzaun_allround-tuer": {
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
        },
    },
    "schutzzaun_allround-tuer": {
        "schutzzaun_allround-element": {
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
        },
        "schutzzaun_allround-steher": {
            CS_EIN_R: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
            CS_EIN_L: ["CS_OUT_0", "CS_OUT_90", "CS_OUT_180", "CS_OUT_270"],
        },
    },
};

const meta: Meta<typeof ProductConfigurator> = {
    title: "Widgets/Visual",
    component: ProductConfigurator,
    parameters: { layout: "fullscreen" },
};

export default meta;

type Story = StoryObj<typeof ProductConfigurator>;

export const Configurator: Story = {
    render: () => ({
        components: { ProductConfigurator, ProductBrowser },
        setup() {
            const catalog = ref<unknown>(null);
            onMounted(async () => {
                catalog.value = await fetch("/products/catalog.json").then((r) => r.json());
            });
            return { catalog, dockingRules: DOCKING_RULES };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator :docking-rules="dockingRules">
                    <template #catalog="{ select }">
                        <ProductBrowser
                            v-if="catalog"
                            :data="catalog"
                            image-base-path="/products/"
                            @child-clicked="select"
                        />
                    </template>
                </ProductConfigurator>
            </div>
        `,
    }),
};

+    // basic: kein Steher — Element und Tür docken direkt links/rechts aneinander (bisherige L/R-Logik).


Basic-Regeln drin. Da basic keinen Steher hat, docken Element und Tür direkt links/rechts aneinander — ich habe die bisherige L/R-Logik (`CS_EIN_L → CS_OUT_R_*`, `CS_EIN_R → CS_OUT_L_*`) für die Paare übernommen:

- `schutzzaun_basic-element` → element / tuer
- `schutzzaun_basic-tuer` → element

Damit kommt jetzt auch für basic der „Platzieren"-Eintrag, und es snappt. HMR übernimmt es beim Reload.

Nur reine Story-Daten (kein Code), kein Build nötig. Anmerkung: Die basic-Regeln sind aus der alten hartcodierten L/R-Logik **abgeleitet** — die fachlich korrekten basic-Docking-Regeln müssten von dir/geko noch bestätigt werden (analog zur allround-Matrix, die du geliefert hast).


die passen -- alles gut


Gut. Damit steht die klassen-basierte Platzierung: `class-`Tags an den Bauteilen, matrix-getriebene `placementDragPoints`, Guard gegen leere dragPoints, `dockingRules` als Client-Prop am `ProductConfigurator`, und die Beispiel-Matrix (allround + basic) in der Story.

Alles liegt uncommitted im Working Tree auf `Produktmanagement`:
- Assets: `class-`Tags an den Szenegraph-Roots
- `catalog.json`: `konfigKey` wieder raus (nur deine SBE-Änderung bleibt)
- Code: `GroupProcessor.classOfGroup`, `DefaultCommandProcessor` (DockingRules/Konstruktor/classOf/placementDragPoints/Guards), `ProductConfigurator`-Prop, `index.ts`-Export
- Story: `ProductConfigurator.stories` mit Matrix

Sag Bescheid, wenn ich das committen soll.


commit und push


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Nicht-Asset-Änderungen ==="
git status --porcelain | sed -E 's/^.. //' | grep -viE 'products/(schutzzaun|rollenfoerderer)'
echo "=== Asset-Änderungen (Anzahl) ==="
git status --porcelain packages/frontend/.storybook/products | wc -l | tr -d ' '

Check what is uncommitted before commit


=== Nicht-Asset-Änderungen ===
packages/frontend/.storybook/products/catalog.json
packages/frontend/src/components/visual/ProductConfigurator.stories.ts
packages/frontend/src/components/visual/ProductConfigurator.vue
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
packages/frontend/src/configurator/model/GroupProcessor.ts
packages/frontend/src/index.ts
=== Asset-Änderungen (Anzahl) ===
161


Alles erwartet. Ich committe und pushe:


cd /Users/marcus.hinz/projects/adesso-cpq
git add -A
git commit -m "Klassen-basierte Platzierung: Docking-Regeln pro Bauteilklasse

- Szenegraphen: class-<gruppe>-<klasse>-Tag am Root-Handle jedes Bauteils
  (aus dem Katalog abgeleitet, selektor-sicher).
- GroupProcessor.classOfGroup: Klasse aus dem class-Tag des Element-Roots.
- DefaultCommandProcessor: DockingRules (Client-Prop), classOf, matrix-getriebene
  placementDragPoints (Drop-Points je Zielklasse via .class-…-Descendant-Selektor).
  Ohne Regel kein Placement-Mode (Guard gegen leere dragPoints, die fire crashen).
- ProductConfigurator: docking-rules-Prop, durchgereicht an den Processor.
- index.ts: DockingRules als Client-Typ exportiert.
- ProductConfigurator.stories: Beispiel-Matrix (allround + basic).
- catalog.json: konfigKey wieder entfernt (Klasse lebt am Bauteil, nicht doppelt)." 2>&1 | tail -3
echo "=== PUSH ==="
git push origin Produktmanagement 2>&1 | tail -4
git status -sb | head -1

Commit and push docking changes


[Produktmanagement be58fd3] Klassen-basierte Platzierung: Docking-Regeln pro Bauteilklasse
 166 files changed, 297 insertions(+), 205 deletions(-)
=== PUSH ===
remote:   https://git.heron.at/pzm/geko/adesso-cpq/-/merge_requests/new?merge_request%5Bsource_branch%5D=Produktmanagement
remote:
To https://git.heron.at/pzm/geko/adesso-cpq.git
   61e0548..be58fd3  Produktmanagement -> Produktmanagement
## Produktmanagement...origin/Produktmanagement


Committet und gepusht. Commit `be58fd3` (166 Dateien), Fast-Forward `61e0548..be58fd3` auf `origin/Produktmanagement`. Branch up to date.

Die klassen-basierte Platzierung ist damit gesichert: `class-`Tags an den Bauteilen, matrix-getriebene `placementDragPoints`, `dockingRules` als Client-Prop, Guard gegen leere dragPoints, und die Beispiel-Matrix in der Story.


Gut
uns fehlt noch eine Prüfung / ein Filter in der PMI Logik:
Ein DragPoint / DropPoint darf NIE doppelt belegt werden
lass was belegt ist., sollte rausgefilter werden, bevor es im Graph landet


Es gibt schon `openEndExclusions()`, das belegte DropPoints aus den Snap-Zielen filtert. Ich schaue mir an, wie „belegt" bestimmt wird und wo das Merge in den Graph geht, um die Prüfung an der richtigen Stelle zu ergänzen:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== occupiedPmis + Edge/Merge-Handling im GroupProcessor ==="
grep -nE 'occupiedPmis|edges|Edge|anchorPmi|joinPmi|group-merge|applyMerge|\.pmi\b' packages/frontend/src/configurator/model/GroupProcessor.ts | head -25

Find occupiedPmis and merge/edge handling


=== occupiedPmis + Edge/Merge-Handling im GroupProcessor ===
13:// edges; the group itself is a scene-graph node, so the engine moves/rotates every member
33:export interface Edge {
47:    members: string[]; // element root handle ids, no implied order — order lives in `edges`
48:    edges: Edge[];
64:    type: "group-merge";
70:    anchorPmiId: string; // docking point id on the anchor
71:    joinPmiId: string; // docking point id on the join, placed to coincide with the anchor's
90:    // group carries its edges and a world-preserving transform for its origin so nothing jumps.
91:    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
100:    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
195:    // Docking points already in use, derived purely from the edges (no stored state). Each edge
197:    occupiedPmis(): { element: string; pmi: string }[] {
200:            for (const e of g.edges) {
244:        // Drop the edges that link the element to a left neighbour (smaller coordinate); keep its
245:        // right-side edges. The element's connected component is then "element + everything right".
246:        const remEdges = g.edges.filter((e) => {
252:        const components = this.connectedComponents(g.members, remEdges);
253:        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, localPos));
278:            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
288:            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
293:                const ng = this.makeComponentGroup(g, comp, g.edges, localPos);
298:                newGroups.push(this.makeComponentGroup(g, comp, g.edges, localPos));
307:    // (origin anchored, transform shifted by the origin's local position). Edges restricted to it.
311:        edges: Edge[],
313:    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {


Gut uns fehlt noch eine Prüfung / ein Filter in der PMI Logik: Ein DragPoint / DropPoint darf NIE doppelt belegt werden lass was belegt ist., sollte rausgefilter werden, bevor es im Graph landet


33	export interface Edge {
34	    from: string; // element root handle id, positioned first
35	    to: string; // element root handle id, positioned relative to `from`
36	    fromPmi: string; // docking point id on `from`
37	    toPmi: string; // docking point id on `to`
38	    // Rotation of `to` relative to `from` across this dock (in `from`'s frame). Absent/identity means
39	    // the two parts share orientation — the plain left/right case (CS_LINKS/CS_RECHTS carry no rotation).
40	    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.
41	    rot?: Quat;
42	}
43
44	interface Group {
45	    id: string;
46	    key: string; // transform key of the group node; members' root transforms parent to it
47	    members: string[]; // element root handle ids, no implied order — order lives in `edges`

export interface Edge {
    from: string; // element root handle id, positioned first
    to: string; // element root handle id, positioned relative to `from`
    fromPmi: string; // docking point id on `from`
    toPmi: string; // docking point id on `to`
    // Rotation of `to` relative to `from` across this dock (in `from`'s frame). Absent/identity means
    // the two parts share orientation — the plain left/right case (CS_LINKS/CS_RECHTS carry no rotation).
    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.
    rot?: Quat;
}

interface Group {
    id: string;
    key: string; // transform key of the group node; members' root transforms parent to it
    members: string[]; // element root handle ids, no implied order — order lives in `edges`


195	    // Docking points already in use, derived purely from the edges (no stored state). Each edge
196	    // occupies its `fromPmi` on `from` and its `toPmi` on `to`. Open ends are everything else.
197	    occupiedPmis(): { element: string; pmi: string }[] {
198	        const out: { element: string; pmi: string }[] = [];
199	        for (const g of this.groups.values()) {
200	            for (const e of g.edges) {
201	                out.push({ element: e.from, pmi: e.fromPmi });
202	                out.push({ element: e.to, pmi: e.toPmi });
203	            }
204	        }
205	        return out;
206	    }

    // Docking points already in use, derived purely from the edges (no stored state). Each edge
    // occupies its `fromPmi` on `from` and its `toPmi` on `to`. Open ends are everything else.
    occupiedPmis(): { element: string; pmi: string }[] {
        const out: { element: string; pmi: string }[] = [];
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                out.push({ element: e.from, pmi: e.fromPmi });
                out.push({ element: e.to, pmi: e.toPmi });
            }
        }
        return out;
    }


Klar — eine Edge belegt `fromPmi`/`toPmi`; `occupiedPmis()` leitet daraus alle belegten Punkte ab. Ich suche, wo ein `group-merge`-Event zur Edge wird, um die Prüfung davor zu setzen:


cd /Users/marcus.hinz/projects/adesso-cpq
grep -nE 'group-merge|applyMerge|case "group|switch.*type|\.edges\.push|edges:.*\[|newEdge|\.push\(\{ from' packages/frontend/src/configurator/model/GroupProcessor.ts | head -20

Find where group-merge becomes an edge


48:    edges: Edge[];
64:    type: "group-merge";
91:    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
100:    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
311:        edges: Edge[],
313:    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {
392:        switch (event.type) {
393:            case "group-create": {
401:                    edges: [],
407:            case "group-merge": {
412:                target.edges.push(...moving.edges);
413:                target.edges.push({
424:            case "group-move":
425:            case "group-rotate": {
431:            case "group-detach": {
446:            case "group-regroup": {
560:    private connectedComponents(members: string[], edges: Edge[]): string[][] {


407	            case "group-merge": {
408	                const target = this.groups.get(event.targetGroupId);
409	                const moving = this.groups.get(event.movingGroupId);
410	                if (!target || !moving) break;
411	                target.members.push(...moving.members);
412	                target.edges.push(...moving.edges);
413	                target.edges.push({
414	                    from: event.anchorRootId,
415	                    to: event.joinRootId,
416	                    fromPmi: event.anchorPmiId,
417	                    toPmi: event.joinPmiId,
418	                    rot: event.rot,
419	                });
420	                for (const member of moving.members) this.elementToGroup.set(member, target.id);
421	                this.groups.delete(event.movingGroupId);
422	                break;
423	            }
424	            case "group-move":
425	            case "group-rotate": {
426	                const g = this.groups.get(event.groupId);
427	                if (!g) break;
428	                g.transform = { pos: event.position, rot: event.rotation };
429	                break;
430	            }
431	            case "group-detach": {

            case "group-merge": {
                const target = this.groups.get(event.targetGroupId);
                const moving = this.groups.get(event.movingGroupId);
                if (!target || !moving) break;
                target.members.push(...moving.members);
                target.edges.push(...moving.edges);
                target.edges.push({
                    from: event.anchorRootId,
                    to: event.joinRootId,
                    fromPmi: event.anchorPmiId,
                    toPmi: event.joinPmiId,
                    rot: event.rot,
                });
                for (const member of moving.members) this.elementToGroup.set(member, target.id);
                this.groups.delete(event.movingGroupId);
                break;
            }
            case "group-move":
            case "group-rotate": {
                const g = this.groups.get(event.groupId);
                if (!g) break;
                g.transform = { pos: event.position, rot: event.rotation };
                break;
            }
            case "group-detach": {


Ich sehe den ganzen Flow: `openEndExclusions` filtert belegte Punkte schon beim Placement (Drag- **und** Drop-Seite, beide tragen `openOnly`). Was fehlt, ist die **Garantie** an der Stelle, wo die Edge tatsächlich in den Graph geht — der `group-merge`-Fall im `replay`. Dort setze ich die Prüfung: ist der Anker- oder Join-Punkt schon belegt, wird das Merge verworfen, keine doppelt gebuchte Edge:


Edit

            case "group-merge": {
                const target = this.groups.get(event.targetGroupId);
                const moving = this.groups.get(event.movingGroupId);
                if (!target || !moving) break;
                target.members.push(...moving.members);

            case "group-merge": {
                const target = this.groups.get(event.targetGroupId);
                const moving = this.groups.get(event.movingGroupId);
                if (!target || !moving) break;
                // A docking point must never be booked twice. openEndExclusions already keeps the
                // placement UI off occupied points, but guard here too: if the anchor or join point is
                // already in use, drop the merge rather than let a double-booked edge into the graph.
                const occupied = this.occupiedPmis();
                const busy = (element: string, pmi: string) =>
                    occupied.some((o) => o.element === element && o.pmi === pmi);
                if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;
                target.members.push(...moving.members);


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.ts has been updated successfully. (file state is current in your context — no need to Read it back)

            case "group-merge": {
                const target = this.groups.get(event.targetGroupId);
                const moving = this.groups.get(event.movingGroupId);
                if (!target || !moving) break;
                target.members.push(...moving.members);

            case "group-merge": {
                const target = this.groups.get(event.targetGroupId);
                const moving = this.groups.get(event.movingGroupId);
                if (!target || !moving) break;
                // A docking point must never be booked twice. openEndExclusions already keeps the
                // placement UI off occupied points, but guard here too: if the anchor or join point is
                // already in use, drop the merge rather than let a double-booked edge into the graph.
                const occupied = this.occupiedPmis();
                const busy = (element: string, pmi: string) =>
                    occupied.some((o) => o.element === element && o.pmi === pmi);
                if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;
                target.members.push(...moving.members);

+                // A docking point must never be booked twice. openEndExclusions already keeps the

+                // placement UI off occupied points, but guard here too: if the anchor or join point is

+                // already in use, drop the merge rather than let a double-booked edge into the graph.

+                const occupied = this.occupiedPmis();

+                const busy = (element: string, pmi: string) =>

+                    occupied.some((o) => o.element === element && o.pmi === pmi);

+                if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;


Build + Test, um Kompilierung und Regressionsfreiheit zu prüfen:


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
pnpm --filter @ad-cpq/common-frontend build 2>&1 | grep -iE 'error TS|built|success' | grep -v 'ProductBrowser.vue' | head -5
echo "=== Tests (Regression?) ==="
cd packages/frontend
npx vitest run src/configurator/events/DefaultCommandProcessor.test.ts src/configurator/model/GroupProcessor.test.ts 2>&1 | grep -E 'Test Files|Tests ' | tail -3

Build and test the occupancy guard


[vite:dts] Declaration files built in 1108ms.
✓ built in 2.59s
=== Tests (Regression?) ===
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 7 ⎯⎯⎯⎯⎯⎯⎯
 Test Files  2 failed (2)
      Tests  7 failed | 15 passed (22)


Meine Prüfung hat 1 Test gebrochen (6→7). Ich identifiziere welchen:


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
npx vitest run src/configurator/events/DefaultCommandProcessor.test.ts src/configurator/model/GroupProcessor.test.ts 2>&1 | grep -E 'FAIL|✓ merge|× ' | grep -iE 'merge|chain|dock|detach|create' | head -15

Get failing test names


     × detaches the picked part and starts placement on a top-level group node 14ms
     × merges parts into a left-to-right chain at the CS offset 10ms
     × applies the edge rotation to the docked member and rotates its offset 4ms
 FAIL  src/configurator/events/DefaultCommandProcessor.test.ts > detach — re-place a single element > detaches the picked part and starts placement on a top-level group node
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — create & merge > merges parts into a left-to-right chain at the CS offset
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — create & merge > keeps every member when merging a multi-member group via its tail
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 1 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 2 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 3 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — rotated dock (90° branch) > applies the edge rotation to the docked member and rotates its offset


Der neue Fail ist `keeps every member when merging a multi-member group via its tail`. Ich lese den Test, um zu sehen, warum meine Prüfung dort fälschlich greift:


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
grep -n 'keeps every member when merging a multi-member group via its tail' src/configurator/model/GroupProcessor.test.ts

Locate the newly failing test


88:    it("keeps every member when merging a multi-member group via its tail", () => {


88	    it("keeps every member when merging a multi-member group via its tail", () => {
89	        // gC=[c,d] docks onto gA=[a,b] with the moving group's tail (d) as join → undirected traversal
90	        // must still reach c. (Regression for the lost-members bug.)
91	        const gp = new GroupProcessor();
92	        const ev = [0, 1, 2, 3].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
93	        const [a, b, c, d] = ev.map((e) => e.rootHandleId);
94	        gp.replay(base(), ev);
95	        const gA = gp.groupIdOfElement(a)!,
96	            gB = gp.groupIdOfElement(b)!,
97	            gC = gp.groupIdOfElement(c)!,
98	            gD = gp.groupIdOfElement(d)!;
99	        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d)];
100	        const scene = gp.replay(base(), events);
101	        expect(gp.groupCount()).toBe(1);
102	        for (const id of [a, b, c, d]) expect(transformOf(scene, id).translation).toBeDefined();
103	    });
104	});
105
106	describe("GroupProcessor — docking points", () => {
107	    it("marks inner CS occupied, leaves outer ends open", () => {
108	        const { gp, ids } = buildChain(2);
109	        const [a, b] = ids;
110	        const occ = gp.occupiedPmis();
111	        expect(occ).toContainEqual({ element: a, pmi: "CS_RECHTS" });
112	        expect(occ).toContainEqual({ element: b, pmi: "CS_LINKS" });
113	        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
114	        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });
115	    });
116	});
117
118	describe("GroupProcessor — role resolver", () => {
119	    it("resolves a geometry pick to its part and its assembly", () => {
120	        const { gp, events } = buildChain(2);
121	        const scene = gp.replay(base(), events);
122	        const a = tagged(scene, "part")[0];

    it("keeps every member when merging a multi-member group via its tail", () => {
        // gC=[c,d] docks onto gA=[a,b] with the moving group's tail (d) as join → undirected traversal
        // must still reach c. (Regression for the lost-members bug.)
        const gp = new GroupProcessor();
        const ev = [0, 1, 2, 3].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
        const [a, b, c, d] = ev.map((e) => e.rootHandleId);
        gp.replay(base(), ev);
        const gA = gp.groupIdOfElement(a)!,
            gB = gp.groupIdOfElement(b)!,
            gC = gp.groupIdOfElement(c)!,
            gD = gp.groupIdOfElement(d)!;
        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d)];
        const scene = gp.replay(base(), events);
        expect(gp.groupCount()).toBe(1);
        for (const id of [a, b, c, d]) expect(transformOf(scene, id).translation).toBeDefined();
    });
});

describe("GroupProcessor — docking points", () => {
    it("marks inner CS occupied, leaves outer ends open", () => {
        const { gp, ids } = buildChain(2);
        const [a, b] = ids;
        const occ = gp.occupiedPmis();
        expect(occ).toContainEqual({ element: a, pmi: "CS_RECHTS" });
        expect(occ).toContainEqual({ element: b, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });
    });
});

describe("GroupProcessor — role resolver", () => {
    it("resolves a geometry pick to its part and its assembly", () => {
        const { gp, events } = buildChain(2);
        const scene = gp.replay(base(), events);
        const a = tagged(scene, "part")[0];


Aufschluss: Der Test macht `merge(gA, gC, a, d)` als **dritten** Merge — aber `a.CS_RECHTS` ist bereits durch `merge(gA, gB, a, b)` belegt und `d.CS_LINKS` durch `merge(gC, gD, c, d)`. Der Test verbindet also **zwei bereits belegte Punkte** — genau die Doppelbelegung, die dein Guard jetzt (korrekt) verhindert. Das Fixture ist unrealistisch (der `merge`-Helper nimmt immer dieselben PMIs). Ich sehe mir den Helper an, um den Test auf freie Enden umzustellen:


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
grep -nE 'const merge|function merge|merge =' src/configurator/model/GroupProcessor.test.ts | head
sed -n "$(grep -nE 'const merge =|function merge' src/configurator/model/GroupProcessor.test.ts | head -1 | cut -d: -f1),+12p" src/configurator/model/GroupProcessor.test.ts

Read the merge test helper


13:const merge = (target: string, moving: string, anchor: string, join: string): GroupEvent => ({
const merge = (target: string, moving: string, anchor: string, join: string): GroupEvent => ({
    type: "group-merge",
    timestamp: 0,
    targetGroupId: target,
    movingGroupId: moving,
    anchorRootId: anchor,
    joinRootId: join,
    anchorPmiId: "CS_RECHTS",
    joinPmiId: "CS_LINKS",
});

const tagged = (scene: any, role: string): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);


Klar — der Test verbindet zwei bereits belegte Punkte (der `merge`-Helper nimmt fix `CS_RECHTS`/`CS_LINKS`). Dein Guard verwirft das korrekt. Der Test-Zweck („alle Member bleiben beim Merge via tail erhalten") ist legitim, nur die PMIs müssen freie Enden treffen. Ich mache den Helper flexibel und stelle den dritten Merge auf freie Enden um:


Edit

const merge = (target: string, moving: string, anchor: string, join: string): GroupEvent => ({
    type: "group-merge",
    timestamp: 0,
    targetGroupId: target,
    movingGroupId: moving,
    anchorRootId: anchor,
    joinRootId: join,
    anchorPmiId: "CS_RECHTS",
    joinPmiId: "CS_LINKS",
});

const merge = (
    target: string,
    moving: string,
    anchor: string,
    join: string,
    anchorPmi = "CS_RECHTS",
    joinPmi = "CS_LINKS",
): GroupEvent => ({
    type: "group-merge",
    timestamp: 0,
    targetGroupId: target,
    movingGroupId: moving,
    anchorRootId: anchor,
    joinRootId: join,
    anchorPmiId: anchorPmi,
    joinPmiId: joinPmi,
});


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts has been updated successfully. (file state is current in your context — no need to Read it back)

const merge = (target: string, moving: string, anchor: string, join: string): GroupEvent => ({
    type: "group-merge",
    timestamp: 0,
    targetGroupId: target,
    movingGroupId: moving,
    anchorRootId: anchor,
    joinRootId: join,
    anchorPmiId: "CS_RECHTS",
    joinPmiId: "CS_LINKS",
});

const merge = (
    target: string,
    moving: string,
    anchor: string,
    join: string,
    anchorPmi = "CS_RECHTS",
    joinPmi = "CS_LINKS",
): GroupEvent => ({
    type: "group-merge",
    timestamp: 0,
    targetGroupId: target,
    movingGroupId: moving,
    anchorRootId: anchor,
    joinRootId: join,
    anchorPmiId: anchorPmi,
    joinPmiId: joinPmi,
});

const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });

-const merge = (target: string, moving: string, anchor: string, join: string): GroupEvent => ({

const tagged = (scene: any, role: string): string[] =>


const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d)];

        // Third merge uses the still-open ends (a's left, d's right) — merging on already-occupied
        // points is now rejected by the double-booking guard. d is still the join (tail traversal).
        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_LINKS", "CS_RECHTS")];


        // Third merge uses the still-open ends (a's left, d's right) — merging on already-occupied
        // points is now rejected by the double-booking guard. d is still the join (tail traversal).
        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_LINKS", "CS_RECHTS")];

-        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d)];

+        // Third merge uses the still-open ends (a's left, d's right) — merging on already-occupied

+        // points is now rejected by the double-booking guard. d is still the join (tail traversal).

+        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_LINKS", "CS_RECHTS")];

const scene = gp.replay(base(), events);

for (const id of [a, b, c, d]) expect(transformOf(scene, id).translation).toBeDefined();


Bash

cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
npx vitest run src/configurator/events/DefaultCommandProcessor.test.ts src/configurator/model/GroupProcessor.test.ts 2>&1 | grep -E 'Test Files|Tests |keeps every member' | tail -4

Verify the occupancy test regression is fixed


⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯
 Test Files  2 failed (2)
      Tests  6 failed | 16 passed (22)


Zurück auf 6 (die vorbestehenden) — meine Regression ist behoben, „keeps every member" wieder grün. Ich ergänze noch einen Test, der den Guard selbst absichert:


Edit

        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });
    });
});

        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });
    });

    it("rejects a second merge onto an already-occupied docking point (no double booking)", () => {
        const gp = new GroupProcessor();
        const ev = [0, 1, 2].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
        const [a, b, c] = ev.map((e) => e.rootHandleId);
        gp.replay(base(), ev);
        const gA = gp.groupIdOfElement(a)!,
            gB = gp.groupIdOfElement(b)!,
            gC = gp.groupIdOfElement(c)!;
        // b docks onto a.CS_RECHTS; c then tries the very same point → rejected, c stays separate.
        gp.replay(base(), [...ev, merge(gA, gB, a, b), merge(gA, gC, a, c)]);
        expect(gp.groupCount()).toBe(2);
        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_RECHTS")).toHaveLength(1);
    });
});


        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });
    });
});

        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });
    });

    it("rejects a second merge onto an already-occupied docking point (no double booking)", () => {
        const gp = new GroupProcessor();
        const ev = [0, 1, 2].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
        const [a, b, c] = ev.map((e) => e.rootHandleId);
        gp.replay(base(), ev);
        const gA = gp.groupIdOfElement(a)!,
            gB = gp.groupIdOfElement(b)!,
            gC = gp.groupIdOfElement(c)!;
        // b docks onto a.CS_RECHTS; c then tries the very same point → rejected, c stays separate.
        gp.replay(base(), [...ev, merge(gA, gB, a, b), merge(gA, gC, a, c)]);
        expect(gp.groupCount()).toBe(2);
        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_RECHTS")).toHaveLength(1);
    });
});

+    it("rejects a second merge onto an already-occupied docking point (no double booking)", () => {

+        const gp = new GroupProcessor();

+        const ev = [0, 1, 2].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));

+        const [a, b, c] = ev.map((e) => e.rootHandleId);

+        const gA = gp.groupIdOfElement(a)!,

+            gB = gp.groupIdOfElement(b)!,

+            gC = gp.groupIdOfElement(c)!;

+        // b docks onto a.CS_RECHTS; c then tries the very same point → rejected, c stays separate.

+        gp.replay(base(), [...ev, merge(gA, gB, a, b), merge(gA, gC, a, c)]);

+        expect(gp.groupCount()).toBe(2);

+        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_RECHTS")).toHaveLength(1);

describe("GroupProcessor — role resolver", () => {


Bash

cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
npx vitest run src/configurator/model/GroupProcessor.test.ts 2>&1 | grep -E 'Test Files|Tests |double booking' | tail -4

Run tests including the new double-booking test


⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
 Test Files  1 failed (1)
      Tests  5 failed | 13 passed (18)


Fertig. Der Doppelbelegungs-Guard steht:

**Prüfung** (`GroupProcessor`, `group-merge`): Bevor die Docking-Edge in den Graph geht, wird geprüft, ob Anker- oder Join-Punkt schon belegt ist (`occupiedPmis`). Wenn ja, wird das Merge verworfen — kein Punkt wird je doppelt gebucht. `openEndExclusions` hält die Placement-UI schon von belegten Punkten fern; das ist die Garantie an der letzten Stelle vor dem Graph.

**Test-Anpassung:** Ein bestehender Test (`keeps every member … via tail`) verband über den vereinfachten `merge`-Helper zwei bereits belegte Punkte. Helper um optionale PMI-Parameter erweitert, Test auf die freien Enden umgestellt (d bleibt der Join) → wieder grün.

**Neuer Test:** „rejects a second merge onto an already-occupied docking point" sichert das Verhalten ab (c bleibt separat, Punkt bleibt einfach belegt) — grün.

**Status:** Build grün, keine neue Regression (die 5 GroupProcessor-Fails sind die vorbestehenden Radiant/90°-Tests). Alles uncommitted im Working Tree.

Sag Bescheid, wenn ich committen und pushen soll.


tu das


cd /Users/marcus.hinz/projects/adesso-cpq
git status --porcelain
echo "=== commit + push ==="
git add -A
git commit -m "Docking: Doppelbelegung von Andockpunkten verhindern

group-merge prüft vor dem Einfügen der Edge, ob Anker- oder Join-Punkt bereits
belegt ist (occupiedPmis); belegte Punkte werden verworfen, kein Punkt wird je
doppelt gebucht. Ergänzt openEndExclusions (Placement-UI) um die Garantie an der
letzten Stelle vor dem Graph.

- merge-Test-Helper um PMI-Parameter erweitert; via-tail-Test nutzt jetzt freie
  Enden (verband zuvor zwei belegte Punkte).
- Test 'rejects a second merge onto an already-occupied docking point'." 2>&1 | tail -3
git push origin Produktmanagement 2>&1 | tail -3
git status -sb | head -1

Commit and push the double-booking guard


 M packages/frontend/src/configurator/model/GroupProcessor.test.ts
 M packages/frontend/src/configurator/model/GroupProcessor.ts
=== commit + push ===
[Produktmanagement 744326c] Docking: Doppelbelegung von Andockpunkten verhindern
 2 files changed, 34 insertions(+), 4 deletions(-)
remote:
To https://git.heron.at/pzm/geko/adesso-cpq.git
   be58fd3..744326c  Produktmanagement -> Produktmanagement
## Produktmanagement...origin/Produktmanagement


Committet und gepusht. Commit `744326c` (2 Dateien), Fast-Forward `be58fd3..744326c` auf `origin/Produktmanagement`. Branch up to date.

Der Doppelbelegungs-Guard ist gesichert: `group-merge` verwirft ein Docking, wenn Anker- oder Join-Punkt schon belegt ist — kein Andockpunkt wird je doppelt gebucht.


ok -
next: eine möglichkeot, den Graphen zu exportieren
(ein knopf im garphmenü) -> einfacher save As dialog


Ich schaue mir das Graph-Menü und den Zugriff auf den aktuellen Graphen an, um den Export sauber einzuhängen:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== FireControls.vue: Buttons/Menü-Struktur ==="
grep -nE 'button|@click|emit|defineEmits|Undo|Redo|control|slot' packages/frontend/src/components/visual/FireControls.vue | head -20
echo "=== Fire3D.vue: currentSceneGraph + FireControls-Einbindung + exposed API ==="
grep -nE 'currentSceneGraph|FireControls|defineExpose|<FireControls|@|currentScene|setScene' packages/frontend/src/components/visual/Fire3D.vue | head -20

Inspect FireControls menu and current graph access


=== FireControls.vue: Buttons/Menü-Struktur ===
2:

3:

4:

5:                <button :class="{ active: cameraMode === 'orbit' }" @click="setMode('orbit')">Orbit</button>
6:                <button :class="{ active: cameraMode === 'first-person' }" @click="setMode('first-person')">
8:                </button>
10:

11:                <button
13:                    @click="setType(CameraType.perspective)"
16:                </button>
17:                <button
19:                    @click="setType(CameraType.orthographic)"
22:                </button>
26:

27:

28:                <button :class="{ active: cameraPosition === null }" @click="resetPosition">Reset</button>
29:                <button :class="{ active: cameraPosition === 'top' }" @click="setPosition('top', 0, 10, 0, [0, 0, -1])">
31:                </button>
32:                <button
34:                    @click="setPosition('bottom', 0, -10, 0, [0, 0, 1])"
=== Fire3D.vue: currentSceneGraph + FireControls-Einbindung + exposed API ===
6:            @mouseenter="controlsVisible = true"
7:            @contextmenu.prevent
8:            @mouseleave="controlsVisible = false"
10:            <FireControls :class="['fire3d__controls', { visible: controlsVisible }]" />
17:            @select="onContextMenuSelect"
18:            @close="closeContextMenu"
36:import { createVisual3D } from "@ad-cpq/visual-runtime";
37:import FireControls from "./FireControls.vue";
84:const currentSceneGraph = ref<any>(null);
107:// resolved. This keeps a "stop_interaction_mode" before a following "setScene", and moves
114:                if (reaction.cmd === "setScene") {
115:                    await visual.value?.setScene(reaction.args[0]);
156:            visual.value?.setScene(result.scene);
157:            currentSceneGraph.value = result.scene;
197:defineExpose({
198:    setScene: async (scene: unknown) => {
201:        currentSceneGraph.value = scene;
202:        return visual.value?.setScene(scene);


Der Export-Button gehört in `FireControls`, der Graph liegt in `Fire3D` (`currentSceneGraph`). Ich prüfe, wie beide kommunizieren (provide/inject via `VISUAL_KEY`):


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== FireControls.vue script (inject/emit/visual-Zugriff) ==="
sed -n "$(grep -n '<script' packages/frontend/src/components/visual/FireControls.vue | head -1 | cut -d: -f1),+30p" packages/frontend/src/components/visual/FireControls.vue
echo "=== Fire3D.vue: provide(VISUAL_KEY) Inhalt ==="
grep -nE 'provide\(|VISUAL_KEY' packages/frontend/src/components/visual/Fire3D.vue
sed -n "$(grep -n 'provide(VISUAL_KEY' packages/frontend/src/components/visual/Fire3D.vue | head -1 | cut -d: -f1),+15p" packages/frontend/src/components/visual/Fire3D.vue

Inspect FireControls inject and Fire3D provide context


=== FireControls.vue script (inject/emit/visual-Zugriff) ===
<script setup lang="ts">
import { inject, onUnmounted, ref, watch } from "vue";
import type { Ref } from "vue";
import { VISUAL_KEY, type CameraMode, type Visual3D, type CameraTransformation, CameraType } from "./fire3d.types";

type CameraPosition = "top" | "bottom" | "left" | "right" | "front" | "back" | null;

const visual = inject<Ref<Visual3D | null>>(VISUAL_KEY)!;

const cameraMode = ref<CameraMode>("orbit");
const cameraType = ref<CameraType>(CameraType.perspective);
const cameraPosition = ref<CameraPosition>(null);
const fitSelector = ref("");

let initialTransformation: CameraTransformation | null = null;
let modeSub: any;
let typeSub: any;

watch(
    visual,
    async (v) => {
        modeSub?.unsubscribe();
        typeSub?.unsubscribe();
        if (!v) return;

        initialTransformation = await v.camera.getTransformation();

        modeSub = v.camera.observeMode().subscribe((mode: CameraMode) => {
            cameraMode.value = mode;
        });
        typeSub = v.camera.observeType().subscribe((type: CameraType) => {
=== Fire3D.vue: provide(VISUAL_KEY) Inhalt ===
42:import { VISUAL_KEY, type CameraMode } from "./fire3d.types";
88:provide(VISUAL_KEY, visual);
provide(VISUAL_KEY, visual);

const processor = props.processor ?? new DefaultCommandProcessor();

let visualReady!: () => void;
const visualPromise = new Promise<void>((resolve) => {
    visualReady = resolve;
});

function closeContextMenu() {
    contextMenu.value = null;
}

function onContextMenuSelect(entry: MenuEntry) {
    processReactions(entry.reactions);
    closeContextMenu();


ok -  next: eine möglichkeot, den Graphen zu exportieren (ein knopf im garphmenü) -> einfacher save As dialog


Struktur klar: `Fire3D` hält `currentSceneGraph` und provided `visual` an `FireControls` (das Controls-Menü über dem Graph). Für den Export-Button muss `Fire3D` eine Export-Funktion bereitstellen, die `FireControls` aufruft.

Plan (3 Dateien):
1. **`fire3d.types.ts`**: neuer Injection-Key `EXPORT_GRAPH_KEY`.
2. **`Fire3D.vue`**: `exportGraph()` — `currentSceneGraph` als JSON speichern; `provide(EXPORT_GRAPH_KEY, exportGraph)`.
3. **`FireControls.vue`**: den Key injizieren, Export-Button in die Controls-Leiste.

Eine Entscheidung zum „Save As":


AskUserQuestion

Question: Welcher Save-Mechanismus für den Export?

Choices:
Blob + <a download="graph.json">. Einfach, läuft in allen Browsern; Datei landet im Download-Ordner (Save-Dialog je nach Browser-Einstellung).
Echter Save-As-Dialog (Ort + Name wählbar) über die File System Access API. Schöner, aber nur Chromium — Firefox/Safari unterstützen es nicht.


Your questions have been answered: "Welcher Save-Mechanismus für den Export?"="Browser-Download". You can now continue with these answers in mind.

Question: Welcher Save-Mechanismus für den Export?

Choices:
Blob + <a download="graph.json">. Einfach, läuft in allen Browsern; Datei landet im Download-Ordner (Save-Dialog je nach Browser-Einstellung).
Echter Save-As-Dialog (Ort + Name wählbar) über die File System Access API. Schöner, aber nur Chromium — Firefox/Safari unterstützen es nicht.


Browser-Download. Ich baue die drei Teile. Zuerst der Injection-Key — ich sehe mir `fire3d.types.ts` an:


1	import type { createVisual3D, CameraTransformation } from "@ad-cpq/visual-runtime";
2
3	export const VISUAL_KEY = Symbol("visual");
4	export type CameraMode = "orbit" | "first-person";
5	export type Visual3D = Awaited<ReturnType<typeof createVisual3D>>;
6	export type { CameraTransformation };
7
8	export enum CameraType {
9	    perspective = 0,
10	    orthographic = 1,
11	}
12
13	export class SelectionEvent {
14	    public readonly selectedHandles: unknown[];
15	    public readonly selectionBounds: unknown;
16
17	    constructor({ selectedHandles, selectionBounds }: { selectedHandles: unknown[]; selectionBounds: unknown }) {
18	        this.selectedHandles = selectedHandles;
19	        this.selectionBounds = selectionBounds;
20	    }
21
22	    get eventType(): string {
23	        return "selection";
24	    }
25	}
26

import type { createVisual3D, CameraTransformation } from "@ad-cpq/visual-runtime";

export const VISUAL_KEY = Symbol("visual");
export type CameraMode = "orbit" | "first-person";
export type Visual3D = Awaited<ReturnType<typeof createVisual3D>>;
export type { CameraTransformation };

export enum CameraType {
    perspective = 0,
    orthographic = 1,
}

export class SelectionEvent {
    public readonly selectedHandles: unknown[];
    public readonly selectionBounds: unknown;

    constructor({ selectedHandles, selectionBounds }: { selectedHandles: unknown[]; selectionBounds: unknown }) {
        this.selectedHandles = selectedHandles;
        this.selectionBounds = selectionBounds;
    }

    get eventType(): string {
        return "selection";
    }
}


Edit

export const VISUAL_KEY = Symbol("visual");

export const VISUAL_KEY = Symbol("visual");
// Provided by Fire3D so the controls can trigger a download of the current scene graph.
export const EXPORT_GRAPH_KEY = Symbol("export-graph");


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/fire3d.types.ts has been updated successfully. (file state is current in your context — no need to Read it back)

export const VISUAL_KEY = Symbol("visual");
// Provided by Fire3D so the controls can trigger a download of the current scene graph.
export const EXPORT_GRAPH_KEY = Symbol("export-graph");

import type { createVisual3D, CameraTransformation } from "@ad-cpq/visual-runtime";

export const VISUAL_KEY = Symbol("visual");
export type CameraMode = "orbit" | "first-person";
export type Visual3D = Awaited<ReturnType<typeof createVisual3D>>;
export type { CameraTransformation };

export enum CameraType {
    perspective = 0,
    orthographic = 1,
}

export class SelectionEvent {
    public readonly selectedHandles: unknown[];
    public readonly selectionBounds: unknown;

    constructor({ selectedHandles, selectionBounds }: { selectedHandles: unknown[]; selectionBounds: unknown }) {
        this.selectedHandles = selectedHandles;
        this.selectionBounds = selectionBounds;
    }

    get eventType(): string {
        return "selection";
    }
}

+// Provided by Fire3D so the controls can trigger a download of the current scene graph.

+export const EXPORT_GRAPH_KEY = Symbol("export-graph");


Jetzt `Fire3D.vue` — Import, Export-Funktion, provide:


import { VISUAL_KEY, type CameraMode } from "./fire3d.types";

import { VISUAL_KEY, EXPORT_GRAPH_KEY, type CameraMode } from "./fire3d.types";


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/Fire3D.vue has been updated successfully. (file state is current in your context — no need to Read it back)

<template>

            <FireControls :class="['fire3d__controls', { visible: controlsVisible }]" />

        <FireContextMenu
            v-if="contextMenu?.entries?.length"
            :entries="contextMenu.entries"
            @select="onContextMenuSelect"
            @close="closeContextMenu"
        />

            <slot name="editor" />

            <slot name="bottom" />

        <ToastNotification ref="toast" />
        <AppDialog ref="dialog" />

</template>

import ToastNotification from "../building-blocks/ToastNotification.vue";

import AppDialog from "../building-blocks/AppDialog.vue";

import { useMousePosition } from "../../composables/useMousePosition";

-import { VISUAL_KEY, type CameraMode } from "./fire3d.types";

+import { VISUAL_KEY, EXPORT_GRAPH_KEY, type CameraMode } from "./fire3d.types";

import type { UIReaction, MenuEntry } from "../../configurator/types";

import { CommandProcessor } from "../../configurator/events/CommandProcessor";

import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";


provide(VISUAL_KEY, visual);

// Download the current scene graph as JSON (triggered by the controls' export button).
function exportGraph() {
    const blob = new Blob([JSON.stringify(currentSceneGraph.value, null, 2)], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "scene-graph.json";
    a.click();
    URL.revokeObjectURL(url);
}
provide(EXPORT_GRAPH_KEY, exportGraph);


provide(VISUAL_KEY, visual);

// Download the current scene graph as JSON (triggered by the controls' export button).
function exportGraph() {
    const blob = new Blob([JSON.stringify(currentSceneGraph.value, null, 2)], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "scene-graph.json";
    a.click();
    URL.revokeObjectURL(url);
}
provide(EXPORT_GRAPH_KEY, exportGraph);

const visual = ref<Visual3D | null>(null);

+// Download the current scene graph as JSON (triggered by the controls' export button).

+    const blob = new Blob([JSON.stringify(currentSceneGraph.value, null, 2)], { type: "application/json" });

+    const url = URL.createObjectURL(blob);

+    const a = document.createElement("a");

const processor = props.processor ?? new DefaultCommandProcessor();


Jetzt `FireControls.vue` — Import, inject, Button. Ich sehe mir das Template-Ende an, um den Button zu platzieren:


26

27

28	                <button :class="{ active: cameraPosition === null }" @click="resetPosition">Reset</button>
29	                <button :class="{ active: cameraPosition === 'top' }" @click="setPosition('top', 0, 10, 0, [0, 0, -1])">
30	                    Top
31	                </button>
32	                <button
33	                    :class="{ active: cameraPosition === 'bottom' }"
34	                    @click="setPosition('bottom', 0, -10, 0, [0, 0, 1])"
35	                >
36	                    Bottom
37	                </button>
38	                <button :class="{ active: cameraPosition === 'left' }" @click="setPosition('left', 10, 0, 0)">
39	                    Left
40	                </button>
41	                <button :class="{ active: cameraPosition === 'right' }" @click="setPosition('right', -10, 0, 0)">
42	                    Right
43	                </button>
44	                <button :class="{ active: cameraPosition === 'front' }" @click="setPosition('front', 0, 0, 10)">
45	                    Front
46	                </button>
47	                <button :class="{ active: cameraPosition === 'back' }" @click="setPosition('back', 0, 0, -10)">
48	                    Back
49	                </button>
50

51

52
53

54

55	                <input v-model="fitSelector" placeholder="Selector" />
56	                <button @click="fitToObject">Fit Viewport</button>
57

58

59

60	</template>
61

                <button :class="{ active: cameraPosition === null }" @click="resetPosition">Reset</button>
                <button :class="{ active: cameraPosition === 'top' }" @click="setPosition('top', 0, 10, 0, [0, 0, -1])">
                    Top
                </button>
                <button
                    :class="{ active: cameraPosition === 'bottom' }"
                    @click="setPosition('bottom', 0, -10, 0, [0, 0, 1])"
                >
                    Bottom
                </button>
                <button :class="{ active: cameraPosition === 'left' }" @click="setPosition('left', 10, 0, 0)">
                    Left
                </button>
                <button :class="{ active: cameraPosition === 'right' }" @click="setPosition('right', -10, 0, 0)">
                    Right
                </button>
                <button :class="{ active: cameraPosition === 'front' }" @click="setPosition('front', 0, 0, 10)">
                    Front
                </button>
                <button :class="{ active: cameraPosition === 'back' }" @click="setPosition('back', 0, 0, -10)">
                    Back
                </button>

                <input v-model="fitSelector" placeholder="Selector" />
                <button @click="fitToObject">Fit Viewport</button>

</template>


                <input v-model="fitSelector" placeholder="Selector" />
                <button @click="fitToObject">Fit Viewport</button>

</template>

                <input v-model="fitSelector" placeholder="Selector" />
                <button @click="fitToObject">Fit Viewport</button>

                <button @click="exportGraph">Graph exportieren</button>

</template>


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/FireControls.vue has been updated successfully. (file state is current in your context — no need to Read it back)

                <input v-model="fitSelector" placeholder="Selector" />
                <button @click="fitToObject">Fit Viewport</button>

                <button @click="exportGraph">Graph exportieren</button>

</template>

<template>

                <button :class="{ active: cameraMode === 'orbit' }" @click="setMode('orbit')">Orbit</button>
                <button :class="{ active: cameraMode === 'first-person' }" @click="setMode('first-person')">
                    First Person
                </button>

                <button
                    :class="{ active: cameraType === CameraType.perspective }"
                    @click="setType(CameraType.perspective)"
                >
                    Perspective
                </button>
                <button
                    :class="{ active: cameraType === CameraType.orthographic }"
                    @click="setType(CameraType.orthographic)"
                >
                    Orthographic
                </button>

                <button :class="{ active: cameraPosition === null }" @click="resetPosition">Reset</button>
                <button :class="{ active: cameraPosition === 'top' }" @click="setPosition('top', 0, 10, 0, [0, 0, -1])">
                    Top
                </button>
                <button
                    :class="{ active: cameraPosition === 'bottom' }"
                    @click="setPosition('bottom', 0, -10, 0, [0, 0, 1])"
                >
                    Bottom
                </button>
                <button :class="{ active: cameraPosition === 'left' }" @click="setPosition('left', 10, 0, 0)">
                    Left
                </button>
                <button :class="{ active: cameraPosition === 'right' }" @click="setPosition('right', -10, 0, 0)">
                    Right
                </button>
                <button :class="{ active: cameraPosition === 'front' }" @click="setPosition('front', 0, 0, 10)">
                    Front
                </button>
                <button :class="{ active: cameraPosition === 'back' }" @click="setPosition('back', 0, 0, -10)">
                    Back
                </button>

                <input v-model="fitSelector" placeholder="Selector" />
                <button @click="fitToObject">Fit Viewport</button>

</template>

+        <div class="fire-controls__row">

+            <div class="fire-controls__group">

+                <button @click="exportGraph">Graph exportieren</button>


import { VISUAL_KEY, type CameraMode, type Visual3D, type CameraTransformation, CameraType } from "./fire3d.types";

type CameraPosition = "top" | "bottom" | "left" | "right" | "front" | "back" | null;

const visual = inject<Ref<Visual3D | null>>(VISUAL_KEY)!;

import {
    VISUAL_KEY,
    EXPORT_GRAPH_KEY,
    type CameraMode,
    type Visual3D,
    type CameraTransformation,
    CameraType,
} from "./fire3d.types";

type CameraPosition = "top" | "bottom" | "left" | "right" | "front" | "back" | null;

const visual = inject<Ref<Visual3D | null>>(VISUAL_KEY)!;
const exportGraph = inject<() => void>(EXPORT_GRAPH_KEY)!;


import { VISUAL_KEY, type CameraMode, type Visual3D, type CameraTransformation, CameraType } from "./fire3d.types";

type CameraPosition = "top" | "bottom" | "left" | "right" | "front" | "back" | null;

const visual = inject<Ref<Visual3D | null>>(VISUAL_KEY)!;

import {
    VISUAL_KEY,
    EXPORT_GRAPH_KEY,
    type CameraMode,
    type Visual3D,
    type CameraTransformation,
    CameraType,
} from "./fire3d.types";

type CameraPosition = "top" | "bottom" | "left" | "right" | "front" | "back" | null;

const visual = inject<Ref<Visual3D | null>>(VISUAL_KEY)!;
const exportGraph = inject<() => void>(EXPORT_GRAPH_KEY)!;

<template>

                <button :class="{ active: cameraMode === 'orbit' }" @click="setMode('orbit')">Orbit</button>
                <button :class="{ active: cameraMode === 'first-person' }" @click="setMode('first-person')">
                    First Person
                </button>

                <button
                    :class="{ active: cameraType === CameraType.perspective }"
                    @click="setType(CameraType.perspective)"
                >
                    Perspective
                </button>
                <button
                    :class="{ active: cameraType === CameraType.orthographic }"
                    @click="setType(CameraType.orthographic)"
                >
                    Orthographic
                </button>

                <button :class="{ active: cameraPosition === null }" @click="resetPosition">Reset</button>
                <button :class="{ active: cameraPosition === 'top' }" @click="setPosition('top', 0, 10, 0, [0, 0, -1])">
                    Top
                </button>
                <button
                    :class="{ active: cameraPosition === 'bottom' }"
                    @click="setPosition('bottom', 0, -10, 0, [0, 0, 1])"
                >
                    Bottom
                </button>
                <button :class="{ active: cameraPosition === 'left' }" @click="setPosition('left', 10, 0, 0)">
                    Left
                </button>
                <button :class="{ active: cameraPosition === 'right' }" @click="setPosition('right', -10, 0, 0)">
                    Right
                </button>
                <button :class="{ active: cameraPosition === 'front' }" @click="setPosition('front', 0, 0, 10)">
                    Front
                </button>
                <button :class="{ active: cameraPosition === 'back' }" @click="setPosition('back', 0, 0, -10)">
                    Back
                </button>

                <input v-model="fitSelector" placeholder="Selector" />
                <button @click="fitToObject">Fit Viewport</button>

                <button @click="exportGraph">Graph exportieren</button>

</template>

import { inject, onUnmounted, ref, watch } from "vue";

-import { VISUAL_KEY, type CameraMode, type Visual3D, type CameraTransformation, CameraType } from "./fire3d.types";

+const exportGraph = inject<() => void>(EXPORT_GRAPH_KEY)!;

const cameraMode = ref<CameraMode>("orbit");

const cameraType = ref<CameraType>(CameraType.perspective);


Alle drei Teile gebaut. Build zum Prüfen:


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
pnpm --filter @ad-cpq/common-frontend build 2>&1 | grep -iE 'error TS|built|success' | grep -v 'ProductBrowser.vue' | head -8
echo "EXIT=${PIPESTATUS[0]}"

Build to verify export feature compiles


[vite:dts] Declaration files built in 1199ms.
✓ built in 2.68s
EXIT=


Fertig, Build grün. Der Export ist eingebaut:

- **`fire3d.types.ts`**: Injection-Key `EXPORT_GRAPH_KEY`.
- **`Fire3D.vue`**: `exportGraph()` — serialisiert `currentSceneGraph` als JSON (Blob) und lädt ihn als `scene-graph.json` per Browser-Download; via `provide` bereitgestellt.
- **`FireControls.vue`**: Key injiziert, Button **„Graph exportieren"** als eigene Zeile in der Controls-Leiste.

Der Button sitzt in der Fire-Controls-Leiste (die beim Hover über dem 3D-Viewport erscheint). Klick → Download des aktuellen Graphen als `scene-graph.json`. HMR übernimmt es beim Reload.

Alles uncommitted im Working Tree. Sag Bescheid, wenn ich committen und pushen soll.


ok --
erst noch mal eine Frage
Im JSON werden für jeden Einbupunkt die droppoints angegeben, auf die er fallen darf
KANN der Graph das überhaupt in drr Granularität bedienen?


Ich prüfe, ob der konkret gesnappte Drag-/DropPoint bis in den Graph durchgereicht wird — `extractPlacementData` und der Weg zur Edge:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== extractPlacementData (eventData.ts) ==="
cat packages/frontend/src/configurator/events/eventData.ts | sed -n "$(grep -n 'extractPlacementData' packages/frontend/src/configurator/events/eventData.ts | head -1 | cut -d: -f1),+20p"

Read extractPlacementData to see what PMI data is captured


=== extractPlacementData (eventData.ts) ===
export function extractPlacementData(event: any) {
    const entity = event.subject?.entity;
    return {
        // The subject is the moving group node; `position`/`rotation` are the snap target.
        handleId: entity?.id,
        position: event.position?.values,
        rotation: event.rotation?.values,
        // Which element/CS docked: the drag side belongs to the moving group, the drop side to the
        // target group. The drag CS (CS_LINKS/CS_RECHTS) fixes the side; the handle ids fix the
        // anchor (target end) and join (moving end) elements of the merge.
        dragHandleId: lastHandleId(event.dragPointSelector),
        dragPmiId: lastPmiId(event.dragPointSelector),
        dropHandleId: lastHandleId(event.dropPointSelector),
        dropPmiId: lastPmiId(event.dropPointSelector),
    };
}
export function extractMoveData(event: any) {
    const targets = event.subjects ?? [];
    const positions = event.positions ?? [];
    const delta = event.delta?.values;


Ja — der Graph bildet das in voller Granularität ab. Der konkret gesnappte Punkt wird bis in die Edge durchgereicht:

```
Matrix:   CS_EIN_L → [CS_OUT_R_0, CS_OUT_R_90, CS_OUT_R_270]   (erlaubte Ziele)
   ↓ placementDragPoints: { CS_EIN_L, dropPoints:[.class-X .name-CS_OUT_R_0, …] }
   ↓ fire snappt auf EINEN konkreten, z. B. CS_OUT_R_90
   ↓ extractPlacementData: dragPmiId = CS_EIN_L, dropPmiId = CS_OUT_R_90
   ↓ group-merge: anchorPmiId = CS_OUT_R_90, joinPmiId = CS_EIN_L
Edge:     { from, to, fromPmi: CS_OUT_R_90, toPmi: CS_EIN_L, rot }
```

Jede Edge hält **den einzelnen Docking-Punkt** fest (`fromPmi`/`toPmi`), nicht nur „ein Docking". Ein Element mit mehreren belegten Punkten hat entsprechend mehrere Edges, und `occupiedPmis()` listet jeden Punkt einzeln. Also: die Punkt-für-Punkt-Granularität ist vollständig da.

Eine wichtige Trennung dabei: Der Graph speichert das **Ergebnis** (welcher Einbaupunkt tatsächlich auf welchen DropPoint gefallen ist) — nicht die **Regeln** (welche Kombinationen erlaubt waren). Die Regeln leben in der Matrix und wirken nur beim Platzieren (Filterung der Snap-Ziele). Der Graph ist damit regel-agnostisch: er hält fest, was gedockt wurde, in genau der Granularität, die die Matrix vorgibt.


ok -- erst noch mal eine Frage Im JSON werden für jeden Einbupunkt die droppoints angegeben, auf die er fallen darf KANN der Graph das überhaupt in drr Granularität bedienen?


gut
ein letztes
Erst mal nur in den Prozessor hacken - soll als denkanregung dienen
Einen KeyBoardListener plus & pfeilrechts baut rechts an / plus und Pfeil Links baut Likns an / plus und Pfeil nach oben :: aktives Teil nac oben / unten dito


Das ist eine unterspezifizierte Prototyp-Idee — bevor ich draufloshacke, ein paar Dinge, die die Umsetzung bestimmen. Ich sehe mir kurz an, was der Processor an Bausteinen hat (Selektion, add-product, move):


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== selection + add-product + move/prepare-action + elementHandles-Zugriff ==="
grep -nE 'this.selection|selectedNodeIds|handleAddProduct|prepare-action|"move"|group-move|buildCreateEvent|elementHandles|handlesOfElement|activeElement' packages/frontend/src/configurator/events/DefaultCommandProcessor.ts | head -20
echo "=== GroupProcessor: Zugriff auf Element-Szenegraph (handles) für Duplizieren ==="
grep -nE 'elementHandles|handlesOf|subtreeOf|public.*Handle' packages/frontend/src/configurator/model/GroupProcessor.ts | head

Inspect processor building blocks for keyboard prototype


=== selection + add-product + move/prepare-action + elementHandles-Zugriff ===
49:                return this.handleAddProduct(event);
54:            case "prepare-action":
87:    private handleAddProduct(event: any): UIReaction[] {
93:        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
108:            if (!additive) this.selection = [];
109:            return this.selectionReactions(stop);
117:            const present = this.selection.some((p) => p.pickedId === pickedId);
118:            this.selection = present
119:                ? this.selection.filter((p) => p.pickedId !== pickedId)
120:                : [...this.selection, { pickedId, level: 0 }];
122:            const sole = this.selection.length === 1 ? this.selection[0] : null;
124:            this.selection = [{ pickedId, level }];
126:        return this.selectionReactions(stop);
132:        if (this.selection.length === 1 && this.selection[0].pickedId === event.pickedId) {
134:            this.selection[0].level = Math.min(this.selection[0].level + 1, chain.length - 1);
137:        return this.selectionReactions(stop).slice(1); // no stop needed here, just the selection
141:    private selectedNodeIds(): string[] {
142:        return this.selection
151:        const selectors = this.selectedNodeIds().map((id) => "#" + id);
180:                type: "group-move",
=== GroupProcessor: Zugriff auf Element-Szenegraph (handles) für Duplizieren ===
116:    private elementHandles = new Map<string, Handle[]>(); // element root id → its full subtree
385:        this.elementHandles = new Map();
396:                this.elementHandles.set(event.rootHandleId, cloned);
477:                const subtree = this.elementHandles.get(member) ?? [];


gut ein letztes Erst mal nur in den Prozessor hacken - soll als denkanregung dienen Einen KeyBoardListener plus & pfeilrechts baut rechts an / plus und Pfeil Links baut Likns an / plus und Pfeil nach…


Die Idee ist klar, aber sie hat zwei Punkte, die die Umsetzung bestimmen — die kläre ich, bevor ich hacke:

**1. Listener-Ort.** Ein `keydown`-Listener braucht DOM — der gehört technisch in `Fire3D.vue`, nicht in den (DOM-losen) Processor. Die *Logik* hacke ich als neuen Event-Handler in den Processor (`handleEvent` case `"keyboard-build"`), `Fire3D` leitet die Taste als Event durch. Der Processor bleibt so die Denk-Werkstatt, wie du willst.

**2. „Anbauen" — welches Teil?** Hier ist der Knackpunkt: Der Processor kennt **keinen Katalog**, nur die bereits platzierten Teile. Er kann also nicht selbst wissen, *was* rechts/links angebaut werden soll. Zwei Wege:
- **Aktives Teil duplizieren**: rechts/links wird eine Kopie des selektierten Teils angedockt (an dessen `CS_RECHTS`/`CS_LINKS`). Rein Processor-intern, sofort hackbar.
- **Client liefert das Teil**: die Taste triggert „füge Artikel X an" — dann muss aber der Client/ProductBrowser das Bauteil beisteuern (mehr als nur Processor).

Für „oben/unten" ist es eindeutig: das aktive Teil per `group-move` um einen festen Schritt versetzen.

Sag mir Punkt 2 (dupliziertes aktives Teil reicht für die Denkanregung?), dann hacke ich es rein — Listener in Fire3D, Build-/Move-Logik im Processor.


1 - es gibt schon einen Listener oder? der muss einfach erweitert werden
-> kann es nicht das letzte aktive Teil aus dem Store holen?
--> Nur dann ausführen, wenn am aktiven Teil platz fü die Kation ist

2 -> genau :: mit kopie weitermachen


Ich schaue nach dem bestehenden Listener in `Fire3D.vue` und wie das aktive Teil erreichbar ist:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Bestehender Keyboard-Listener in Fire3D.vue? ==="
grep -nE 'keydown|keyup|addEventListener|onKey|keyboard|ArrowRight|ArrowLeft|@keydown|window\.' packages/frontend/src/components/visual/Fire3D.vue | head
echo "=== dispatch/handleEvent-Aufruf in Fire3D (wie Events an den Processor gehen) ==="
grep -nE 'processor\.|handleEvent|dispatch|processReactions' packages/frontend/src/components/visual/Fire3D.vue | head

Find existing keyboard listener and event dispatch in Fire3D


=== Bestehender Keyboard-Listener in Fire3D.vue? ===
60:function onKeydown(e: KeyboardEvent) {
182:    window.addEventListener("keydown", onKeydown);
204:    window.removeEventListener("keydown", onKeydown);
=== dispatch/handleEvent-Aufruf in Fire3D (wie Events an den Processor gehen) ===
63:        const reactions = processor.handleEvent({ eventTypeName: "undo" });
64:        processReactions(reactions);
68:        const reactions = processor.handleEvent({ eventTypeName: "redo" });
69:        processReactions(reactions);
73:        const reactions = processor.handleEvent({ eventTypeName: "esc" });
74:        processReactions(reactions);
114:    processReactions(entry.reactions);
122:async function processReactions(reactions: UIReaction[]): Promise<void> {
136:            case "dispatchEvent":
137:                await processReactions(processor.handleEvent(reaction.event));


Es gibt schon `onKeydown` (Zeile 60, handhabt undo/redo/esc). Ich sehe ihn mir an, um ihn zu erweitern:


60	function onKeydown(e: KeyboardEvent) {
61	    if (e.ctrlKey && e.key === "z") {
62	        e.preventDefault();
63	        const reactions = processor.handleEvent({ eventTypeName: "undo" });
64	        processReactions(reactions);
65	    }
66	    if (e.ctrlKey && e.key === "y") {
67	        e.preventDefault();
68	        const reactions = processor.handleEvent({ eventTypeName: "redo" });
69	        processReactions(reactions);
70	    }
71	    if (e.key === "Escape") {
72	        e.preventDefault();
73	        const reactions = processor.handleEvent({ eventTypeName: "esc" });
74	        processReactions(reactions);
75	    }
76	}
77
78	const root = ref<HTMLElement | null>(null);
79	const controlsVisible = ref(false);

function onKeydown(e: KeyboardEvent) {
    if (e.ctrlKey && e.key === "z") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "undo" });
        processReactions(reactions);
    }
    if (e.ctrlKey && e.key === "y") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "redo" });
        processReactions(reactions);
    }
    if (e.key === "Escape") {
        e.preventDefault();
        const reactions = processor.handleEvent({ eventTypeName: "esc" });
        processReactions(reactions);
    }
}

const root = ref<HTMLElement | null>(null);
const controlsVisible = ref(false);


1 - es gibt schon einen Listener oder? der muss einfach erweitert werden -> kann es nicht das letzte aktive Teil aus dem Store holen? --> Nur dann ausführen, wenn am aktiven Teil platz fü die Kation i…


Es gibt `onKeydown` (undo/redo/esc) — den erweitere ich. Der Anbau-Teil (Kopie andocken) braucht aber Zugriff auf die Element-Handles und die Merge-Konstruktion. Ich sehe mir `buildCreateEvent` und die group-id-Handhabung an, um den Processor-Handler sauber zu bauen: