Edit

        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        // Direction from the böppel's ACTUAL frame, not a raw angle: the Steher is rotated relative to
        // its fence, so a literal angle would send the clone backward. Orient the clone to the Steher's
        // matching CS_OUT_<angle>, expressed back in the fence's frame.
        //   S   = Steher orientation relative to the anchor fence
        //   rel = clone relative to the Steher (clone's join frame read off the identical anchor fence)
        //   rot = S ∘ rel   → clone relative to the fence
        const angleOut = clickedPmi.replace("CS_SELECT_", "CS_OUT_");
        const s = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, steherRoot, origin.joinPmi);
        const rel = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);
        const rot = quatMul(s, rel);
        ctx.record({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: targetGroup,
            movingGroupId: createEvent.groupId,
            anchorRootId: origin.fence,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
            ephemeral: true, // dock past the guard: the junction point carries the shared Steher
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);

        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        // Direction from the böppel's ACTUAL frame, not a raw angle: the Steher is rotated relative to
        // its fence, so a literal angle would send the clone backward. Orient the clone to the Steher's
        // matching CS_OUT_<angle>, expressed back in the fence's frame.
        //   S   = Steher orientation relative to the anchor fence
        //   rel = clone relative to the Steher (clone's join frame read off the identical anchor fence)
        //   rot = S ∘ rel   → clone relative to the fence
        const angleOut = clickedPmi.replace("CS_SELECT_", "CS_OUT_");
        const s = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, steherRoot, origin.joinPmi);
        const rel = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);
        const rot = quatMul(s, rel);
        // create + merge as ONE undo step (the Steher diff the reconciler appends joins the same group).
        ctx.recordGroup([
            createEvent,
            {
                type: "group-merge",
                timestamp: Date.now(),
                targetGroupId: targetGroup,
                movingGroupId: createEvent.groupId,
                anchorRootId: origin.fence,
                joinRootId: createEvent.rootHandleId,
                anchorPmiId: origin.anchorPmi,
                joinPmiId: origin.joinPmi,
                rot,
                ephemeral: true, // dock past the guard: the junction point carries the shared Steher
            } as GroupEvent,
        ]);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);


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

        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        // Direction from the böppel's ACTUAL frame, not a raw angle: the Steher is rotated relative to
        // its fence, so a literal angle would send the clone backward. Orient the clone to the Steher's
        // matching CS_OUT_<angle>, expressed back in the fence's frame.
        //   S   = Steher orientation relative to the anchor fence
        //   rel = clone relative to the Steher (clone's join frame read off the identical anchor fence)
        //   rot = S ∘ rel   → clone relative to the fence
        const angleOut = clickedPmi.replace("CS_SELECT_", "CS_OUT_");
        const s = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, steherRoot, origin.joinPmi);
        const rel = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);
        const rot = quatMul(s, rel);
        ctx.record({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: targetGroup,
            movingGroupId: createEvent.groupId,
            anchorRootId: origin.fence,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
            ephemeral: true, // dock past the guard: the junction point carries the shared Steher
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);

        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        // Direction from the böppel's ACTUAL frame, not a raw angle: the Steher is rotated relative to
        // its fence, so a literal angle would send the clone backward. Orient the clone to the Steher's
        // matching CS_OUT_<angle>, expressed back in the fence's frame.
        //   S   = Steher orientation relative to the anchor fence
        //   rel = clone relative to the Steher (clone's join frame read off the identical anchor fence)
        //   rot = S ∘ rel   → clone relative to the fence
        const angleOut = clickedPmi.replace("CS_SELECT_", "CS_OUT_");
        const s = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, steherRoot, origin.joinPmi);
        const rel = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);
        const rot = quatMul(s, rel);
        // create + merge as ONE undo step (the Steher diff the reconciler appends joins the same group).
        ctx.recordGroup([
            createEvent,
            {
                type: "group-merge",
                timestamp: Date.now(),
                targetGroupId: targetGroup,
                movingGroupId: createEvent.groupId,
                anchorRootId: origin.fence,
                joinRootId: createEvent.rootHandleId,
                anchorPmiId: origin.anchorPmi,
                joinPmiId: origin.joinPmi,
                rot,
                ephemeral: true, // dock past the guard: the junction point carries the shared Steher
            } as GroupEvent,
        ]);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);

const scene = ctx.groups.sceneOfElement(origin.fence);

if (!targetGroup || !scene) return [stop];

-            targetGroupId: targetGroup,

-            movingGroupId: createEvent.groupId,

-            anchorRootId: origin.fence,

-            joinRootId: createEvent.rootHandleId,

-            anchorPmiId: origin.anchorPmi,

-            ephemeral: true, // dock past the guard: the junction point carries the shared Steher

+        // create + merge as ONE undo step (the Steher diff the reconciler appends joins the same group).

+                targetGroupId: targetGroup,

+                movingGroupId: createEvent.groupId,

+                anchorRootId: origin.fence,

+                joinRootId: createEvent.rootHandleId,

+                anchorPmiId: origin.anchorPmi,

+                joinPmiId: origin.joinPmi,

+                ephemeral: true, // dock past the guard: the junction point carries the shared Steher


Test für #3 (Böppel-Undo = ein Schritt):


it("R and L clones land on OPPOSITE sides of the anchor", () => {

    it("undo of a böppel build is ONE step (clone + its Stehers together)", () => {
        const { proc, scene } = place();
        expect(fences(scene)).toHaveLength(1);
        clickBöppel(proc, böppelOf(scene, "R")!);
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(fences(undone)).toHaveLength(1); // back to just the anchor in a single undo
        expect(stehers(undone)).toHaveLength(2); // its two end Stehers restored
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {


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

    it("undo of a böppel build is ONE step (clone + its Stehers together)", () => {
        const { proc, scene } = place();
        expect(fences(scene)).toHaveLength(1);
        clickBöppel(proc, böppelOf(scene, "R")!);
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(fences(undone)).toHaveLength(1); // back to just the anchor in a single undo
        expect(stehers(undone)).toHaveLength(2); // its two end Stehers restored
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import allroundElement from "../../../.storybook/products/schutzzaun_allround/SAE0260PNN.json";
import steherScene from "../../../.storybook/products/schutzzaun_allround/SAP4180BNA.json";

// The real allround geometry: element ends are CS_OUT_L / CS_OUT_R, the Steher (SAP4180) is rotated
// relative to its fence — the case where a raw-angle böppel sent the RIGHT clone backwards ("nichts").
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_allround-element", joins: { CS_OUT_R: "CS_EIN_L", CS_OUT_L: "CS_EIN_R" } },
];
const resolver: SteherPartResolver = () => steherScene;

const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const fences = (scene: any) => parts(scene).filter((id) => !id.startsWith("h-steher"));
const stehers = (scene: any) => parts(scene).filter((id) => id.startsWith("h-steher"));
const rootPos = (scene: any, id: string): number[] =>
    scene.handles.find((h: any) => h.id === id).components.find((c: any) => c._resourceClass === "component_transform")
        .translation;

function place() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES, resolver));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
    return { proc, scene };
}

const böppelOf = (scene: any, side: "R" | "L") => stehers(scene).find((id) => id.includes(`CS_OUT_${side}`));
const clickBöppel = (proc: DefaultCommandProcessor, steher: string) =>
    sceneOf(
        proc.handleEvent({
            eventTypeName: "selection-click",
            target: { id: `sel-marker-${steher}-CS_SELECT_0` },
            selectionAction: "set",
        }),
    );

describe("Steher — real allround geometry", () => {
    it("places an element and heals a Steher at each end (L + R)", () => {
        const { scene } = place();
        expect(fences(scene)).toHaveLength(1);
        expect(stehers(scene)).toHaveLength(2);
        expect(böppelOf(scene, "R")).toBeTruthy();
        expect(böppelOf(scene, "L")).toBeTruthy();
    });

    it("the RIGHT böppel builds a fence, docked away from the anchor (not overlapping)", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "R")!);
        expect(fences(after)).toHaveLength(2); // anchor + clone — the "rechts tut sich nix" regression
        const clone = fences(after).find((f) => f !== anchor)!;
        // The clone sits at a real offset from the anchor origin — a backward/overlapping build would
        // leave it near [0,0,0] (on top of the anchor).
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("the LEFT böppel also builds a fence, docked away from the anchor", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "L")!);
        expect(fences(after)).toHaveLength(2);
        const clone = fences(after).find((f) => f !== anchor)!;
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {
        const right = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "R")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        const left = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "L")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        // Opposite X sign → the two builds extend in opposite directions (not both backwards).
        expect(Math.sign(right[0])).toBe(-Math.sign(left[0]));
    });
});

+    it("undo of a böppel build is ONE step (clone + its Stehers together)", () => {

+        const { proc, scene } = place();

+        expect(fences(scene)).toHaveLength(1);

+        clickBöppel(proc, böppelOf(scene, "R")!);

+        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));

+        expect(fences(undone)).toHaveLength(1); // back to just the anchor in a single undo

+        expect(stehers(undone)).toHaveLength(2); // its two end Stehers restored


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteherAllround.test.ts 2>&1 | grep -E '✓|×|Tests |failed|Expected|Received|undo' | head

Run allround tests incl undo


Tests  5 passed (5)


#3 grün (5 Tests). Jetzt **#1 (Snap-2)** — diesmal schrittweise mit Test-Netz. Schritt A: Steher-Kanten aus `occupiedPmis` ausschließen (Böppel-Bypass bleibt vorerst), dann testen.


Edit

        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;

        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                if (e.ephemeral) continue; // a Steher edge doesn't occupy the fence's point for docking
                out.push({ element: e.from, pmi: e.fromPmi });
                out.push({ element: e.to, pmi: e.toPmi });
            }
        }
        return out;


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)

        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;

        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                if (e.ephemeral) continue; // a Steher edge doesn't occupy the fence's point for docking
                out.push({ element: e.from, pmi: e.fromPmi });
                out.push({ element: e.to, pmi: e.toPmi });
            }
        }
        return out;

const out: { element: string; pmi: string }[] = [];

+                if (e.ephemeral) continue; // a Steher edge doesn't occupy the fence's point for docking


Bash

pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests |×' | head

Test after occupiedPmis exclusion


 Test Files  11 passed (11)
      Tests  147 passed (147)


Schritt A grün (147). Schritt B: `connectionsOf` + `blockedPmis` ausschließen und den Böppel-Bypass entfernen (die Kante zählt jetzt als echte Junction; der Steher blockiert nicht mehr).


Edit

        const out: Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> = [];
        for (const e of g.edges) {
            if (this.ephemeralElements.has(e.from) || this.ephemeralElements.has(e.to)) continue;

        const out: Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> = [];
        for (const e of g.edges) {
            if (e.ephemeral) continue; // a Steher edge is not a fence-fence connection
            if (this.ephemeralElements.has(e.from) || this.ephemeralElements.has(e.to)) continue;


        const out: Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> = [];
        for (const e of g.edges) {
            if (this.ephemeralElements.has(e.from) || this.ephemeralElements.has(e.to)) continue;

        const out: Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> = [];
        for (const e of g.edges) {
            if (e.ephemeral) continue; // a Steher edge is not a fence-fence connection
            if (this.ephemeralElements.has(e.from) || this.ephemeralElements.has(e.to)) continue;

+            if (e.ephemeral) continue; // a Steher edge is not a fence-fence connection

if (e.from === elementRoot) out.push({ pmi: e.fromPmi, other: e.to, otherPmi: e.toPmi, rot: e.rot ?? IDENTITY_QUAT, isFrom: true });

else if (e.to === elementRoot) out.push({ pmi: e.toPmi, other: e.from, otherPmi: e.fromPmi, rot: e.rot ?? IDENTITY_QUAT, isFrom: false });


            for (const e of g.edges) {
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];

            for (const e of g.edges) {
                if (e.ephemeral) continue; // Steher edges don't form the fence-fence junction rule
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];


            for (const e of g.edges) {
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];

            for (const e of g.edges) {
                if (e.ephemeral) continue; // Steher edges don't form the fence-fence junction rule
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];

const union = (a: string, b: string) => parent.set(find(a), find(b));

const node = (el: string, end: string) => `${el}|${end}`;

+                if (e.ephemeral) continue; // Steher edges don't form the fence-fence junction rule

const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];


                anchorPmiId: origin.anchorPmi,
                joinPmiId: origin.joinPmi,
                rot,
                ephemeral: true, // dock past the guard: the junction point carries the shared Steher
            } as GroupEvent,
        ]);

                anchorPmiId: origin.anchorPmi,
                joinPmiId: origin.joinPmi,
                rot,
            } as GroupEvent,
        ]);


                anchorPmiId: origin.anchorPmi,
                joinPmiId: origin.joinPmi,
                rot,
                ephemeral: true, // dock past the guard: the junction point carries the shared Steher
            } as GroupEvent,
        ]);

                anchorPmiId: origin.anchorPmi,
                joinPmiId: origin.joinPmi,
                rot,
            } as GroupEvent,
        ]);

-                ephemeral: true, // dock past the guard: the junction point carries the shared Steher

ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);


Bash

pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests |×|FAIL' | head

Test after Step B (connectionsOf/blockedPmis exclusion + böppel non-ephemeral)


Schritt B grün (147, Böppel intakt). Jetzt der **Snap-Pfad** als Test — zwei Elemente zusammenschnappen → **ein** geteilter Steher (nicht 2).


Edit

const rootPos = (scene: any, id: string): number[] =>

const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const rootPos = (scene: any, id: string): number[] =>


const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const rootPos = (scene: any, id: string): number[] =>

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import allroundElement from "../../../.storybook/products/schutzzaun_allround/SAE0260PNN.json";
import steherScene from "../../../.storybook/products/schutzzaun_allround/SAP4180BNA.json";

// The real allround geometry: element ends are CS_OUT_L / CS_OUT_R, the Steher (SAP4180) is rotated
// relative to its fence — the case where a raw-angle böppel sent the RIGHT clone backwards ("nichts").
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_allround-element", joins: { CS_OUT_R: "CS_EIN_L", CS_OUT_L: "CS_EIN_R" } },
];
const resolver: SteherPartResolver = () => steherScene;

const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const fences = (scene: any) => parts(scene).filter((id) => !id.startsWith("h-steher"));
const stehers = (scene: any) => parts(scene).filter((id) => id.startsWith("h-steher"));
const rootPos = (scene: any, id: string): number[] =>
    scene.handles.find((h: any) => h.id === id).components.find((c: any) => c._resourceClass === "component_transform")
        .translation;

function place() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES, resolver));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
    return { proc, scene };
}

const böppelOf = (scene: any, side: "R" | "L") => stehers(scene).find((id) => id.includes(`CS_OUT_${side}`));
const clickBöppel = (proc: DefaultCommandProcessor, steher: string) =>
    sceneOf(
        proc.handleEvent({
            eventTypeName: "selection-click",
            target: { id: `sel-marker-${steher}-CS_SELECT_0` },
            selectionAction: "set",
        }),
    );

describe("Steher — real allround geometry", () => {
    it("places an element and heals a Steher at each end (L + R)", () => {
        const { scene } = place();
        expect(fences(scene)).toHaveLength(1);
        expect(stehers(scene)).toHaveLength(2);
        expect(böppelOf(scene, "R")).toBeTruthy();
        expect(böppelOf(scene, "L")).toBeTruthy();
    });

    it("the RIGHT böppel builds a fence, docked away from the anchor (not overlapping)", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "R")!);
        expect(fences(after)).toHaveLength(2); // anchor + clone — the "rechts tut sich nix" regression
        const clone = fences(after).find((f) => f !== anchor)!;
        // The clone sits at a real offset from the anchor origin — a backward/overlapping build would
        // leave it near [0,0,0] (on top of the anchor).
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("the LEFT böppel also builds a fence, docked away from the anchor", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "L")!);
        expect(fences(after)).toHaveLength(2);
        const clone = fences(after).find((f) => f !== anchor)!;
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("undo of a böppel build is ONE step (clone + its Stehers together)", () => {
        const { proc, scene } = place();
        expect(fences(scene)).toHaveLength(1);
        clickBöppel(proc, böppelOf(scene, "R")!);
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(fences(undone)).toHaveLength(1); // back to just the anchor in a single undo
        expect(stehers(undone)).toHaveLength(2); // its two end Stehers restored
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {
        const right = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "R")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        const left = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "L")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        // Opposite X sign → the two builds extend in opposite directions (not both backwards).
        expect(Math.sign(right[0])).toBe(-Math.sign(left[0]));
    });
});

+const groupNodeOf = (scene: any, part: string) =>

+    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")


it("R and L clones land on OPPOSITE sides of the anchor", () => {

    it("snapping two elements shares ONE Steher at the junction (a Steher never blocks the dock)", () => {
        const proc = new DefaultCommandProcessor();
        proc.register(new GekoPlacementModule({}, RULES, resolver));
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });
        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
        const [a, b] = fences(scene); // two separate elements, 4 Stehers so far
        expect(stehers(scene)).toHaveLength(4);
        // Snap B's CS_EIN_L onto A's CS_OUT_R — where A already carries its right Steher.
        scene = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "placement",
                lifecycleState: "update",
                subject: { entity: { id: groupNodeOf(scene, b) } },
                position: { values: [0, 0, 0] },
                rotation: { values: [1, 0, 0, 0] },
                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,
                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,
            }),
        );
        expect(fences(scene)).toHaveLength(2); // still two fences, now connected
        // A-L, shared junction (A-R), B-R = 3 — NOT 4 (the Steher did not block the fence-fence dock).
        expect(stehers(scene)).toHaveLength(3);
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {


    it("snapping two elements shares ONE Steher at the junction (a Steher never blocks the dock)", () => {
        const proc = new DefaultCommandProcessor();
        proc.register(new GekoPlacementModule({}, RULES, resolver));
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });
        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
        const [a, b] = fences(scene); // two separate elements, 4 Stehers so far
        expect(stehers(scene)).toHaveLength(4);
        // Snap B's CS_EIN_L onto A's CS_OUT_R — where A already carries its right Steher.
        scene = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "placement",
                lifecycleState: "update",
                subject: { entity: { id: groupNodeOf(scene, b) } },
                position: { values: [0, 0, 0] },
                rotation: { values: [1, 0, 0, 0] },
                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,
                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,
            }),
        );
        expect(fences(scene)).toHaveLength(2); // still two fences, now connected
        // A-L, shared junction (A-R), B-R = 3 — NOT 4 (the Steher did not block the fence-fence dock).
        expect(stehers(scene)).toHaveLength(3);
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import allroundElement from "../../../.storybook/products/schutzzaun_allround/SAE0260PNN.json";
import steherScene from "../../../.storybook/products/schutzzaun_allround/SAP4180BNA.json";

// The real allround geometry: element ends are CS_OUT_L / CS_OUT_R, the Steher (SAP4180) is rotated
// relative to its fence — the case where a raw-angle böppel sent the RIGHT clone backwards ("nichts").
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_allround-element", joins: { CS_OUT_R: "CS_EIN_L", CS_OUT_L: "CS_EIN_R" } },
];
const resolver: SteherPartResolver = () => steherScene;

const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const fences = (scene: any) => parts(scene).filter((id) => !id.startsWith("h-steher"));
const stehers = (scene: any) => parts(scene).filter((id) => id.startsWith("h-steher"));
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const rootPos = (scene: any, id: string): number[] =>
    scene.handles.find((h: any) => h.id === id).components.find((c: any) => c._resourceClass === "component_transform")
        .translation;

function place() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES, resolver));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
    return { proc, scene };
}

const böppelOf = (scene: any, side: "R" | "L") => stehers(scene).find((id) => id.includes(`CS_OUT_${side}`));
const clickBöppel = (proc: DefaultCommandProcessor, steher: string) =>
    sceneOf(
        proc.handleEvent({
            eventTypeName: "selection-click",
            target: { id: `sel-marker-${steher}-CS_SELECT_0` },
            selectionAction: "set",
        }),
    );

describe("Steher — real allround geometry", () => {
    it("places an element and heals a Steher at each end (L + R)", () => {
        const { scene } = place();
        expect(fences(scene)).toHaveLength(1);
        expect(stehers(scene)).toHaveLength(2);
        expect(böppelOf(scene, "R")).toBeTruthy();
        expect(böppelOf(scene, "L")).toBeTruthy();
    });

    it("the RIGHT böppel builds a fence, docked away from the anchor (not overlapping)", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "R")!);
        expect(fences(after)).toHaveLength(2); // anchor + clone — the "rechts tut sich nix" regression
        const clone = fences(after).find((f) => f !== anchor)!;
        // The clone sits at a real offset from the anchor origin — a backward/overlapping build would
        // leave it near [0,0,0] (on top of the anchor).
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("the LEFT böppel also builds a fence, docked away from the anchor", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "L")!);
        expect(fences(after)).toHaveLength(2);
        const clone = fences(after).find((f) => f !== anchor)!;
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("undo of a böppel build is ONE step (clone + its Stehers together)", () => {
        const { proc, scene } = place();
        expect(fences(scene)).toHaveLength(1);
        clickBöppel(proc, böppelOf(scene, "R")!);
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(fences(undone)).toHaveLength(1); // back to just the anchor in a single undo
        expect(stehers(undone)).toHaveLength(2); // its two end Stehers restored
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {
        const right = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "R")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        const left = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "L")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        // Opposite X sign → the two builds extend in opposite directions (not both backwards).
        expect(Math.sign(right[0])).toBe(-Math.sign(left[0]));
    });
});

+    it("snapping two elements shares ONE Steher at the junction (a Steher never blocks the dock)", () => {

+        const proc = new DefaultCommandProcessor();

+        proc.register(new GekoPlacementModule({}, RULES, resolver));

+        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });

+        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));

+        const [a, b] = fences(scene); // two separate elements, 4 Stehers so far

+        expect(stehers(scene)).toHaveLength(4);

+        // Snap B's CS_EIN_L onto A's CS_OUT_R — where A already carries its right Steher.

+                eventTypeName: "interaction-mode",

+                lifecycleState: "update",

+                subject: { entity: { id: groupNodeOf(scene, b) } },

+                position: { values: [0, 0, 0] },

+                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,

+                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,

+        expect(fences(scene)).toHaveLength(2); // still two fences, now connected

+        // A-L, shared junction (A-R), B-R = 3 — NOT 4 (the Steher did not block the fence-fence dock).

+        expect(stehers(scene)).toHaveLength(3);


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteherAllround.test.ts 2>&1 | grep -E '✓|×|Tests |failed|Expected|Received|snapping|toHaveLength' | head

Run snap test


Tests  6 passed (6)


Snap grün — ein geteilter Steher (3, nicht 4). **#1 und #3 sind erledigt und getestet.** Volle Suite + Typecheck.


pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Full suite and typecheck


 Test Files  11 passed (11)
      Tests  148 passed (148)
---done---


Fertig gebaut, was ich verlässlich blind bauen kann — **148 Tests grün, Typecheck sauber:**

**#1 Snap-2 behoben** (getestet): Steher-Kanten sind als „ephemer" markiert und aus `occupiedPmis`/`blockedPmis`/`connectionsOf` ausgeschlossen. Ein Steher blockiert damit nie das Andocken eines Zauns und verfälscht die Junction-Erkennung nicht. Zwei Zäune zusammenschnappen → **ein** geteilter Steher (Test: 3 statt 4). Der Böppel-Merge braucht keinen Guard-Bypass mehr — echte Zaun-Kante.

**#3 Böppel = ein Undo-Schritt** (getestet): create + merge + der vom Reconciler angehängte Steher-Diff liegen in **einer** Gruppe → Undo nimmt Klon + seine Steher in einem Schritt zurück.

**#2 Winkel → Bauform (Ecken D/E/F):** das ist der Teil, den wir von Anfang an als **Bild-Tuning** markiert haben. Der `occupied`-Input der Matrix müsste aus den Kanten-Rotationen an der Junction kommen, im **Frame des Stehers** — die exakte `rot`→Winkel-Zuordnung (0/90/180) kann ich nicht blind treffen. Wenn ich sie rate, bekommen Ecken die **falsche** Bauform (sichtbar falsche Form) — schlimmer als das aktuelle „immer End-Form B/C".

Also: #1 und #3 sind zu Ende. #2 ist die eine offene Stelle, die zwingend am laufenden Bild abgestimmt werden muss — genau wie bei der Böppel-Richtung vorhin (die haben wir am Bild bestätigt, dann Test). Sag mir, wann du am Bild bist, dann fülle ich `occupied` und wir prüfen A–G direkt — das ist ein kleiner, gezielter Schritt.

Soll ich die restlichen Punkte (Kaskaden-Löschen, Höhe/Sondersteher) auch noch einplanen, oder erst #2 am Bild?


kaskaden brauchen wir
was war #2?


**#2** war **Winkel → Bauform** — die Ecken-Bauformen D/E/F, also `occupied` aus den Kanten-Rotationen zu füllen (der Bild-Tuning-Teil).

Jetzt die **Kaskaden**. GEKO-76: Steher löschen → verbundene Zäune mit weg; Zaun löschen → alleinstehende Steher weg, verbundene bleiben und heilen neu. Der Reconciler räumt verwaiste Steher schon — es fehlt der **Delete-Pfad**. Ich baue: Core-`delete` (entfernt Selektion) + GEKO-Prepare, das eine Steher-Selektion auf ihre Zäune übersetzt.

Erst der Core-Handler.


Edit

            case "context-click":
                return this.getContextForSelection(event);

            case "delete":
                return this.handleDelete();
            case "context-click":
                return this.getContextForSelection(event);


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)

            case "delete":
                return this.handleDelete();
            case "context-click":
                return this.getContextForSelection(event);

+                return this.handleDelete();


    // Remove the selected element roots. Derived nodes (Stehers) then reconcile: a deleted fence's lone
    // Stehers drop out (their slot is no longer a target); shared ones re-heal onto the surviving fence.
    private handleDelete(): UIReaction[] {
        this.replay();
        const roots = this.selectionElementRoots();
        if (!roots.length) return [];
        this.selection = [];
        this.pushEventGroup(roots.map((r) => ({ type: "group-remove", timestamp: Date.now(), elementId: r }) as GroupEvent));
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
        ];
    }

    private handleUndo(): UIReaction[] {


    // Remove the selected element roots. Derived nodes (Stehers) then reconcile: a deleted fence's lone
    // Stehers drop out (their slot is no longer a target); shared ones re-heal onto the surviving fence.
    private handleDelete(): UIReaction[] {
        this.replay();
        const roots = this.selectionElementRoots();
        if (!roots.length) return [];
        this.selection = [];
        this.pushEventGroup(roots.map((r) => ({ type: "group-remove", timestamp: Date.now(), elementId: r }) as GroupEvent));
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
        ];
    }

    private handleUndo(): UIReaction[] {

+    // Remove the selected element roots. Derived nodes (Stehers) then reconcile: a deleted fence's lone

+    // Stehers drop out (their slot is no longer a target); shared ones re-heal onto the surviving fence.

+    private handleDelete(): UIReaction[] {

+        const roots = this.selectionElementRoots();

+        this.pushEventGroup(roots.map((r) => ({ type: "group-remove", timestamp: Date.now(), elementId: r }) as GroupEvent));

+            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },

+            { type: "sendCommand", cmd: "setScene", args: [scene] },

if (!this.eventStore.canUndo) return [];


Jetzt der GEKO-Prepare-Hook, der eine Steher-Selektion auf ihre Zäune übersetzt (Steher löschen → Zäune mit weg).


            // A manual override on a derived Steher slot (geko-defined payload — e.g. a chosen length).
            // Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-override": {
                replace: (event, ctx) => this.setSteherOverride(event, ctx),
            },

            // A manual override on a derived Steher slot (geko-defined payload — e.g. a chosen length).
            // Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-override": {
                replace: (event, ctx) => this.setSteherOverride(event, ctx),
            },
            // Cascade delete (GEKO-76): deleting a Steher deletes its connected fences. Rewrites a Steher
            // selection to those fences; the core then removes them and the reconciler drops the Stehers.
            delete: {
                prepare: (_event, ctx) => this.cascadeDelete(ctx),
            },


            // A manual override on a derived Steher slot (geko-defined payload — e.g. a chosen length).
            // Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-override": {
                replace: (event, ctx) => this.setSteherOverride(event, ctx),
            },
            // Cascade delete (GEKO-76): deleting a Steher deletes its connected fences. Rewrites a Steher
            // selection to those fences; the core then removes them and the reconciler drops the Stehers.
            delete: {
                prepare: (_event, ctx) => this.cascadeDelete(ctx),
            },

+            // Cascade delete (GEKO-76): deleting a Steher deletes its connected fences. Rewrites a Steher

+            // selection to those fences; the core then removes them and the reconciler drops the Stehers.

+                prepare: (_event, ctx) => this.cascadeDelete(ctx),


private setSteherOverride(event: any, ctx: CommandContext): UIReaction[] {

    // If the selection contains a Steher, replace it with the fences it connects (its anchor fence plus
    // any junction partner). Returns null either way so the core delete removes the resulting fences.
    private cascadeDelete(ctx: CommandContext): PrepareResult {
        const steherSlots = new Map(ctx.groups.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.root, e.value] as const));
        const roots = ctx.selectionElementRoots();
        if (!roots.some((r) => steherSlots.has(r))) return null; // no Steher selected → core deletes as-is
        const fences = new Set<string>();
        for (const r of roots) {
            const slot = steherSlots.get(r);
            if (!slot) {
                fences.add(r);
                continue;
            }
            const anchorFence = slot.slice("steher@".length, slot.lastIndexOf(":"));
            const pmi = slot.slice(slot.lastIndexOf(":") + 1);
            fences.add(anchorFence);
            for (const c of ctx.groups.connectionsOf(anchorFence)) if (c.pmi === pmi) fences.add(c.other);
        }
        ctx.setSelection([...fences].map((f) => ({ pickedId: f, level: 0 })));
        return null; // core handleDelete removes the fences; the reconciler drops their Stehers
    }

    private setSteherOverride(event: any, ctx: CommandContext): UIReaction[] {


    // If the selection contains a Steher, replace it with the fences it connects (its anchor fence plus
    // any junction partner). Returns null either way so the core delete removes the resulting fences.
    private cascadeDelete(ctx: CommandContext): PrepareResult {
        const steherSlots = new Map(ctx.groups.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.root, e.value] as const));
        const roots = ctx.selectionElementRoots();
        if (!roots.some((r) => steherSlots.has(r))) return null; // no Steher selected → core deletes as-is
        const fences = new Set<string>();
        for (const r of roots) {
            const slot = steherSlots.get(r);
            if (!slot) {
                fences.add(r);
                continue;
            }
            const anchorFence = slot.slice("steher@".length, slot.lastIndexOf(":"));
            const pmi = slot.slice(slot.lastIndexOf(":") + 1);
            fences.add(anchorFence);
            for (const c of ctx.groups.connectionsOf(anchorFence)) if (c.pmi === pmi) fences.add(c.other);
        }
        ctx.setSelection([...fences].map((f) => ({ pickedId: f, level: 0 })));
        return null; // core handleDelete removes the fences; the reconciler drops their Stehers
    }

    private setSteherOverride(event: any, ctx: CommandContext): UIReaction[] {

// Record a manual override for a Steher slot. `event.payload` is geko-defined (the module does not

// interpret it — the resolver does); null/absent clears the override back to the derived default.

// Undo-able; re-renders from the fresh model.

+    // If the selection contains a Steher, replace it with the fences it connects (its anchor fence plus

+    // any junction partner). Returns null either way so the core delete removes the resulting fences.

+    private cascadeDelete(ctx: CommandContext): PrepareResult {

+        const steherSlots = new Map(ctx.groups.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.root, e.value] as const));

+        const roots = ctx.selectionElementRoots();

+        if (!roots.some((r) => steherSlots.has(r))) return null; // no Steher selected → core deletes as-is

+        const fences = new Set<string>();

+            const slot = steherSlots.get(r);

+            const anchorFence = slot.slice("steher@".length, slot.lastIndexOf(":"));

+            const pmi = slot.slice(slot.lastIndexOf(":") + 1);

+            for (const c of ctx.groups.connectionsOf(anchorFence)) if (c.pmi === pmi) fences.add(c.other);

+        ctx.setSelection([...fences].map((f) => ({ pickedId: f, level: 0 })));

+        return null; // core handleDelete removes the fences; the reconciler drops their Stehers


Cascade-Tests:


it("R and L clones land on OPPOSITE sides of the anchor", () => {

    const select = (proc: DefaultCommandProcessor, id: string) =>
        proc.handleEvent({ eventTypeName: "selection-click", target: { id }, selectionAction: "set" });
    const del = (proc: DefaultCommandProcessor) => sceneOf(proc.handleEvent({ eventTypeName: "delete" }));

    it("deleting a fence removes it and its lone Stehers (cascade)", () => {
        const { proc, scene } = place();
        select(proc, fences(scene)[0]);
        expect(parts(del(proc))).toHaveLength(0); // fence + both end Stehers gone
    });

    it("deleting a Steher deletes its connected fence (GEKO-76)", () => {
        const { proc, scene } = place();
        select(proc, böppelOf(scene, "R")!); // select a Steher
        expect(parts(del(proc))).toHaveLength(0); // its fence — and thus both its Stehers — gone
    });

    it("deleting one fence of a junction keeps the shared Steher on the survivor", () => {
        const proc = new DefaultCommandProcessor();
        proc.register(new GekoPlacementModule({}, RULES, resolver));
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });
        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
        const [a, b] = fences(scene);
        scene = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "placement",
                lifecycleState: "update",
                subject: { entity: { id: groupNodeOf(scene, b) } },
                position: { values: [0, 0, 0] },
                rotation: { values: [1, 0, 0, 0] },
                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,
                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,
            }),
        );
        expect(stehers(scene)).toHaveLength(3); // shared junction
        select(proc, b);
        const after = del(proc);
        expect(fences(after)).toHaveLength(1); // only A survives
        expect(stehers(after)).toHaveLength(2); // A's two ends re-healed
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {


    const select = (proc: DefaultCommandProcessor, id: string) =>
        proc.handleEvent({ eventTypeName: "selection-click", target: { id }, selectionAction: "set" });
    const del = (proc: DefaultCommandProcessor) => sceneOf(proc.handleEvent({ eventTypeName: "delete" }));

    it("deleting a fence removes it and its lone Stehers (cascade)", () => {
        const { proc, scene } = place();
        select(proc, fences(scene)[0]);
        expect(parts(del(proc))).toHaveLength(0); // fence + both end Stehers gone
    });

    it("deleting a Steher deletes its connected fence (GEKO-76)", () => {
        const { proc, scene } = place();
        select(proc, böppelOf(scene, "R")!); // select a Steher
        expect(parts(del(proc))).toHaveLength(0); // its fence — and thus both its Stehers — gone
    });

    it("deleting one fence of a junction keeps the shared Steher on the survivor", () => {
        const proc = new DefaultCommandProcessor();
        proc.register(new GekoPlacementModule({}, RULES, resolver));
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });
        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
        const [a, b] = fences(scene);
        scene = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "placement",
                lifecycleState: "update",
                subject: { entity: { id: groupNodeOf(scene, b) } },
                position: { values: [0, 0, 0] },
                rotation: { values: [1, 0, 0, 0] },
                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,
                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,
            }),
        );
        expect(stehers(scene)).toHaveLength(3); // shared junction
        select(proc, b);
        const after = del(proc);
        expect(fences(after)).toHaveLength(1); // only A survives
        expect(stehers(after)).toHaveLength(2); // A's two ends re-healed
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import allroundElement from "../../../.storybook/products/schutzzaun_allround/SAE0260PNN.json";
import steherScene from "../../../.storybook/products/schutzzaun_allround/SAP4180BNA.json";

// The real allround geometry: element ends are CS_OUT_L / CS_OUT_R, the Steher (SAP4180) is rotated
// relative to its fence — the case where a raw-angle böppel sent the RIGHT clone backwards ("nichts").
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_allround-element", joins: { CS_OUT_R: "CS_EIN_L", CS_OUT_L: "CS_EIN_R" } },
];
const resolver: SteherPartResolver = () => steherScene;

const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const fences = (scene: any) => parts(scene).filter((id) => !id.startsWith("h-steher"));
const stehers = (scene: any) => parts(scene).filter((id) => id.startsWith("h-steher"));
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const rootPos = (scene: any, id: string): number[] =>
    scene.handles.find((h: any) => h.id === id).components.find((c: any) => c._resourceClass === "component_transform")
        .translation;

function place() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES, resolver));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
    return { proc, scene };
}

const böppelOf = (scene: any, side: "R" | "L") => stehers(scene).find((id) => id.includes(`CS_OUT_${side}`));
const clickBöppel = (proc: DefaultCommandProcessor, steher: string) =>
    sceneOf(
        proc.handleEvent({
            eventTypeName: "selection-click",
            target: { id: `sel-marker-${steher}-CS_SELECT_0` },
            selectionAction: "set",
        }),
    );

describe("Steher — real allround geometry", () => {
    it("places an element and heals a Steher at each end (L + R)", () => {
        const { scene } = place();
        expect(fences(scene)).toHaveLength(1);
        expect(stehers(scene)).toHaveLength(2);
        expect(böppelOf(scene, "R")).toBeTruthy();
        expect(böppelOf(scene, "L")).toBeTruthy();
    });

    it("the RIGHT böppel builds a fence, docked away from the anchor (not overlapping)", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "R")!);
        expect(fences(after)).toHaveLength(2); // anchor + clone — the "rechts tut sich nix" regression
        const clone = fences(after).find((f) => f !== anchor)!;
        // The clone sits at a real offset from the anchor origin — a backward/overlapping build would
        // leave it near [0,0,0] (on top of the anchor).
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("the LEFT böppel also builds a fence, docked away from the anchor", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "L")!);
        expect(fences(after)).toHaveLength(2);
        const clone = fences(after).find((f) => f !== anchor)!;
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("undo of a böppel build is ONE step (clone + its Stehers together)", () => {
        const { proc, scene } = place();
        expect(fences(scene)).toHaveLength(1);
        clickBöppel(proc, böppelOf(scene, "R")!);
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(fences(undone)).toHaveLength(1); // back to just the anchor in a single undo
        expect(stehers(undone)).toHaveLength(2); // its two end Stehers restored
    });

    it("snapping two elements shares ONE Steher at the junction (a Steher never blocks the dock)", () => {
        const proc = new DefaultCommandProcessor();
        proc.register(new GekoPlacementModule({}, RULES, resolver));
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });
        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
        const [a, b] = fences(scene); // two separate elements, 4 Stehers so far
        expect(stehers(scene)).toHaveLength(4);
        // Snap B's CS_EIN_L onto A's CS_OUT_R — where A already carries its right Steher.
        scene = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "placement",
                lifecycleState: "update",
                subject: { entity: { id: groupNodeOf(scene, b) } },
                position: { values: [0, 0, 0] },
                rotation: { values: [1, 0, 0, 0] },
                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,
                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,
            }),
        );
        expect(fences(scene)).toHaveLength(2); // still two fences, now connected
        // A-L, shared junction (A-R), B-R = 3 — NOT 4 (the Steher did not block the fence-fence dock).
        expect(stehers(scene)).toHaveLength(3);
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {
        const right = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "R")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        const left = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "L")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        // Opposite X sign → the two builds extend in opposite directions (not both backwards).
        expect(Math.sign(right[0])).toBe(-Math.sign(left[0]));
    });
});

+    const select = (proc: DefaultCommandProcessor, id: string) =>

+        proc.handleEvent({ eventTypeName: "selection-click", target: { id }, selectionAction: "set" });

+    const del = (proc: DefaultCommandProcessor) => sceneOf(proc.handleEvent({ eventTypeName: "delete" }));

+    it("deleting a fence removes it and its lone Stehers (cascade)", () => {

+        const { proc, scene } = place();

+        select(proc, fences(scene)[0]);

+        expect(parts(del(proc))).toHaveLength(0); // fence + both end Stehers gone

+    it("deleting a Steher deletes its connected fence (GEKO-76)", () => {

+        select(proc, böppelOf(scene, "R")!); // select a Steher

+        expect(parts(del(proc))).toHaveLength(0); // its fence — and thus both its Stehers — gone

+    it("deleting one fence of a junction keeps the shared Steher on the survivor", () => {

+        const proc = new DefaultCommandProcessor();

+        proc.register(new GekoPlacementModule({}, RULES, resolver));

+        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });

+        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));

+                eventTypeName: "interaction-mode",

+                lifecycleState: "update",

+                subject: { entity: { id: groupNodeOf(scene, b) } },

+                position: { values: [0, 0, 0] },

+                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,

+                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,

+        expect(stehers(scene)).toHaveLength(3); // shared junction

+        expect(fences(after)).toHaveLength(1); // only A survives

+        expect(stehers(after)).toHaveLength(2); // A's two ends re-healed


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteherAllround.test.ts 2>&1 | grep -E '✓|×|Tests |failed|Expected|Received|deleting|toHaveLength' | head -20

Run cascade tests


Tests  9 passed (9)


Kaskaden grün (9/9 allround). Volle Suite + Typecheck.


pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Full suite and typecheck


 Test Files  11 passed (11)
      Tests  151 passed (151)
---done---


Kaskaden gebaut und getestet — **151 Tests grün, Typecheck sauber.**

**Kaskaden-Löschen (GEKO-76):**
- Core-`delete`: entfernt die selektierten Element-Roots per `group-remove`; der Reconciler räumt danach die Steher (verwaiste weg, geteilte heilen auf den überlebenden Zaun um).
- GEKO-`delete`-Prepare: eine **Steher**-Selektion wird auf ihre verbundenen Zäune übersetzt → Steher löschen löscht die Zäune → Steher fallen mit weg.
- Tests: Zaun löschen → Zaun + End-Steher weg; Steher löschen → sein Zaun weg; einen Zaun einer Junction löschen → der geteilte Steher heilt auf den Überlebenden um (1 Zaun, 2 Steher bleiben).

**Wiring nötig (wie bei den anderen Events):** die App muss `{ eventTypeName: "delete" }` bei Entf/Delete dispatchen.

**Stand der offenen Punkte:**
| | Status |
|---|---|
| Snap-2-Steher | ✅ erledigt + getestet |
| Böppel = ein Undo-Schritt | ✅ erledigt + getestet |
| Kaskaden-Löschen | ✅ erledigt + getestet |
| **#2 Winkel → Bauform (Ecken D/E/F)** | offen — braucht Bild-Tuning (rot→Winkel) |
| Höhe / Sondersteher | offen — config-getrieben, braucht die Config-JSON |

Damit sind alle blind sicher baubaren Steher-Punkte durch. Übrig: **#2 am Bild** (der eine Geometrie-Schritt) und die **Höhen-Config** (deine JSON). Sag mir, was zuerst — oder ob du erst am Bild gegentestest.


Trest / commiten / pushen


cd /Users/marcus.hinz/projects/adesso-cpq && pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; echo "=== status ==="; git status --short | head -40; echo "=== branch ==="; git branch --show-current; git rev-list --left-right --count @{u}...HEAD 2>&1 || echo "no upstream"

Run tests and check git status/branch


 Test Files  11 passed (11)
      Tests  151 passed (151)
=== status ===
 M packages/frontend/.storybook/products/catalog.json
 M packages/frontend/src/components/visual/Fire3D.vue
 M packages/frontend/src/components/visual/ProductConfigurator.stories.ts
 M packages/frontend/src/components/visual/ProductConfigurator.vue
 M packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
 M packages/frontend/src/configurator/events/EventStore.ts
 M packages/frontend/src/configurator/events/PlacementModule.ts
 M packages/frontend/src/configurator/model/GroupProcessor.ts
 M packages/frontend/src/configurator/modules/GekoPlacementModule.ts
 M packages/frontend/src/index.ts
?? packages/frontend/src/configurator/modules/GekoSteher.test.ts
?? packages/frontend/src/configurator/modules/GekoSteherAllround.test.ts
?? packages/frontend/src/configurator/modules/steherBauform.test.ts
?? packages/frontend/src/configurator/modules/steherBauform.ts
?? packages/frontend/src/configurator/perf.ts
=== branch ===
Produktmanagement
0	0


Tests grün (151), Branch `Produktmanagement`, sync mit Upstream. Ich committe alles und pushe (fast-forward).


git add -A && git commit -q -m "Steher: reale, reconcilte Knoten + Junction-Heilung, Anbau, Kaskaden-Loeschen

- PlacementModule: prepare/replace/refine Hooks, derive/reconcile; module-events
- Reconciler baut reale Steher pro Junction, gebuendelt in die Undo-Gruppe der Aktion
- GEKO-76 Bauform-Matrix (A-G), steherBauform + Tests
- Boeppel-Anbau: klont Anker-Zaun, Richtung aus dem Boeppel-Frame; ein Undo-Schritt
- geteilter Steher pro Junction (Steher-Kanten ephemer, blocken kein Docking)
- Kaskaden-Loeschen: Steher loeschen -> verbundene Zaeune; Zaun loeschen -> Steher heilen/verwaisen
- GroupProcessor: deterministische Ids, group-remove, elementsTagged, connectionsOf
- opt-in Perf-Instrumentierung (window.__cpqPerf), replay-Cache
- Steher-/Allround-Geometrie-Tests" && git push 2>&1 | tail -4

Commit and push the Steher work


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
   5564353..0ae02be  Produktmanagement -> Produktmanagement


Getestet, committet, gepusht.

- Tests: 151 grün, Typecheck sauber.
- Commit `5564353..0ae02be` auf `Produktmanagement`, fast-forward gepusht.

Enthält den kompletten Steher-Umbau (reale reconcilte Knoten, Junction-Heilung, Böppel-Anbau, geteilter Steher, Kaskaden-Löschen, Bauform-Matrix) plus die Perf-Instrumentierung und die neuen Tests. Kein Claude-Hinweis in der Message.

Offen bleiben: **#2 Winkel→Bauform (Ecken D/E/F)** am Bild und die **Höhen-Config**.


Er baut nicht mehr nach hinten und vorne


90/270 (die Ecken) bauen falsch, weil der Klon immer am **geraden** Punkt des Zauns andockt (nur rotiert), statt am **Eck-Slot des Stehers**. Der frame-basierte Fix hat gerade/rechts gerichtet, aber die Eck-**Position** ist falsch.

Richtig: der Klon dockt an `CS_OUT_<Winkel>` des **Stehers** (Position *und* Richtung aus dem Steher-Frame) — genau GEKO-76 „Zaun hängt am PMI des Stehers". Ich stelle den Böppel darauf um.


Edit

    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: string,
        steherRoot: string,
        stop: UIReaction,
        ctx: CommandContext,
    ): UIReaction[] {
        ctx.replay();
        const targetGroup = ctx.groups.groupIdOfElement(origin.fence);
        const scene = ctx.groups.sceneOfElement(origin.fence);
        if (!targetGroup || !scene) return [stop];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        // Direction from the böppel's ACTUAL frame, not a raw angle: the Steher is rotated relative to
        // its fence, so a literal angle would send the clone backward. Orient the clone to the Steher's
        // matching CS_OUT_<angle>, expressed back in the fence's frame.
        //   S   = Steher orientation relative to the anchor fence
        //   rel = clone relative to the Steher (clone's join frame read off the identical anchor fence)
        //   rot = S ∘ rel   → clone relative to the fence
        const angleOut = clickedPmi.replace("CS_SELECT_", "CS_OUT_");
        const s = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, steherRoot, origin.joinPmi);
        const rel = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);
        const rot = quatMul(s, rel);
        // create + merge as ONE undo step (the Steher diff the reconciler appends joins the same group).
        ctx.recordGroup([
            createEvent,
            {
                type: "group-merge",
                timestamp: Date.now(),
                targetGroupId: targetGroup,
                movingGroupId: createEvent.groupId,
                anchorRootId: origin.fence,
                joinRootId: createEvent.rootHandleId,
                anchorPmiId: origin.anchorPmi,
                joinPmiId: origin.joinPmi,
                rot,
            } as GroupEvent,
        ]);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: string,
        steherRoot: string,
        stop: UIReaction,
        ctx: CommandContext,
    ): UIReaction[] {
        ctx.replay();
        const targetGroup = ctx.groups.groupIdOfElement(steherRoot);
        const scene = ctx.groups.sceneOfElement(origin.fence);
        if (!targetGroup || !scene) return [stop];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        // The clone docks onto the Steher's OWN CS_OUT_<angle> matching the clicked böppel — position AND
        // direction come from the Steher's frame, so straight and the 90/270 corners all land right (the
        // fence hangs on the Steher's PMI, GEKO-76). The clone's join frame is read off the identical
        // anchor fence (the fresh copy isn't in the handle map yet); rot is relative to the Steher anchor.
        const angleOut = clickedPmi.replace("CS_SELECT_", "CS_OUT_");
        const rot = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);
        // create + merge as ONE undo step (the Steher diff the reconciler appends joins the same group).
        ctx.recordGroup([
            createEvent,
            {
                type: "group-merge",
                timestamp: Date.now(),
                targetGroupId: targetGroup,
                movingGroupId: createEvent.groupId,
                anchorRootId: steherRoot,
                joinRootId: createEvent.rootHandleId,
                anchorPmiId: angleOut,
                joinPmiId: origin.joinPmi,
                rot,
            } as GroupEvent,
        ]);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }


    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: string,
        steherRoot: string,
        stop: UIReaction,
        ctx: CommandContext,
    ): UIReaction[] {
        ctx.replay();
        const targetGroup = ctx.groups.groupIdOfElement(origin.fence);
        const scene = ctx.groups.sceneOfElement(origin.fence);
        if (!targetGroup || !scene) return [stop];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        // Direction from the böppel's ACTUAL frame, not a raw angle: the Steher is rotated relative to
        // its fence, so a literal angle would send the clone backward. Orient the clone to the Steher's
        // matching CS_OUT_<angle>, expressed back in the fence's frame.
        //   S   = Steher orientation relative to the anchor fence
        //   rel = clone relative to the Steher (clone's join frame read off the identical anchor fence)
        //   rot = S ∘ rel   → clone relative to the fence
        const angleOut = clickedPmi.replace("CS_SELECT_", "CS_OUT_");
        const s = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, steherRoot, origin.joinPmi);
        const rel = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);
        const rot = quatMul(s, rel);
        // create + merge as ONE undo step (the Steher diff the reconciler appends joins the same group).
        ctx.recordGroup([
            createEvent,
            {
                type: "group-merge",
                timestamp: Date.now(),
                targetGroupId: targetGroup,
                movingGroupId: createEvent.groupId,
                anchorRootId: origin.fence,
                joinRootId: createEvent.rootHandleId,
                anchorPmiId: origin.anchorPmi,
                joinPmiId: origin.joinPmi,
                rot,
            } as GroupEvent,
        ]);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: string,
        steherRoot: string,
        stop: UIReaction,
        ctx: CommandContext,
    ): UIReaction[] {
        ctx.replay();
        const targetGroup = ctx.groups.groupIdOfElement(steherRoot);
        const scene = ctx.groups.sceneOfElement(origin.fence);
        if (!targetGroup || !scene) return [stop];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        // The clone docks onto the Steher's OWN CS_OUT_<angle> matching the clicked böppel — position AND
        // direction come from the Steher's frame, so straight and the 90/270 corners all land right (the
        // fence hangs on the Steher's PMI, GEKO-76). The clone's join frame is read off the identical
        // anchor fence (the fresh copy isn't in the handle map yet); rot is relative to the Steher anchor.
        const angleOut = clickedPmi.replace("CS_SELECT_", "CS_OUT_");
        const rot = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);
        // create + merge as ONE undo step (the Steher diff the reconciler appends joins the same group).
        ctx.recordGroup([
            createEvent,
            {
                type: "group-merge",
                timestamp: Date.now(),
                targetGroupId: targetGroup,
                movingGroupId: createEvent.groupId,
                anchorRootId: steherRoot,
                joinRootId: createEvent.rootHandleId,
                anchorPmiId: angleOut,
                joinPmiId: origin.joinPmi,
                rot,
            } as GroupEvent,
        ]);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

-        const targetGroup = ctx.groups.groupIdOfElement(origin.fence);

+        const targetGroup = ctx.groups.groupIdOfElement(steherRoot);

-        // Direction from the böppel's ACTUAL frame, not a raw angle: the Steher is rotated relative to

-        // its fence, so a literal angle would send the clone backward. Orient the clone to the Steher's

-        // matching CS_OUT_<angle>, expressed back in the fence's frame.

-        //   S   = Steher orientation relative to the anchor fence

-        //   rel = clone relative to the Steher (clone's join frame read off the identical anchor fence)

-        //   rot = S ∘ rel   → clone relative to the fence

+        // The clone docks onto the Steher's OWN CS_OUT_<angle> matching the clicked böppel — position AND

+        // direction come from the Steher's frame, so straight and the 90/270 corners all land right (the

+        // fence hangs on the Steher's PMI, GEKO-76). The clone's join frame is read off the identical

+        // anchor fence (the fresh copy isn't in the handle map yet); rot is relative to the Steher anchor.

-        const s = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, steherRoot, origin.joinPmi);

-        const rel = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);

+        const rot = ctx.groups.dockRotation(steherRoot, angleOut, origin.fence, origin.joinPmi);

-                anchorRootId: origin.fence,

+                anchorRootId: steherRoot,

-                anchorPmiId: origin.anchorPmi,


Jetzt den Reconcile-Filter entfernen, damit die Klon→Steher-Kante als Junction zählt (der Klon heilt nur sein freies Ende). Und `quatMul` ist ungenutzt.


                // Fence-fence connections only (edges to Stehers are ignored) for junction detection.
                const conns = model.connectionsOf(anchor).filter((c) => !steherRoots.has(c.other));

                // Connections for junction detection. connectionsOf already drops the fence's own (ephemeral)
                // Steher edges; a real edge to a Steher (a fence hung on the Steher's CS_OUT via the böppel)
                // stays, so its join end is correctly treated as a junction.
                const conns = model.connectionsOf(anchor);


                // Fence-fence connections only (edges to Stehers are ignored) for junction detection.
                const conns = model.connectionsOf(anchor).filter((c) => !steherRoots.has(c.other));

                // Connections for junction detection. connectionsOf already drops the fence's own (ephemeral)
                // Steher edges; a real edge to a Steher (a fence hung on the Steher's CS_OUT via the böppel)
                // stays, so its join end is correctly treated as a junction.
                const conns = model.connectionsOf(anchor);

if (steherRoots.has(anchor)) continue; // a Steher is never an anchor (guards recursion)

const anchorGroup = model.groupIdOfElement(anchor);

-                // Fence-fence connections only (edges to Stehers are ignored) for junction detection.

-                const conns = model.connectionsOf(anchor).filter((c) => !steherRoots.has(c.other));

+                // Connections for junction detection. connectionsOf already drops the fence's own (ephemeral)

+                // Steher edges; a real edge to a Steher (a fence hung on the Steher's CS_OUT via the böppel)

+                // stays, so its join end is correctly treated as a junction.

+                const conns = model.connectionsOf(anchor);

conns.filter((c) => !c.isFrom).map((c) => sideOfPmi(c.pmi)).filter((s): s is string => !!s),


import { IDENTITY_QUAT, quatMul } from "../model/transformMath";

import { IDENTITY_QUAT } from "../model/transformMath";


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

import type { GroupProcessor, GroupEvent } from "../model/GroupProcessor";

-import { IDENTITY_QUAT, quatMul } from "../model/transformMath";

+import { IDENTITY_QUAT } from "../model/transformMath";

import { extractPlacementData } from "../events/eventData";

import type { CommandContext, EventHooks, PlacementModule, PrepareResult } from "../events/PlacementModule";

import { steherBauform, type AnbauSide, type Bauform, type SteherAngle } from "./steherBauform";


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteherAllround.test.ts src/configurator/modules/GekoSteher.test.ts 2>&1 | grep -E '✓|×|Tests |failed|Expected|Received|toHaveLength|greater|sign' | head -20

Run steher tests after hub böppel


Tests  18 passed (18)


18 grün. Volle Suite + Typecheck.


pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Full suite and typecheck after hub böppel


151 grün. Ich sichere den Eck-Fix mit einem Test ab: der 90°-Böppel dockt an einem **anderen Punkt** als der gerade (0) — also perpendicular, nicht überlappend.


Edit

const select = (proc: DefaultCommandProcessor, id: string) =>

    it("a corner böppel (90) docks at a different point than straight (0) — not just rotated", () => {
        const build = (angle: string) => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = sceneOf(
                proc.handleEvent({
                    eventTypeName: "selection-click",
                    target: { id: `sel-marker-${böppelOf(scene, "R")}-CS_SELECT_${angle}` },
                    selectionAction: "set",
                }),
            );
            expect(fences(after)).toHaveLength(2);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        };
        // Different dock point → the corner extends perpendicular from the Steher, not on top of the
        // straight run. (Old raw-angle build kept the same point and only rotated → overlap.)
        expect(build("0")).not.toEqual(build("90"));
    });

    const select = (proc: DefaultCommandProcessor, id: string) =>


    it("a corner böppel (90) docks at a different point than straight (0) — not just rotated", () => {
        const build = (angle: string) => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = sceneOf(
                proc.handleEvent({
                    eventTypeName: "selection-click",
                    target: { id: `sel-marker-${böppelOf(scene, "R")}-CS_SELECT_${angle}` },
                    selectionAction: "set",
                }),
            );
            expect(fences(after)).toHaveLength(2);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        };
        // Different dock point → the corner extends perpendicular from the Steher, not on top of the
        // straight run. (Old raw-angle build kept the same point and only rotated → overlap.)
        expect(build("0")).not.toEqual(build("90"));
    });

    const select = (proc: DefaultCommandProcessor, id: string) =>

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import allroundElement from "../../../.storybook/products/schutzzaun_allround/SAE0260PNN.json";
import steherScene from "../../../.storybook/products/schutzzaun_allround/SAP4180BNA.json";

// The real allround geometry: element ends are CS_OUT_L / CS_OUT_R, the Steher (SAP4180) is rotated
// relative to its fence — the case where a raw-angle böppel sent the RIGHT clone backwards ("nichts").
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_allround-element", joins: { CS_OUT_R: "CS_EIN_L", CS_OUT_L: "CS_EIN_R" } },
];
const resolver: SteherPartResolver = () => steherScene;

const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const fences = (scene: any) => parts(scene).filter((id) => !id.startsWith("h-steher"));
const stehers = (scene: any) => parts(scene).filter((id) => id.startsWith("h-steher"));
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const rootPos = (scene: any, id: string): number[] =>
    scene.handles.find((h: any) => h.id === id).components.find((c: any) => c._resourceClass === "component_transform")
        .translation;

function place() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES, resolver));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
    return { proc, scene };
}

const böppelOf = (scene: any, side: "R" | "L") => stehers(scene).find((id) => id.includes(`CS_OUT_${side}`));
const clickBöppel = (proc: DefaultCommandProcessor, steher: string) =>
    sceneOf(
        proc.handleEvent({
            eventTypeName: "selection-click",
            target: { id: `sel-marker-${steher}-CS_SELECT_0` },
            selectionAction: "set",
        }),
    );

describe("Steher — real allround geometry", () => {
    it("places an element and heals a Steher at each end (L + R)", () => {
        const { scene } = place();
        expect(fences(scene)).toHaveLength(1);
        expect(stehers(scene)).toHaveLength(2);
        expect(böppelOf(scene, "R")).toBeTruthy();
        expect(böppelOf(scene, "L")).toBeTruthy();
    });

    it("the RIGHT böppel builds a fence, docked away from the anchor (not overlapping)", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "R")!);
        expect(fences(after)).toHaveLength(2); // anchor + clone — the "rechts tut sich nix" regression
        const clone = fences(after).find((f) => f !== anchor)!;
        // The clone sits at a real offset from the anchor origin — a backward/overlapping build would
        // leave it near [0,0,0] (on top of the anchor).
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("the LEFT böppel also builds a fence, docked away from the anchor", () => {
        const { proc, scene } = place();
        const anchor = fences(scene)[0];
        const after = clickBöppel(proc, böppelOf(scene, "L")!);
        expect(fences(after)).toHaveLength(2);
        const clone = fences(after).find((f) => f !== anchor)!;
        expect(Math.hypot(...rootPos(after, clone))).toBeGreaterThan(100);
    });

    it("undo of a böppel build is ONE step (clone + its Stehers together)", () => {
        const { proc, scene } = place();
        expect(fences(scene)).toHaveLength(1);
        clickBöppel(proc, böppelOf(scene, "R")!);
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(fences(undone)).toHaveLength(1); // back to just the anchor in a single undo
        expect(stehers(undone)).toHaveLength(2); // its two end Stehers restored
    });

    it("snapping two elements shares ONE Steher at the junction (a Steher never blocks the dock)", () => {
        const proc = new DefaultCommandProcessor();
        proc.register(new GekoPlacementModule({}, RULES, resolver));
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });
        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
        const [a, b] = fences(scene); // two separate elements, 4 Stehers so far
        expect(stehers(scene)).toHaveLength(4);
        // Snap B's CS_EIN_L onto A's CS_OUT_R — where A already carries its right Steher.
        scene = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "placement",
                lifecycleState: "update",
                subject: { entity: { id: groupNodeOf(scene, b) } },
                position: { values: [0, 0, 0] },
                rotation: { values: [1, 0, 0, 0] },
                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,
                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,
            }),
        );
        expect(fences(scene)).toHaveLength(2); // still two fences, now connected
        // A-L, shared junction (A-R), B-R = 3 — NOT 4 (the Steher did not block the fence-fence dock).
        expect(stehers(scene)).toHaveLength(3);
    });

    const select = (proc: DefaultCommandProcessor, id: string) =>
        proc.handleEvent({ eventTypeName: "selection-click", target: { id }, selectionAction: "set" });
    const del = (proc: DefaultCommandProcessor) => sceneOf(proc.handleEvent({ eventTypeName: "delete" }));

    it("deleting a fence removes it and its lone Stehers (cascade)", () => {
        const { proc, scene } = place();
        select(proc, fences(scene)[0]);
        expect(parts(del(proc))).toHaveLength(0); // fence + both end Stehers gone
    });

    it("deleting a Steher deletes its connected fence (GEKO-76)", () => {
        const { proc, scene } = place();
        select(proc, böppelOf(scene, "R")!); // select a Steher
        expect(parts(del(proc))).toHaveLength(0); // its fence — and thus both its Stehers — gone
    });

    it("deleting one fence of a junction keeps the shared Steher on the survivor", () => {
        const proc = new DefaultCommandProcessor();
        proc.register(new GekoPlacementModule({}, RULES, resolver));
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles });
        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(allroundElement).handles }));
        const [a, b] = fences(scene);
        scene = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "placement",
                lifecycleState: "update",
                subject: { entity: { id: groupNodeOf(scene, b) } },
                position: { values: [0, 0, 0] },
                rotation: { values: [1, 0, 0, 0] },
                dragPointSelector: `handle#${b} pmi#CS_EIN_L`,
                dropPointSelector: `handle#${a} pmi#CS_OUT_R`,
            }),
        );
        expect(stehers(scene)).toHaveLength(3); // shared junction
        select(proc, b);
        const after = del(proc);
        expect(fences(after)).toHaveLength(1); // only A survives
        expect(stehers(after)).toHaveLength(2); // A's two ends re-healed
    });

    it("R and L clones land on OPPOSITE sides of the anchor", () => {
        const right = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "R")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        const left = (() => {
            const { proc, scene } = place();
            const anchor = fences(scene)[0];
            const after = clickBöppel(proc, böppelOf(scene, "L")!);
            return rootPos(after, fences(after).find((f) => f !== anchor)!);
        })();
        // Opposite X sign → the two builds extend in opposite directions (not both backwards).
        expect(Math.sign(right[0])).toBe(-Math.sign(left[0]));
    });
});

+    it("a corner böppel (90) docks at a different point than straight (0) — not just rotated", () => {

+        const build = (angle: string) => {

+            const { proc, scene } = place();

+            const anchor = fences(scene)[0];

+                    eventTypeName: "selection-click",

+                    target: { id: `sel-marker-${böppelOf(scene, "R")}-CS_SELECT_${angle}` },

+                    selectionAction: "set",

+            expect(fences(after)).toHaveLength(2);

+            return rootPos(after, fences(after).find((f) => f !== anchor)!);

+        // Different dock point → the corner extends perpendicular from the Steher, not on top of the

+        // straight run. (Old raw-angle build kept the same point and only rotated → overlap.)

+        expect(build("0")).not.toEqual(build("90"));


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteherAllround.test.ts 2>&1 | grep -E 'Tests |×|corner|failed|Expected|Received' | head

Run corner test


Tests  10 passed (10)


10 grün (inkl. Eck-Test). Finaler voller Lauf.


pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Final full suite and typecheck


 Test Files  11 passed (11)
      Tests  152 passed (152)
---done---


Gefixt — 152 Tests grün, Typecheck sauber.

**Der Klon dockt jetzt am `CS_OUT_<Winkel>` des Stehers** (dem geklickten Böppel entsprechend) — **Position und Richtung aus dem Steher-Frame**, nicht mehr immer am geraden Zaun-Ende nur rotiert. Damit:
- gerade (0/180) → gerade Verlängerung,
- **90/270 → dockt am perpendikulären Slot des Stehers → baut nach hinten/vorne** (statt überlappend am geraden Punkt).

Das ist GEKO-76 „der Zaun hängt am PMI des Stehers". Der Reconciler zählt die Klon→Steher-Kante als Junction, also heilt der Klon nur sein freies Ende (kein Doppel).

Test sichert es ab: 90°-Böppel dockt an einem **anderen Punkt** als der gerade — perpendicular, nicht rotiert-überlappend.

Bitte am Bild prüfen: rechter Steher → **90/270-Böppel** → baut jetzt nach hinten/vorne? Wenn ja, ist die Böppel-Richtung rundum durch (gerade L/R + Ecken). Sitzt das, committe ich.


commit und push


git status --short; echo "==="; git add -A && git commit -q -m "Steher-Anbau: Klon dockt am CS_OUT_<Winkel> des Stehers (Ecken 90/270)

- Boeppel dockt Position UND Richtung aus dem Steher-Frame statt am geraden Zaun-Ende
- 90/270 bauen perpendicular (nach hinten/vorne), gerade weiter gerade
- Reconciler zaehlt Klon->Steher-Kante als Junction (Klon heilt nur freies Ende)
- Test: Eck-Boeppel dockt an anderem Punkt als gerade" && git push 2>&1 | tail -3

Commit and push corner fix


 M packages/frontend/src/configurator/modules/GekoPlacementModule.ts
 M packages/frontend/src/configurator/modules/GekoSteherAllround.test.ts
===
remote:
To https://git.heron.at/pzm/geko/adesso-cpq.git
   0ae02be..dba8566  Produktmanagement -> Produktmanagement


Committet und gepusht — `0ae02be..dba8566` auf `Produktmanagement`, fast-forward. 152 Tests grün, kein Claude-Hinweis in der Message.