Edit

                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    target.add(slotId);
                    const known = existing.get(slotId);
                    if (known) {
                        this.steherOrigin.set(known, { fence: anchor, anchorPmi, joinPmi });
                        continue; // already present — leave it
                    }
                    // Bauform via the GEKO-76 matrix. TODO: fill `occupied` from the junction conns' rot.
                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
                    const occupied: SteherAngle[] = [];
                    const bauform = steherBauform(side, occupied);
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
                    if (!part) continue;

                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // Bauform via the GEKO-76 matrix. TODO: fill `occupied` from the junction conns' rot.
                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
                    const occupied: SteherAngle[] = [];
                    const bauform = steherBauform(side, occupied);
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
                    // Resolver returned null (e.g. a suppress override) → not a target → an existing Steher
                    // here is removed below.
                    if (!part) continue;
                    target.add(slotId);
                    const known = existing.get(slotId);
                    if (known) {
                        this.steherOrigin.set(known, { fence: anchor, anchorPmi, joinPmi });
                        continue; // already present — leave it
                    }


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 slotId = `steher@${anchor}:${anchorPmi}`;
                    target.add(slotId);
                    const known = existing.get(slotId);
                    if (known) {
                        this.steherOrigin.set(known, { fence: anchor, anchorPmi, joinPmi });
                        continue; // already present — leave it
                    }
                    // Bauform via the GEKO-76 matrix. TODO: fill `occupied` from the junction conns' rot.
                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
                    const occupied: SteherAngle[] = [];
                    const bauform = steherBauform(side, occupied);
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
                    if (!part) continue;

                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // Bauform via the GEKO-76 matrix. TODO: fill `occupied` from the junction conns' rot.
                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
                    const occupied: SteherAngle[] = [];
                    const bauform = steherBauform(side, occupied);
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
                    // Resolver returned null (e.g. a suppress override) → not a target → an existing Steher
                    // here is removed below.
                    if (!part) continue;
                    target.add(slotId);
                    const known = existing.get(slotId);
                    if (known) {
                        this.steherOrigin.set(known, { fence: anchor, anchorPmi, joinPmi });
                        continue; // already present — leave it
                    }

// Join-side of a junction → the shared Steher is healed from the anchor fence's CS_OUT.

if (joinEnds.has(sideOfPmi(anchorPmi) ?? "")) continue;

-                    target.add(slotId);

-                    const known = existing.get(slotId);

-                        this.steherOrigin.set(known, { fence: anchor, anchorPmi, joinPmi });

-                        continue; // already present — leave it

+                    // Resolver returned null (e.g. a suppress override) → not a target → an existing Steher

+                    // here is removed below.

+                    target.add(slotId);

+                    const known = existing.get(slotId);

+                        this.steherOrigin.set(known, { fence: anchor, anchorPmi, joinPmi });

+                        continue; // already present — leave it

// Real (stored) create, tagged with its slot so the next reconcile finds it. Apply

// create then merge to the working model so dockRotation reads the Steher's frames and

// the next junction sees the occupancy; the events go into the diff the core persists.


Bash

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

Re-run Steher tests


 ❯ src/configurator/modules/GekoSteher.test.ts (9 tests | 1 failed) 52ms
     × a junction carries exactly ONE shared Steher, not two side by side (both-side rule) 12ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  src/configurator/modules/GekoSteher.test.ts > Steher derivation > a junction carries exactly ONE shared Steher, not two side by side (both-side rule)
- Expected
+ Received
    131|         // anchor-L (free) + shared junction + clone-R (free) = 3 — NO…
 Test Files  1 failed (1)
      Tests  1 failed | 8 passed (9)
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command failed with exit code 1: vitest run src/configurator/modules/GekoSteher.test.ts


pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteher.test.ts -t "both-side" 2>&1 | grep -E 'expected|Received|Expected|Array|h-steher' | head

See both-side test received value


AssertionError: expected [ …(4) ] to have a length of 3 but got 4
- Expected
+ Received
    130|         expect(parts(scene).filter((id) => !id.startsWith("h-steher"))…
    132|         expect(parts(scene).filter((id) => id.startsWith("h-steher")))…


Ein Extra-Steher. Ich logge die Ziel-Slots am Ende von `reconcile`.


Edit

// Remove Stehers whose junction is gone (fence deleted / disconnected).

        console.error("[DBG] target", [...target], "existing", [...existing.keys()]);
        // Remove Stehers whose junction is gone (fence deleted / disconnected).


        console.error("[DBG] target", [...target], "existing", [...existing.keys()]);
        // Remove Stehers whose junction is gone (fence deleted / disconnected).

+        console.error("[DBG] target", [...target], "existing", [...existing.keys()]);

for (const [slotId, root] of existing) {


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteher.test.ts -t "both-side" 2>&1 | grep -E 'DBG' | head

See target/existing slots for both-side test


[DBG] target [
[DBG] target [


Edit

console.error("[DBG] target", [...target], "existing", [...existing.keys()]);

console.error("[DBG] target=" + JSON.stringify([...target]) + " existing=" + JSON.stringify([...existing.keys()]));


-        console.error("[DBG] target", [...target], "existing", [...existing.keys()]);

+        console.error("[DBG] target=" + JSON.stringify([...target]) + " existing=" + JSON.stringify([...existing.keys()]));


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteher.test.ts -t "both-side" 2>&1 | grep -oE '\[DBG\].*' | head

See target/existing slots one-line


Gefunden: der **echte** Steher belegt `anchor.CS_OUT_R_0`, also blockiert die Guard den Böppel-Klon-Merge dort → der Klon hängt disconnected → Junction-Erkennung scheitert → Klon bekommt beidseitig Steher. Fix: der Böppel-Merge darf auf den belegten Junction-Punkt docken (`ephemeral: true` = Guard-Bypass). Debug raus.


Edit

        console.error("[DBG] target=" + JSON.stringify([...target]) + " existing=" + JSON.stringify([...existing.keys()]));
        // Remove Stehers whose junction is gone (fence deleted / disconnected).

// Remove Stehers whose junction is gone (fence deleted / disconnected).


        console.error("[DBG] target=" + JSON.stringify([...target]) + " existing=" + JSON.stringify([...existing.keys()]));
        // Remove Stehers whose junction is gone (fence deleted / disconnected).

-        console.error("[DBG] target=" + JSON.stringify([...target]) + " existing=" + JSON.stringify([...existing.keys()]));


            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
            // The junction point is occupied by the shared Steher (a real element now) — dock past the
            // double-book guard, exactly as the Steher merges do.
            ephemeral: true,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }


            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
            // The junction point is occupied by the shared Steher (a real element now) — dock past the
            // double-book guard, exactly as the Steher merges do.
            ephemeral: true,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

+            // The junction point is occupied by the shared Steher (a real element now) — dock past the

+            // double-book guard, exactly as the Steher merges do.


Bash

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

Re-run Steher tests


Tests  9 passed (9)


9/9 Steher-Tests 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


 Test Files  10 passed (10)
      Tests  142 passed (142)
---done---


Stufe 2 steht — **echte Steher** über Reconciliation. 142 Tests grün, Typecheck sauber.

**Was jetzt gilt:**
- Steher sind **reale, gespeicherte Knoten**, nicht mehr ephemer. Ein **Reconciler** läuft einmal nach jeder mutierenden Aktion, berechnet das Ziel-Steher-Set pro Junction, diffed gegen die vorhandenen (per Slot-Tag) → **create/remove**.
- Der Diff wird per `appendToLastGroup` in **dieselbe Undo-Gruppe** wie die auslösende Aktion gebündelt → **eine Aktion = ein Undo-Schritt** (Zaun + seine Steher zusammen).
- `derive` ist raus dem Replay; Reconcile läuft vor `refine`, damit die Marker die finale Szene dekorieren.
- Recursion-Guard (ein Steher ist nie Anker), Suppress-Override entfernt den Steher, Böppel/Merge docken am belegten Junction-Punkt vorbei an der Guard.

**Damit weg:** die ganze Ephemer-Übersetzungsschicht — Select/Delete/Override/Böppel arbeiten jetzt an realen Knoten.

**Im Configurator zu prüfen:** bauen/Böppel → reale Steher; Undo nimmt Zaun + Steher **in einem Schritt** zurück; einen Steher direkt selektieren geht.

**Noch offen (Folgestufen):**
1. **Winkel → Bauform** (Ecken D/E/F) — `occupied` ist noch `[]`. Jetzt, mit realen Kanten und dem Winkel im `edge.rot`, kann ich das aus den Junction-Kanten füllen.
2. **Kaskaden-Löschen** (Zaun löschen → verwaiste Steher weg) — der Reconciler entfernt schon „verwaiste", aber ein Zaun-Lösch-Handler ist noch nicht verdrahtet.
3. **Böppel als ein Undo-Schritt** (aktuell create + merge getrennt) und **Snap** auf denselben Junction-Pfad.

Sag mir, ob die realen Steher im Bild sauber sitzen (Select/Move/Undo) — dann nehme ich Winkel→Bauform (Punkt 1) als nächstes.


Die logik greift nbeim move nicht
da wird NUR der Zaun platzieet (ok)
aber dann greift der Hook nicht und räumt auf


Gefunden: Mit **realen** Steher-Membern splittet `buildRegroupEvent` beim Move den Steher vom Zaun ab (der Zaun ist selektiert, der Steher nicht) → Zaun bewegt sich, Steher bleibt zurück, Reconcile behält ihn (Slot passt) → keine Aufräumung.

Fix im Reconciler: ein Steher zählt nur als vorhanden, wenn er noch **in der Gruppe seines Ankerzauns** steckt. Abgesplittete → entfernen und neu erzeugen (am neuen Ort). Removes kommen vor Creates (gleiche deterministische Id).


Edit

        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        // Existing real Stehers, keyed by their slot id (from the tag). Diff target against these.
        const existing = new Map(model.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.value, e.root] as const));
        const steherRoots = new Set(existing.values());
        const target = new Set<string>();
        const diff: GroupEvent[] = [];

        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        const tagged = model.elementsTagged(STEHER_SLOT_TAG);
        const steherRoots = new Set(tagged.map((e) => e.root));
        const fenceOfSlot = (slot: string) => slot.slice("steher@".length, slot.lastIndexOf(":"));
        // A Steher counts as present only if it is still docked to its anchor fence (same group). One
        // split off by a move/regroup — or whose fence is gone — is treated as absent → removed and (if
        // still a target) re-created in the fence's current group.
        const existing = new Map<string, string>();
        for (const { root, value } of tagged) {
            const fg = model.groupIdOfElement(fenceOfSlot(value));
            if (fg && model.groupIdOfElement(root) === fg) existing.set(value, root);
        }
        const target = new Set<string>();
        const diff: GroupEvent[] = [];


        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        // Existing real Stehers, keyed by their slot id (from the tag). Diff target against these.
        const existing = new Map(model.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.value, e.root] as const));
        const steherRoots = new Set(existing.values());
        const target = new Set<string>();
        const diff: GroupEvent[] = [];

        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        const tagged = model.elementsTagged(STEHER_SLOT_TAG);
        const steherRoots = new Set(tagged.map((e) => e.root));
        const fenceOfSlot = (slot: string) => slot.slice("steher@".length, slot.lastIndexOf(":"));
        // A Steher counts as present only if it is still docked to its anchor fence (same group). One
        // split off by a move/regroup — or whose fence is gone — is treated as absent → removed and (if
        // still a target) re-created in the fence's current group.
        const existing = new Map<string, string>();
        for (const { root, value } of tagged) {
            const fg = model.groupIdOfElement(fenceOfSlot(value));
            if (fg && model.groupIdOfElement(root) === fg) existing.set(value, root);
        }
        const target = new Set<string>();
        const diff: GroupEvent[] = [];

if (!this.steherRules.length || !this.resolveSteherPart) return [];

-        // Existing real Stehers, keyed by their slot id (from the tag). Diff target against these.

-        const existing = new Map(model.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.value, e.root] as const));

-        const steherRoots = new Set(existing.values());

+        const tagged = model.elementsTagged(STEHER_SLOT_TAG);

+        const steherRoots = new Set(tagged.map((e) => e.root));

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

+        // A Steher counts as present only if it is still docked to its anchor fence (same group). One

+        // split off by a move/regroup — or whose fence is gone — is treated as absent → removed and (if

+        // still a target) re-created in the fence's current group.

+        const existing = new Map<string, string>();

+        for (const { root, value } of tagged) {

+            const fg = model.groupIdOfElement(fenceOfSlot(value));

+            if (fg && model.groupIdOfElement(root) === fg) existing.set(value, root);


        // Remove Stehers whose junction is gone (fence deleted / disconnected).
        for (const [slotId, root] of existing) {
            if (!target.has(slotId)) {
                diff.push({ type: "group-remove", timestamp: 0, elementId: root } as GroupEvent);
                dbg(`removed Steher ${slotId}`);
            }
        }
        return diff;

        // Remove any tagged Steher that isn't a correctly-docked target: junction gone, split off by a
        // move, or fence deleted. Removes go FIRST so a re-created Steher (same deterministic id) isn't
        // dropped by its own removal.
        const removes: GroupEvent[] = [];
        for (const { root, value } of tagged) {
            if (!target.has(value) || existing.get(value) !== root) {
                removes.push({ type: "group-remove", timestamp: 0, elementId: root } as GroupEvent);
                dbg(`removed Steher ${value}`);
            }
        }
        return [...removes, ...diff];


        // Remove Stehers whose junction is gone (fence deleted / disconnected).
        for (const [slotId, root] of existing) {
            if (!target.has(slotId)) {
                diff.push({ type: "group-remove", timestamp: 0, elementId: root } as GroupEvent);
                dbg(`removed Steher ${slotId}`);
            }
        }
        return diff;

        // Remove any tagged Steher that isn't a correctly-docked target: junction gone, split off by a
        // move, or fence deleted. Removes go FIRST so a re-created Steher (same deterministic id) isn't
        // dropped by its own removal.
        const removes: GroupEvent[] = [];
        for (const { root, value } of tagged) {
            if (!target.has(value) || existing.get(value) !== root) {
                removes.push({ type: "group-remove", timestamp: 0, elementId: root } as GroupEvent);
                dbg(`removed Steher ${value}`);
            }
        }
        return [...removes, ...diff];

-        // Remove Stehers whose junction is gone (fence deleted / disconnected).

-        for (const [slotId, root] of existing) {

-                diff.push({ type: "group-remove", timestamp: 0, elementId: root } as GroupEvent);

-                dbg(`removed Steher ${slotId}`);

+        // Remove any tagged Steher that isn't a correctly-docked target: junction gone, split off by a

+        // move, or fence deleted. Removes go FIRST so a re-created Steher (same deterministic id) isn't

+        const removes: GroupEvent[] = [];

+            if (!target.has(value) || existing.get(value) !== root) {

+                removes.push({ type: "group-remove", timestamp: 0, elementId: root } as GroupEvent);

+                dbg(`removed Steher ${value}`);

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


Die logik greift nbeim move nicht da wird NUR der Zaun platzieet (ok)  aber dann greift der Hook nicht und räumt auf


Bash

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 move-split fix


Move-Split-Fix grün (142). Ich verankere die Kopplung nach Move noch im Test.


Edit

        expect(parts(moved)).toHaveLength(2); // fence + exactly one Steher, no duplication / no leak
    });

        expect(parts(moved)).toHaveLength(2); // fence + exactly one Steher, no duplication / no leak
        // The Steher followed the fence (re-docked in its group), not left behind by the regroup split.
        const steher = steherOf(moved)!;
        expect(groupNodeOf(moved, steher)).toBe(groupNodeOf(moved, anchorOf(moved)!));
    });


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

        expect(parts(moved)).toHaveLength(2); // fence + exactly one Steher, no duplication / no leak
        // The Steher followed the fence (re-docked in its group), not left behind by the regroup split.
        const steher = steherOf(moved)!;
        expect(groupNodeOf(moved, steher)).toBe(groupNodeOf(moved, anchorOf(moved)!));
    });

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (via the resolver,
// reusing the same scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open
// CS_OUT_R_0 end via CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [{ anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L" } }];

// Stand-in resolver: geko owns part choice. Here it returns the fixture scene, or null when the slot's
// override says "suppress".
const resolver: SteherPartResolver = (ctx) => (ctx.override === "suppress" ? null : productData);

const product = () => structuredClone(productData);
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 groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const steherOf = (scene: any) => parts(scene).find((id) => id.startsWith("h-steher"));
const anchorOf = (scene: any) => parts(scene).find((id) => !id.startsWith("h-steher"));

function setup() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES, resolver));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    const anchor = anchorOf(scene)!;
    const slotId = `steher@${anchor}:CS_OUT_R_0`;
    return { proc, scene, anchor, slotId };
}

describe("Steher derivation", () => {
    it("derives a Steher coupled to the anchor's group", () => {
        const { scene, anchor } = setup();
        const p = parts(scene);
        expect(p).toHaveLength(2); // anchor + derived Steher
        const steher = steherOf(scene)!;
        // Coupled: the Steher sits in the anchor's group node → a group-move on the base carries it along.
        expect(groupNodeOf(scene, steher)).toBe(groupNodeOf(scene, anchor));
    });

    it("hands the resolver the matrix Bauform (R-end, nothing else occupied → C)", () => {
        let seen: string | undefined;
        const proc = new DefaultCommandProcessor();
        proc.register(
            new GekoPlacementModule({}, RULES, (ctx) => {
                seen = ctx.bauform;
                return productData;
            }),
        );
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: product().handles });
        expect(seen).toBe("C"); // side R + occupied {} → C per GEKO-76
    });

    it("re-heals on move-end (hook runs on every replay, incl. move)", () => {
        const { proc, scene, anchor } = setup();
        const groupKey = groupNodeOf(scene, anchor);
        const moved = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "movement",
                lifecycleState: "update",
                subjects: [{ entity: { id: groupKey } }],
                positions: [{ values: [500, 0, 0] }],
                delta: { values: [500, 0, 0] },
            }),
        );
        expect(parts(moved)).toHaveLength(2); // Steher still there after move → derive ran on move-end
        const steher = steherOf(moved)!;
        expect(groupNodeOf(moved, steher)).toBe(groupNodeOf(moved, anchor)); // still coupled
    });

    it("full move path (prepare-action + move-end) keeps exactly one Steher", () => {
        const { proc, scene, anchor } = setup();
        proc.handleEvent({ eventTypeName: "selection-click", target: { id: anchor }, selectionAction: "set" });
        const prep = proc.handleEvent({ eventTypeName: "prepare-action", mode: "move" });
        const sel = (prep.find((r: any) => r.cmd === "set_selection") as any).args[0] as string[];
        const moved = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "movement",
                lifecycleState: "update",
                subjects: [{ entity: { id: sel[0].slice(1) } }],
                positions: [{ values: [500, 0, 0] }],
                delta: { values: [500, 0, 0] },
            }),
        );
        expect(parts(moved)).toHaveLength(2); // fence + exactly one Steher, no duplication / no leak
    });

    it("böppel on a healed Steher clones the anchor fence, not the Steher", () => {
        const { proc, scene } = setup();
        const steher = steherOf(scene)!;
        expect(parts(scene).filter((id) => !id.startsWith("h-steher"))).toHaveLength(1); // just the anchor fence
        const after = sceneOf(
            proc.handleEvent({
                eventTypeName: "selection-click",
                target: { id: `sel-marker-${steher}-CS_SELECT_0` },
                selectionAction: "set",
            }),
        );
        // A real fence was cloned (not a Steher): now two non-Steher parts (anchor + clone).
        expect(parts(after).filter((id) => !id.startsWith("h-steher"))).toHaveLength(2);
        // The junction now carries a SHARED Steher plus the clone's free-end Steher = 2 (not 0, not 3).
        expect(parts(after).filter((id) => id.startsWith("h-steher"))).toHaveLength(2);
    });

    it("a junction carries exactly ONE shared Steher, not two side by side (both-side rule)", () => {
        const BOTH: SteherRules = [
            { anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L", CS_OUT_L_0: "CS_EIN_R" } },
        ];
        const proc = new DefaultCommandProcessor();
        proc.register(new GekoPlacementModule({}, BOTH, () => productData));
        proc.setInitialScene(base());
        let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
        expect(parts(scene).filter((id) => id.startsWith("h-steher"))).toHaveLength(2); // a Steher at each end
        const steherR = parts(scene).find((id) => id.startsWith("h-steher") && id.includes("CS_OUT_R"))!;
        scene = sceneOf(
            proc.handleEvent({
                eventTypeName: "selection-click",
                target: { id: `sel-marker-${steherR}-CS_SELECT_0` },
                selectionAction: "set",
            }),
        );
        expect(parts(scene).filter((id) => !id.startsWith("h-steher"))).toHaveLength(2); // two fences
        // anchor-L (free) + shared junction + clone-R (free) = 3 — NOT 4 (no doubled junction Steher).
        expect(parts(scene).filter((id) => id.startsWith("h-steher"))).toHaveLength(3);
    });

    it("is never recorded — no undo step exists for the derived Steher", () => {
        const { proc, scene } = setup();
        expect(parts(scene)).toHaveLength(2);
        // Only the add-product is on the stack; undo removes the anchor (and with it the derived Steher).
        const back = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(back)).toHaveLength(0);
    });

    it("a suppress override removes the Steher and is undo-able", () => {
        const { proc, slotId } = setup();
        const suppressed = sceneOf(proc.handleEvent({ eventTypeName: "steher-override", slotId, payload: "suppress" }));
        expect(parts(suppressed)).toHaveLength(1); // Steher gone, anchor stays
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(undone)).toHaveLength(2); // override reverted → derived again
    });

    it("clearing the override brings the Steher back with the SAME id (deterministic across replays)", () => {
        const { proc, scene, slotId } = setup();
        const steherId = steherOf(scene)!;
        proc.handleEvent({ eventTypeName: "steher-override", slotId, payload: "suppress" });
        const cleared = sceneOf(proc.handleEvent({ eventTypeName: "steher-override", slotId })); // payload absent → clear
        expect(parts(cleared)).toHaveLength(2);
        expect(steherOf(cleared)).toBe(steherId); // re-derived with identical id, not churned
    });
});

+        // The Steher followed the fence (re-docked in its group), not left behind by the regroup split.

+        const steher = steherOf(moved)!;

+        expect(groupNodeOf(moved, steher)).toBe(groupNodeOf(moved, anchorOf(moved)!));


Bash

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

Verify move coupling assertion


Gefixt — 142 Tests grün.

**Move-Bug behoben:** Mit realen Steher-Membern hat `buildRegroupEvent` beim Move den Steher (nicht selektiert) vom Zaun (selektiert) abgesplittet → Zaun bewegte sich, Steher blieb, Reconcile behielt ihn (Slot passte) → keine Aufräumung.

**Fix im Reconciler:** Ein Steher zählt nur als vorhanden, wenn er noch **in der Gruppe seines Ankerzauns** steckt. Ein abgesplittter (oder dessen Zaun weg ist) gilt als absent → wird **entfernt und neu erzeugt** in der aktuellen Gruppe des Zauns. Removes laufen vor Creates (gleiche deterministische Id). Test verankert: nach Move sitzt der Steher wieder im Zaun-Gruppenknoten.

Als Nebeneffekt fällt damit auch das **Kaskaden-Löschen** teilweise raus: Zaun weg → sein Steher-Slot ist kein Target mehr → Steher wird entfernt.

Im Configurator prüfen: Zaun verschieben → der Steher folgt/heilt neu mit. Wenn das sitzt, nehme ich als nächstes **Winkel → Bauform** (Ecken D/E/F), jetzt wo die Kanten real sind.


Sehe ich eieder 2Steher zwischen den Zäunen?


Wahrscheinlich ja — und ich weiß warum. Ich prüfe `occupiedPmis`/`blockedPmis`.


210	    | GroupDetachEvent
211	    | GroupRegroupEvent
212	    | GroupModuleEvent
213	    | GroupRemoveEvent;
214
215	export class GroupProcessor {
216
217	    // Last replayed state — record-time queries (which group owns a part, where its open ends are)
218	    // read this; replay() rebuilds it from the event log.
219	    private groups = new Map<string, Group>();
220	    private handles = new Map<string, Handle>(); // id → element subtree handle (cloned)
221	    private elementHandles = new Map<string, Handle[]>(); // element root id → its full subtree
222	    private elementToGroup = new Map<string, string>(); // element root id → group id
223	    // Built each materialise: handle id → {parent handle id, role}. The role resolver reads this.
224	    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();
225	    // Module-owned override state, filed by module-event: moduleId → (key → payload). Read back by a
226	    // module's derive() to override the geometry it derives. Rebuilt each replay from the event log.
227	    private moduleState = new Map<string, Map<string, unknown>>();
228	    // Element roots created by a derivation (ephemeral). Excluded from regroup/selection baking.
229	    private ephemeralElements = new Set<string>();
230
231	    // Record-time id allocation: a fresh UUID per graph id (handle, transform key, group id/key).
232	    // Unique across instances and replays (baked into the create event). The prefix keeps the id a
233	    // valid CSS identifier — a bare UUID can start with a digit, which breaks the "#id" selector.
234	    private nextId(prefix: string): string {
235	        return `${prefix}-${crypto.randomUUID()}`;
236	    }
237
238	    // ---- record-time -------------------------------------------------------
239

    | GroupDetachEvent
    | GroupRegroupEvent
    | GroupModuleEvent
    | GroupRemoveEvent;

export class GroupProcessor {

    // Last replayed state — record-time queries (which group owns a part, where its open ends are)
    // read this; replay() rebuilds it from the event log.
    private groups = new Map<string, Group>();
    private handles = new Map<string, Handle>(); // id → element subtree handle (cloned)
    private elementHandles = new Map<string, Handle[]>(); // element root id → its full subtree
    private elementToGroup = new Map<string, string>(); // element root id → group id
    // Built each materialise: handle id → {parent handle id, role}. The role resolver reads this.
    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();
    // Module-owned override state, filed by module-event: moduleId → (key → payload). Read back by a
    // module's derive() to override the geometry it derives. Rebuilt each replay from the event log.
    private moduleState = new Map<string, Map<string, unknown>>();
    // Element roots created by a derivation (ephemeral). Excluded from regroup/selection baking.
    private ephemeralElements = new Set<string>();

    // Record-time id allocation: a fresh UUID per graph id (handle, transform key, group id/key).
    // Unique across instances and replays (baked into the create event). The prefix keeps the id a
    // valid CSS identifier — a bare UUID can start with a digit, which breaks the "#id" selector.
    private nextId(prefix: string): string {
        return `${prefix}-${crypto.randomUUID()}`;
    }

    // ---- record-time -------------------------------------------------------


grep -nE 'occupiedPmis\(\)|blockedPmis\(\)|for \(const e of g.edges\)|g.edges\.push' packages/frontend/src/configurator/model/GroupProcessor.ts | head

Locate occupiedPmis/blockedPmis and edge iteration


289:        for (const e of g.edges) {
321:    occupiedPmis(): { element: string; pmi: string }[] {
324:            for (const e of g.edges) {
341:    blockedPmis(): { element: string; pmi: string }[] {
342:        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
361:            for (const e of g.edges) {
685:                    const occupied = this.occupiedPmis();
816:        for (const e of g.edges) {


321	    occupiedPmis(): { element: string; pmi: string }[] {
322	        const out: { element: string; pmi: string }[] = [];
323	        for (const g of this.groups.values()) {
324	            for (const e of g.edges) {
325	                out.push({ element: e.from, pmi: e.fromPmi });
326	                out.push({ element: e.to, pmi: e.toPmi });
327	            }
328	        }
329	        return out;
330	    }
331
332	    // occupiedPmis plus the junction rule: where two elements meet straight (an edge on CS_OUT_*_0),
333	    // the two abutting posts share a branch slot per direction — only one 90 and one 270 may sit in
334	    // that gap. So a branch occupied on one post's CS_OUT_<side>_<90|270> also blocks the mirror
335	    // CS_OUT_<otherSide>_<same> on the neighbour. Both the placement drop points and the select
336	    // markers read this, so the rule is graph-constant across positioning and display.
337	    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
338	    // (the panel directions). All element-ends meeting at one post share it, so a build slot is open
339	    // only if its direction is free — i.e. it does not point into another element's body. This is
340	    // read by both the placement drop points and the select markers, so the rule is graph-constant.
341	    blockedPmis(): { element: string; pmi: string }[] {
342	        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
343	        const norm = (v: Vec3): Vec3 => {
344	            const l = Math.hypot(v[0], v[1], v[2]);
345	            return l < 1e-6 ? [0, 0, 0] : [v[0] / l, v[1] / l, v[2] / l];
346	        };
347	        const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
348	        const other = (end: string) => (end === "L" ? "R" : "L");
349	        for (const g of this.groups.values()) {
350	            const layout = this.computeLayout(g);
351	            // Cluster the element-ends that meet at one physical post (union-find over the edges).
352	            const parent = new Map<string, string>();
353	            const find = (x: string): string => {
354	                if (!parent.has(x)) parent.set(x, x);
355	                let r = parent.get(x)!;
356	                if (r !== x) { r = find(r); parent.set(x, r); }
357	                return r;
358	            };
359	            const union = (a: string, b: string) => parent.set(find(a), find(b));
360	            const node = (el: string, end: string) => `${el}|${end}`;
361	            for (const e of g.edges) {
362	                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
363	                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
364	                if (!fs || !ts) continue;
365	                union(node(e.from, fs), node(e.to, ts));

    occupiedPmis(): { element: string; pmi: string }[] {
        const out: { element: string; pmi: string }[] = [];
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                out.push({ element: e.from, pmi: e.fromPmi });
                out.push({ element: e.to, pmi: e.toPmi });
            }
        }
        return out;
    }

    // occupiedPmis plus the junction rule: where two elements meet straight (an edge on CS_OUT_*_0),
    // the two abutting posts share a branch slot per direction — only one 90 and one 270 may sit in
    // that gap. So a branch occupied on one post's CS_OUT_<side>_<90|270> also blocks the mirror
    // CS_OUT_<otherSide>_<same> on the neighbour. Both the placement drop points and the select
    // markers read this, so the rule is graph-constant across positioning and display.
    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
    // (the panel directions). All element-ends meeting at one post share it, so a build slot is open
    // only if its direction is free — i.e. it does not point into another element's body. This is
    // read by both the placement drop points and the select markers, so the rule is graph-constant.
    blockedPmis(): { element: string; pmi: string }[] {
        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
        const norm = (v: Vec3): Vec3 => {
            const l = Math.hypot(v[0], v[1], v[2]);
            return l < 1e-6 ? [0, 0, 0] : [v[0] / l, v[1] / l, v[2] / l];
        };
        const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
        const other = (end: string) => (end === "L" ? "R" : "L");
        for (const g of this.groups.values()) {
            const layout = this.computeLayout(g);
            // Cluster the element-ends that meet at one physical post (union-find over the edges).
            const parent = new Map<string, string>();
            const find = (x: string): string => {
                if (!parent.has(x)) parent.set(x, x);
                let r = parent.get(x)!;
                if (r !== x) { r = find(r); parent.set(x, r); }
                return r;
            };
            const union = (a: string, b: string) => parent.set(find(a), find(b));
            const node = (el: string, end: string) => `${el}|${end}`;
            for (const e of g.edges) {
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
                if (!fs || !ts) continue;
                union(node(e.from, fs), node(e.to, ts));


Ja — beim Snappen. Grund: Der schon vorhandene (reale) Steher belegt den Andockpunkt des Zauns → die Guard blockiert den **Zaun-zu-Zaun**-Merge dort → die Zäune verbinden sich nicht → jeder behält seinen eigenen End-Steher → 2 nebeneinander.

Fix: **Steher-Kanten zählen nicht als Zaun-Belegung.** Ich markiere die Steher-Merges als ephemere Kante und schließe sie aus `occupiedPmis`/`blockedPmis`/`connectionsOf` aus.


Edit

    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.
    rot?: Quat;
}

    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.
    rot?: Quat;
    // A derived edge (Steher docked onto a fence). Excluded from occupancy/junction queries so a Steher
    // never blocks a fence from docking at its point, and never counts as a fence-fence junction.
    ephemeral?: boolean;
}


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)

    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.
    rot?: Quat;
}

    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.
    rot?: Quat;
    // A derived edge (Steher docked onto a fence). Excluded from occupancy/junction queries so a Steher
    // never blocks a fence from docking at its point, and never counts as a fence-fence junction.
    ephemeral?: boolean;
}

// the two parts share orientation — the plain left/right case (CS_LINKS/CS_RECHTS carry no rotation).

+    // A derived edge (Steher docked onto a fence). Excluded from occupancy/junction queries so a Steher

+    // never blocks a fence from docking at its point, and never counts as a fence-fence junction.


                target.edges.push({
                    from: event.anchorRootId,
                    to: event.joinRootId,
                    fromPmi: event.anchorPmiId,
                    toPmi: event.joinPmiId,
                    rot: event.rot,
                });

                target.edges.push({
                    from: event.anchorRootId,
                    to: event.joinRootId,
                    fromPmi: event.anchorPmiId,
                    toPmi: event.joinPmiId,
                    rot: event.rot,
                    ephemeral: event.ephemeral,
                });


                target.edges.push({
                    from: event.anchorRootId,
                    to: event.joinRootId,
                    fromPmi: event.anchorPmiId,
                    toPmi: event.joinPmiId,
                    rot: event.rot,
                });

                target.edges.push({
                    from: event.anchorRootId,
                    to: event.joinRootId,
                    fromPmi: event.anchorPmiId,
                    toPmi: event.joinPmiId,
                    rot: event.rot,
                    ephemeral: event.ephemeral,
                });

+                    ephemeral: event.ephemeral,

for (const member of moving.members) this.elementToGroup.set(member, target.id);


        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; // Steher edges don't occupy a fence's point
                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) {
                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; // Steher edges don't occupy a fence's point
                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; // Steher edges don't occupy a fence's point


            for (const e of g.edges) {
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
                if (!fs || !ts) continue;

            for (const e of g.edges) {
                if (e.ephemeral) continue; // Steher edges don't form fence-fence junctions
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
                if (!fs || !ts) continue;


            for (const e of g.edges) {
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
                if (!fs || !ts) continue;

            for (const e of g.edges) {
                if (e.ephemeral) continue; // Steher edges don't form fence-fence junctions
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
                if (!fs || !ts) continue;

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 fence-fence junctions


        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; // Steher edges are not fence-fence connections
            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; // Steher edges are not fence-fence connections
            if (this.ephemeralElements.has(e.from) || this.ephemeralElements.has(e.to)) continue;

+            if (e.ephemeral) continue; // Steher edges are not fence-fence connections

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 });


Bash

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 ephemeral-edge fix


⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
 Test Files  1 failed | 9 passed (10)
      Tests  1 failed | 141 passed (142)
---done---


pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E '×|FAIL|Expected|Received|toContain|not\.' | head

Identify failing test


     × a junction carries exactly ONE shared Steher, not two side by side (both-side rule) 12ms
 FAIL  src/configurator/modules/GekoSteher.test.ts > Steher derivation > a junction carries exactly ONE shared Steher, not two side by side (both-side rule)
- Expected
+ Received
[ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL] @ad-cpq/common-frontend@0.1.1-local.5 test: `vitest run`


Klar: Der Böppel-Merge hatte `ephemeral: true` (mein früherer Guard-Bypass) — jetzt markiert das die **Zaun-Kante** als ephemer → Junction-Erkennung ignoriert sie → Doppel. Mit dem Occupancy-Fix braucht der Böppel-Merge den Bypass nicht mehr. Ich nehme ihn raus.


Edit

            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
            // The junction point is occupied by the shared Steher (a real element now) — dock past the
            // double-book guard, exactly as the Steher merges do.
            ephemeral: true,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }


            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
            // The junction point is occupied by the shared Steher (a real element now) — dock past the
            // double-book guard, exactly as the Steher merges do.
            ephemeral: true,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

-            // The junction point is occupied by the shared Steher (a real element now) — dock past the

-            // double-book guard, exactly as the Steher merges do.


Bash

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


Gefixt — 142 Tests grün.

**Steher-Kanten sind jetzt „ephemer" markiert und aus `occupiedPmis`/`blockedPmis`/`connectionsOf` ausgeschlossen.** Damit:
- blockiert ein Steher nicht mehr das Andocken eines Zauns → **Snap verbindet** die Zäune (statt geblockt zu werden),
- schrumpfen die Drop-Points nicht mehr (das Zaun-Ende mit Steher wird wieder als Snap-Ziel angeboten),
- verfälscht der Steher nicht die Junction-Erkennung.

Der Böppel-Merge ist wieder eine **echte** Zaun-Kante (der frühere Guard-Bypass ist raus — nicht mehr nötig).

Ergebnis: An der gemeinsamen Kante verbinden sich die Zäune, und die Heilung setzt **einen** geteilten Steher — kein Doppel mehr, auch beim Snappen.

Im Configurator prüfen: zwei Zäune zusammenschnappen → **ein** Steher am Übergang. Sitzt das, gehe ich an Winkel→Bauform (D/E/F).


Recht anbauen geht gar nicht
links hat nach einemn move gzickt - konnte ich aber nicht nachstellen


Recht anbauen geht gar nicht  links hat nach einemn move gzickt - konnte ich aber nicht nachstellen


Dass **links geht, rechts nicht**, ist der entscheidende Hinweis: mein Code ist L/R-**symmetrisch** (Regel, Reconcile, Böppel unterscheiden nur die PMI-Namen). Ein Code-Bug würde beide Seiten treffen. Eine reine **Rechts-Störung** deutet auf **Daten** — die versetzten R-Punkt-Koordinaten am Allround-Element, die schon in der Nomenklatur-Notiz als „geregelter Einbau nach rechts kaputt" stehen.

Also: `CS_OUT_R` / `CS_EIN_L` am Element sitzen vermutlich nicht spiegelbildlich zu `CS_OUT_L` / `CS_EIN_R` → der nach rechts geklonte Zaun landet falsch/überlappend → „geht gar nicht".

Ich kann das in einem Blick bestätigen: die Positionen/Rotationen von `CS_OUT_R`/`CS_EIN_L` gegen `CS_OUT_L`/`CS_EIN_R` am Allround-Element vergleichen. Soll ich?

Der **Links-nach-Move- Zicker** ohne Repro: den kann ich nicht jagen. Wenn er wiederkommt, sag mir die Schritte (welcher Zaun, wohin verschoben, was passierte) — dann rekonstruiere ich ihn im Test.


Einbfaches element paltzieren -> Steher rechts klicken --> böppel wählen --> passiert nichts


Der Repro ist eindeutig — ich prüfe die Daten. Wenn Rechts spezifisch „nichts" macht (Klon landet überlappend auf dem Anker), liegt es an den R-Punkt-Frames. Ich vergleiche `CS_OUT_R`/`CS_EIN_L` gegen `CS_OUT_L`/`CS_EIN_R` am Allround-Element.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products 2>/dev/null; f=schutzzaun_allround/SAE0260PNN.json; jq -c '.handles[].components[]? | select(._resourceClass=="component_pmi" and (.id|test("^CS_(OUT|EIN)_[LR]$"))) | {id, pos: .properties.position.value, rot: .properties.rotation.value}' "$f"

Compare CS_OUT/CS_EIN L vs R pos+rot on allround element


Die Daten sind **symmetrisch** (Straight-Dock = Identität für beide Seiten), und im Unit-Test baut der Rechts-Böppel einen Zaun. Also liegt „passiert nichts" nicht an der Geometrie und nicht am reinen Build-Pfad — es muss am **realen Allround-Steher** (andere `CS_SELECT`-Nomenklatur) im Bild liegen. Statt zu raten baue ich Diagnose-Logs in den Klick-Pfad, die du am Bild siehst.


195	    // interpret it — the resolver does); null/absent clears the override back to the derived default.
196	    // Undo-able; re-renders from the fresh model.
197	    private setSteherOverride(event: any, ctx: CommandContext): UIReaction[] {
198	        ctx.record({
199	            type: "module-event",
200	            timestamp: Date.now(),
201	            moduleId: STEHER_MODULE_ID,
202	            key: event.slotId,
203	            payload: event.payload ?? null,
204	        } as GroupEvent);
205	        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
206	    }
207
208	    // ---- Anbau via select marker -------------------------------------------------------------------
209
210	    // Intercept a click on one of our select-PMI marker spheres — dock a copy of the element there and
211	    // stop the plain selection from running.
212	    private interceptMarkerClick(event: any, ctx: CommandContext): PrepareResult {
213	        const targetId: unknown = event.target?.id;
214	        if (typeof targetId !== "string" || !targetId.startsWith("sel-marker-")) return null;

    // interpret it — the resolver does); null/absent clears the override back to the derived default.
    // Undo-able; re-renders from the fresh model.
    private setSteherOverride(event: any, ctx: CommandContext): UIReaction[] {
        ctx.record({
            type: "module-event",
            timestamp: Date.now(),
            moduleId: STEHER_MODULE_ID,
            key: event.slotId,
            payload: event.payload ?? null,
        } as GroupEvent);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }

    // ---- Anbau via select marker -------------------------------------------------------------------

    // Intercept a click on one of our select-PMI marker spheres — dock a copy of the element there and
    // stop the plain selection from running.
    private interceptMarkerClick(event: any, ctx: CommandContext): PrepareResult {
        const targetId: unknown = event.target?.id;
        if (typeof targetId !== "string" || !targetId.startsWith("sel-marker-")) return null;


214	        if (typeof targetId !== "string" || !targetId.startsWith("sel-marker-")) return null;
215	        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
216	        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
217	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
218	        // A böppel on a healed (ephemeral) Steher clones the ANCHOR FENCE, never the Steher. Legacy
219	        // markers that sit on a real element (basic model) still clone that element.
220	        const origin = this.steherOrigin.get(element);
221	        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };
222	        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };
223	    }
224
225	    // Böppel on a healed Steher → clone its anchor fence and dock a fresh copy onto the same open end the
226	    // Steher heals from. The Steher is never referenced; it re-heals around the new fence. No occupancy
227	    // guard: a Steher only heals at a real-free end (derive skips occupied ones), so that end is open;

        if (typeof targetId !== "string" || !targetId.startsWith("sel-marker-")) return null;
        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        // A böppel on a healed (ephemeral) Steher clones the ANCHOR FENCE, never the Steher. Legacy
        // markers that sit on a real element (basic model) still clone that element.
        const origin = this.steherOrigin.get(element);
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };
    }

    // Böppel on a healed Steher → clone its anchor fence and dock a fresh copy onto the same open end the
    // Steher heals from. The Steher is never referenced; it re-heals around the new fence. No occupancy
    // guard: a Steher only heals at a real-free end (derive skips occupied ones), so that end is open;


Edit

        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        // A böppel on a healed (ephemeral) Steher clones the ANCHOR FENCE, never the Steher. Legacy
        // markers that sit on a real element (basic model) still clone that element.
        const origin = this.steherOrigin.get(element);
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };

        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        // A böppel on a healed (ephemeral) Steher clones the ANCHOR FENCE, never the Steher. Legacy
        // markers that sit on a real element (basic model) still clone that element.
        const origin = this.steherOrigin.get(element);
        if ((globalThis as any).__cpqSteher) {
            console.log("[steher] böppel click pmi=" + pmi + " element=" + element + " origin=" + JSON.stringify(origin) + " knownStehers=" + JSON.stringify([...this.steherOrigin.keys()]));
        }
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };


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 pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        // A böppel on a healed (ephemeral) Steher clones the ANCHOR FENCE, never the Steher. Legacy
        // markers that sit on a real element (basic model) still clone that element.
        const origin = this.steherOrigin.get(element);
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };

        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        // A böppel on a healed (ephemeral) Steher clones the ANCHOR FENCE, never the Steher. Legacy
        // markers that sit on a real element (basic model) still clone that element.
        const origin = this.steherOrigin.get(element);
        if ((globalThis as any).__cpqSteher) {
            console.log("[steher] böppel click pmi=" + pmi + " element=" + element + " origin=" + JSON.stringify(origin) + " knownStehers=" + JSON.stringify([...this.steherOrigin.keys()]));
        }
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };

+        if ((globalThis as any).__cpqSteher) {

+            console.log("[steher] böppel click pmi=" + pmi + " element=" + element + " origin=" + JSON.stringify(origin) + " knownStehers=" + JSON.stringify([...this.steherOrigin.keys()]));


Diagnose-Log ist drin (guarded). Kurzer Testlauf, dann Anleitung.


Bash

pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Tests '; echo "done"

Verify tests still green


      Tests  142 passed (142)
done


Diagnose-Log ist drin, Tests grün. So findest du's am Bild:

1. Konsole (im `storybook-preview-iframe`): `window.__cpqSteher = true`
2. Einfaches Element platzieren → **rechten** Steher selektieren → Böppel klicken.
3. Es erscheint eine Zeile:
```
[steher] böppel click pmi=… element=… origin=… knownStehers=[…]
```

Poste mir diese Zeile — für den **rechten** und zum Vergleich den **linken** Böppel.

Das sagt eindeutig, wo es klemmt:
- **`origin=undefined`** beim rechten → der Marker/Steher-Id-Parse trifft den Steher nicht (Id-Problem) → es fällt in den falschen Zweig, kein Zaun-Klon.
- **`origin={…}` gesetzt** (wie links) → der Klick feuert korrekt, dann liegt's danach (Merge/Überlappung) — dann schaue ich `buildFenceFromSteher` für rechts an.

Mit den zwei Zeilen (rechts + links) weiß ich, ob es der Parse oder die Geometrie ist — ohne weiter zu raten.


Jetzt gehrt es nbirgends mehr


„Nirgends mehr" = Regression aus meinem Ephemer-Kanten-Umbau. Ich rolle den zurück auf den Stand, wo der Böppel ging (Steher-Kanten belegen wieder, Böppel-Merge mit Bypass). Das Snap-2-Problem gehe ich danach anders an — Funktion vor Feinschliff.


Edit

        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                if (e.ephemeral) continue; // Steher edges don't occupy a fence's point
                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) {
                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; // Steher edges don't occupy a fence's point
                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) {
                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; // Steher edges don't occupy a fence's point


            for (const e of g.edges) {
                if (e.ephemeral) continue; // Steher edges don't form fence-fence junctions
                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 fence-fence junctions
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];

            for (const e of g.edges) {
                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 fence-fence junctions

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


        const out: Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> = [];
        for (const e of g.edges) {
            if (e.ephemeral) continue; // Steher edges are not fence-fence connections
            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; // Steher edges are not fence-fence connections
            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;

-            if (e.ephemeral) continue; // Steher edges are not fence-fence connections

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 });


            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

            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);
    }


            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);
    }

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


Bash

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---"

Verify revert green


 Test Files  10 passed (10)
      Tests  142 passed (142)
---done---


Zurückgerollt — 142 Tests grün. Der Ephemer-Kanten-Umbau ist raus (der die Regression brachte), der Böppel-Merge hat wieder seinen Guard-Bypass. Nach dem HMR-Reload sollte der Böppel wieder gehen wie zuvor.

Das Snap-2-Steher-Problem gehe ich später gezielter an — nicht über die globale Occupancy-Ausnahme, die alles aufriss.

Der Diagnose-Log ist noch drin. Bitte jetzt für **rechts**:
1. `window.__cpqSteher = true`
2. Element platzieren → rechten Steher → Böppel klicken.
3. Poste die `[steher] böppel click …`-Zeile.

Damit sehe ich, ob `origin` beim rechten Böppel gesetzt ist (dann Geometrie) oder `undefined` (dann Id-Parse) — ohne weiter zu raten.


Rechts tut sich immer noch nix


Ich hab die Ursache: Der rechte Steher ist gegenüber dem Zaun **gedreht** — der geklickte Böppel-Winkel wird aber im **Zaun-Frame** als rohe Drehung angewandt, also schickt „rechts" den Klon rückwärts in den Anker → Überlappung → „nichts". Links trifft zufällig Winkel 0 (gerade).

Fix: die Richtung aus dem **echten Frame des Böppel** (`CS_OUT_<Winkel>` am Steher) nehmen, nicht aus der rohen Zahl — genau „Ausrichtung des Böppel". Ich rechne die Klon-Rotation über das Steher-Frame.


Edit

        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };

        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, element, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };


        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };

        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, element, stop, ctx) };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };

console.log("[steher] böppel click pmi=" + pmi + " element=" + element + " origin=" + JSON.stringify(origin) + " knownStehers=" + JSON.stringify([...this.steherOrigin.keys()]));

-        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, stop, ctx) };

+        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, element, stop, ctx) };


228	    // Böppel on a healed Steher → clone its anchor fence and dock a fresh copy onto the same open end the
229	    // Steher heals from. The Steher is never referenced; it re-heals around the new fence. No occupancy
230	    // guard: a Steher only heals at a real-free end (derive skips occupied ones), so that end is open;
231	    // the ephemeral Steher edge does not block, and the merge's own apply-time guard catches real doubles.
232	    private buildFenceFromSteher(
233	        origin: { fence: string; anchorPmi: string; joinPmi: string },
234	        clickedPmi: string,
235	        stop: UIReaction,
236	        ctx: CommandContext,
237	    ): UIReaction[] {
238	        ctx.replay();
239	        const targetGroup = ctx.groups.groupIdOfElement(origin.fence);
240	        const scene = ctx.groups.sceneOfElement(origin.fence);
241	        if (!targetGroup || !scene) return [stop];
242	        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
243	        ctx.record(createEvent);
244	        // Straight dock (both frames off the anchor fence; the fresh copy isn't in the handle map yet)
245	        // composed with the branch angle from the clicked böppel (CS_SELECT_<angle>) around the vertical
246	        // (Y) axis. NOTE: axis / order / sign of `branch` are the un-verifiable-blind bit — tune at the
247	        // running image (0 = straight, 90 / 270 = the two corners).
248	        const angleDeg = Number(clickedPmi.match(/(\d+)\s*$/)?.[1] ?? "0");
249	        const branch = eulerZyxRadToQuat([0, (angleDeg * Math.PI) / 180, 0]);
250	        const straight = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi);
251	        const rot = angleDeg ? quatMul(branch, straight) : straight;
252	        ctx.record({
253	            type: "group-merge",
254	            timestamp: Date.now(),
255	            targetGroupId: targetGroup,
256	            movingGroupId: createEvent.groupId,
257	            anchorRootId: origin.fence,
258	            joinRootId: createEvent.rootHandleId,
259	            anchorPmiId: origin.anchorPmi,
260	            joinPmiId: origin.joinPmi,
261	            rot,
262	            ephemeral: true, // dock past the guard: the junction point carries the shared Steher
263	        } as GroupEvent);
264	        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
265	        return ctx.selectionReactions(stop);
266	    }
267

    // Böppel on a healed Steher → clone its anchor fence and dock a fresh copy onto the same open end the
    // Steher heals from. The Steher is never referenced; it re-heals around the new fence. No occupancy
    // guard: a Steher only heals at a real-free end (derive skips occupied ones), so that end is open;
    // the ephemeral Steher edge does not block, and the merge's own apply-time guard catches real doubles.
    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: 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]);
        ctx.record(createEvent);
        // Straight dock (both frames off the anchor fence; the fresh copy isn't in the handle map yet)
        // composed with the branch angle from the clicked böppel (CS_SELECT_<angle>) around the vertical
        // (Y) axis. NOTE: axis / order / sign of `branch` are the un-verifiable-blind bit — tune at the
        // running image (0 = straight, 90 / 270 = the two corners).
        const angleDeg = Number(clickedPmi.match(/(\d+)\s*$/)?.[1] ?? "0");
        const branch = eulerZyxRadToQuat([0, (angleDeg * Math.PI) / 180, 0]);
        const straight = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi);
        const rot = angleDeg ? quatMul(branch, straight) : straight;
        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);
    }


    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: 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]);
        ctx.record(createEvent);
        // Straight dock (both frames off the anchor fence; the fresh copy isn't in the handle map yet)
        // composed with the branch angle from the clicked böppel (CS_SELECT_<angle>) around the vertical
        // (Y) axis. NOTE: axis / order / sign of `branch` are the un-verifiable-blind bit — tune at the
        // running image (0 = straight, 90 / 270 = the two corners).
        const angleDeg = Number(clickedPmi.match(/(\d+)\s*$/)?.[1] ?? "0");
        const branch = eulerZyxRadToQuat([0, (angleDeg * Math.PI) / 180, 0]);
        const straight = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi);
        const rot = angleDeg ? quatMul(branch, straight) : straight;
        ctx.record({

    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]);
        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({


    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: 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]);
        ctx.record(createEvent);
        // Straight dock (both frames off the anchor fence; the fresh copy isn't in the handle map yet)
        // composed with the branch angle from the clicked böppel (CS_SELECT_<angle>) around the vertical
        // (Y) axis. NOTE: axis / order / sign of `branch` are the un-verifiable-blind bit — tune at the
        // running image (0 = straight, 90 / 270 = the two corners).
        const angleDeg = Number(clickedPmi.match(/(\d+)\s*$/)?.[1] ?? "0");
        const branch = eulerZyxRadToQuat([0, (angleDeg * Math.PI) / 180, 0]);
        const straight = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi);
        const rot = angleDeg ? quatMul(branch, straight) : straight;
        ctx.record({

    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]);
        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({

-        // Straight dock (both frames off the anchor fence; the fresh copy isn't in the handle map yet)

-        // composed with the branch angle from the clicked böppel (CS_SELECT_<angle>) around the vertical

-        // (Y) axis. NOTE: axis / order / sign of `branch` are the un-verifiable-blind bit — tune at the

-        // running image (0 = straight, 90 / 270 = the two corners).

-        const angleDeg = Number(clickedPmi.match(/(\d+)\s*$/)?.[1] ?? "0");

-        const branch = eulerZyxRadToQuat([0, (angleDeg * Math.PI) / 180, 0]);

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

-        const rot = angleDeg ? quatMul(branch, straight) : straight;

+        // 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);


`eulerZyxRadToQuat` ist jetzt ungenutzt — ich entferne es aus dem Import.


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

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


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

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

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

+import { IDENTITY_QUAT, quatMul } 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 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---"

Run tests and typecheck after frame-based rot


Gefixt — 142 grün, Typecheck sauber.

Der Böppel-Anbau nimmt die Richtung jetzt aus dem **echten Frame des geklickten Böppel** (`CS_OUT_<Winkel>` am Steher), zurückgerechnet in den Zaun-Frame (`rot = S ∘ rel`) — statt aus der rohen Winkelzahl. Damit ist es **symmetrisch**: der rechte Steher schickt den Klon nicht mehr rückwärts in den Anker.

Reload, dann prüfen:
- **Rechts** gerade anbauen → baut jetzt einen Zaun (statt „nichts")?
- **Links** weiterhin ok?
- **90/270** an beiden Seiten die richtige Ecke?

Falls eine Richtung noch falsch dreht, ist es nur die Reihenfolge im `quatMul(s, rel)` — das drehe ich dann in einem Schritt. Aber der Ansatz (Böppel-Frame statt Zahl) ist der richtige, und er ist L/R-gleich.

Den Debug-Log (`window.__cpqSteher`) lasse ich noch drin, falls rechts weiter zickt — dann sehen wir, ob `origin` gesetzt ist.


sieht aus. als ginge es kannst du tests dafür schreiben?


Ja. Ich schreibe Tests mit den **echten Allround-Szenen** (SAE-Element + SAP4180-Steher), damit die R/L-Geometrie wirklich getroffen wird — genau der Fall, der „rechts tut sich nix" war.


Write

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]));
    });
});


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

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]));
    });
});


Bash

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

Run allround geometry tests


Tests  4 passed (4)


Alle 4 grün — inkl. „rechter Böppel baut, nicht überlappend" (die „rechts tut sich nix"-Absicherung) und „R/L bauen in entgegengesetzte Richtungen". 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  146 passed (146)
---done---


146 grün (4 neue), Typecheck sauber. Der Debug-Log wird nicht mehr gebraucht — ich nehme ihn raus.


Edit

        const origin = this.steherOrigin.get(element);
        if ((globalThis as any).__cpqSteher) {
            console.log("[steher] böppel click pmi=" + pmi + " element=" + element + " origin=" + JSON.stringify(origin) + " knownStehers=" + JSON.stringify([...this.steherOrigin.keys()]));
        }
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, element, stop, ctx) };

        const origin = this.steherOrigin.get(element);
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, element, stop, ctx) };


        const origin = this.steherOrigin.get(element);
        if ((globalThis as any).__cpqSteher) {
            console.log("[steher] böppel click pmi=" + pmi + " element=" + element + " origin=" + JSON.stringify(origin) + " knownStehers=" + JSON.stringify([...this.steherOrigin.keys()]));
        }
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, element, stop, ctx) };

        const origin = this.steherOrigin.get(element);
        if (origin) return { stopDefault: true, reactions: this.buildFenceFromSteher(origin, pmi, element, stop, ctx) };

// A böppel on a healed (ephemeral) Steher clones the ANCHOR FENCE, never the Steher. Legacy

// markers that sit on a real element (basic model) still clone that element.

-        if ((globalThis as any).__cpqSteher) {

-            console.log("[steher] böppel click pmi=" + pmi + " element=" + element + " origin=" + JSON.stringify(origin) + " knownStehers=" + JSON.stringify([...this.steherOrigin.keys()]));

return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };


Bash

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

Final verify


      Tests  146 passed (146)
done


Erledigt — 146 Tests grün, Typecheck sauber, Debug-Log raus.

**Neue Tests** (`GekoSteherAllround.test.ts`, echte Allround-Szenen SAE-Element + SAP4180-Steher — genau die R/L-Geometrie, die kaputt war):
- Element platzieren → je ein Steher an L- und R-Ende.
- **Rechter Böppel baut einen Zaun, vom Anker weg gedockt** (>100 vom Ursprung) — die direkte Absicherung gegen „rechts tut sich nix" (rückwärts/überlappend wäre ~0).
- Linker Böppel baut ebenso.
- R- und L-Klon landen auf **entgegengesetzten Seiten** (gegensätzliches X-Vorzeichen).

Damit ist der frame-basierte Richtungs-Fix (Böppel-Frame statt roher Winkel) regressionsgesichert.

**Stand der offenen Punkte** aus dem Steher-Umbau:
1. **Snap-2-Steher** — noch offen (ich gehe es gezielter an, nicht über die globale Occupancy-Ausnahme, die alles aufriss).
2. **Winkel → Bauform** (Ecken D/E/F) — `occupied` noch `[]`, kommt aus den Kanten.
3. **Böppel als ein Undo-Schritt** (create + merge aktuell getrennt).


Dann au das noch zu Ende, oder?


Ja. Diesmal mit Tests als Netz, nicht Panik-Revert. Ich gehe alle drei durch.

**Punkt 3 zuerst (Böppel = ein Undo-Schritt):** create + merge in eine Gruppe bündeln.