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)

    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("is never recorded — no undo step exists for the derived Steher", () => {

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

+    it("re-heals on move-end (hook runs on every replay, incl. move)", () => {

+        const { proc, scene, anchor } = setup();

+        const groupKey = groupNodeOf(scene, anchor);

+                eventTypeName: "interaction-mode",

+                lifecycleState: "update",

+                subjects: [{ entity: { id: groupKey } }],

+        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


Bash

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

Run move-heal test


Tests  5 passed (5)


`derive` läuft beim direkten Move (Test grün). Ich prüfe jetzt den **vollen App-Pfad** (prepare-action → regroup → move-end) — dort könnte der ephemere Steher als „Member" in den Regroup lecken und alles zerschießen.


Edit

it("is never recorded — no undo step exists for the derived Steher", () => {

    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("is never recorded — no undo step exists for the derived Steher", () => {


    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("is never recorded — no undo step exists for the derived Steher", () => {

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

+    it("full move path (prepare-action + move-end) keeps exactly one Steher", () => {

+        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[];

+                subjects: [{ entity: { id: sel[0].slice(1) } }],

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


der hook zieht nicht bei move ich hätte erwartet, dass zwei neue steher ein und zwei verwaist ausgebaut werden


Bash

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

Run full move path test


 ❯ src/configurator/modules/GekoSteher.test.ts (6 tests | 1 failed) 26ms
     × full move path (prepare-action + move-end) keeps exactly one Steher 8ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  src/configurator/modules/GekoSteher.test.ts > Steher derivation > full move path (prepare-action + move-end) keeps exactly one Steher
AssertionError: expected [ …(3) ] to have a length of 2 but got 3
- Expected
+ Received
     79|         expect(parts(moved)).toHaveLength(2); // fence + exactly one S…
 Test Files  1 failed (1)
      Tests  1 failed | 5 passed (6)


Bug bestätigt: nach dem vollen Move-Pfad **3 Steher statt 1**. Ursache: der ephemere Steher wird als „Member" in die Gruppe geheilt; `prepare-action`→`buildRegroupEvent` liest die Member (inkl. Steher) und **backt seine Id in ein gespeichertes Regroup-Event** → beim nächsten Replay existiert er als Stored-Member *und* wird neu geheilt → Duplikat. Das ist dein „Hook zieht nicht bei move" (Move produziert Müll).

Fix: **abgeleitete Member dürfen nie in gespeicherte Events lecken.** Ich markiere derive-Creates als ephemer und schließe sie aus Regroup/Selektions-Expansion aus. Ich lese `buildRegroupEvent`.


438	        const components = this.connectedComponents(g.members, remEdges);
439	        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, layout));
440	        return { type: "group-detach", timestamp: Date.now(), sourceGroupId, elementId, newGroups };
441	    }
442
443	    // Record-time: turn a selection of element roots into movable groups for move/rotate. Per affected
444	    // source group, the selected members split into connected components (each → a movable group) and
445	    // the non-selected remainder into its components (stay). A group that is selected whole and in one
446	    // piece is left untouched. Returns the regroup event (null if nothing had to break) and the ids of
447	    // the groups to move (whole groups kept + new selected-component groups).
448	    buildRegroupEvent(selectedElementIds: string[]): { event: GroupRegroupEvent | null; moveGroupIds: string[] } {
449	        const byGroup = new Map<string, string[]>();
450	        for (const id of selectedElementIds) {
451	            const gid = this.elementToGroup.get(id);
452	            if (!gid) continue;
453	            if (!byGroup.has(gid)) byGroup.set(gid, []);
454	            byGroup.get(gid)!.push(id);
455	        }
456
457	        const sourceGroupIds: string[] = [];
458	        const newGroups: GroupRegroupEvent["newGroups"] = [];
459	        const moveGroupIds: string[] = [];
460
461	        for (const [gid, sel] of byGroup) {
462	            const g = this.groups.get(gid)!;
463	            const selSet = new Set(sel);
464	            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
465	            const selComponents = this.connectedComponents(sel, selInduced);
466
467	            if (sel.length === g.members.length && selComponents.length === 1) {
468	                moveGroupIds.push(gid); // whole group selected in one piece — keep it, just move it
469	                continue;
470	            }
471
472	            const layout = this.computeLayout(g);
473	            const restMembers = g.members.filter((m) => !selSet.has(m));
474	            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
475	            const restComponents = this.connectedComponents(restMembers, restInduced);
476
477	            sourceGroupIds.push(gid);
478	            for (const comp of selComponents) {
479	                const ng = this.makeComponentGroup(g, comp, g.edges, layout);
480	                newGroups.push(ng);
481	                moveGroupIds.push(ng.groupId);
482	            }
483	            for (const comp of restComponents) {
484	                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));
485	            }
486	        }
487
488	        if (!sourceGroupIds.length) return { event: null, moveGroupIds };
489	        return { event: { type: "group-regroup", timestamp: Date.now(), sourceGroupIds, newGroups }, moveGroupIds };
490	    }
491
492	    // A connected component of a source group → a fresh group, keeping its members' world positions
493	    // AND orientations. The new group is anchored at its origin (members[0]): the position is shifted by
494	    // the origin's local position, the rotation is the group rotation composed with the origin's
495	    // accumulated in-group rotation — otherwise a component whose origin sat behind an angled dock would
496	    // snap back to the source origin's orientation. Edges restricted to it.
497	    private makeComponentGroup(
498	        g: Group,
499	        members: string[],

        const components = this.connectedComponents(g.members, remEdges);
        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, layout));
        return { type: "group-detach", timestamp: Date.now(), sourceGroupId, elementId, newGroups };
    }

    // Record-time: turn a selection of element roots into movable groups for move/rotate. Per affected
    // source group, the selected members split into connected components (each → a movable group) and
    // the non-selected remainder into its components (stay). A group that is selected whole and in one
    // piece is left untouched. Returns the regroup event (null if nothing had to break) and the ids of
    // the groups to move (whole groups kept + new selected-component groups).
    buildRegroupEvent(selectedElementIds: string[]): { event: GroupRegroupEvent | null; moveGroupIds: string[] } {
        const byGroup = new Map<string, string[]>();
        for (const id of selectedElementIds) {
            const gid = this.elementToGroup.get(id);
            if (!gid) continue;
            if (!byGroup.has(gid)) byGroup.set(gid, []);
            byGroup.get(gid)!.push(id);
        }

        const sourceGroupIds: string[] = [];
        const newGroups: GroupRegroupEvent["newGroups"] = [];
        const moveGroupIds: string[] = [];

        for (const [gid, sel] of byGroup) {
            const g = this.groups.get(gid)!;
            const selSet = new Set(sel);
            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
            const selComponents = this.connectedComponents(sel, selInduced);

            if (sel.length === g.members.length && selComponents.length === 1) {
                moveGroupIds.push(gid); // whole group selected in one piece — keep it, just move it
                continue;
            }

            const layout = this.computeLayout(g);
            const restMembers = g.members.filter((m) => !selSet.has(m));
            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, g.edges, layout);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));
            }
        }

        if (!sourceGroupIds.length) return { event: null, moveGroupIds };
        return { event: { type: "group-regroup", timestamp: Date.now(), sourceGroupIds, newGroups }, moveGroupIds };
    }

    // A connected component of a source group → a fresh group, keeping its members' world positions
    // AND orientations. The new group is anchored at its origin (members[0]): the position is shifted by
    // the origin's local position, the rotation is the group rotation composed with the origin's
    // accumulated in-group rotation — otherwise a component whose origin sat behind an angled dock would
    // snap back to the source origin's orientation. Edges restricted to it.
    private makeComponentGroup(
        g: Group,
        members: string[],


Ursache klar: `buildRegroupEvent` sieht den ephemeren Steher als „rest"-Member und backt ihn ins Regroup. Fix: derive-Creates als `ephemeral` markieren; Regroup (und Selektions-Expansion) ignorieren ephemere Member. Ich baue das im `GroupProcessor`.


Edit

export interface GroupCreateEvent {
    type: "group-create";
    timestamp: number;
    groupId: string;
    groupKey: string;
    rootHandleId: string; // the element root among `handles`
    handles: Handle[]; // the element's full subtree, already opaquely re-identified
    position: Vec3;
    rotation: Quat;
}

export interface GroupCreateEvent {
    type: "group-create";
    timestamp: number;
    groupId: string;
    groupKey: string;
    rootHandleId: string; // the element root among `handles`
    handles: Handle[]; // the element's full subtree, already opaquely re-identified
    position: Vec3;
    rotation: Quat;
    // Set by a derivation (never stored): the element exists only for this replay. Core operations that
    // bake stored events (regroup, selection-expansion) must skip it, or its ephemeral id leaks into the
    // event log and duplicates on the next derive.
    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)

export interface GroupCreateEvent {
    type: "group-create";
    timestamp: number;
    groupId: string;
    groupKey: string;
    rootHandleId: string; // the element root among `handles`
    handles: Handle[]; // the element's full subtree, already opaquely re-identified
    position: Vec3;
    rotation: Quat;
}

export interface GroupCreateEvent {
    type: "group-create";
    timestamp: number;
    groupId: string;
    groupKey: string;
    rootHandleId: string; // the element root among `handles`
    handles: Handle[]; // the element's full subtree, already opaquely re-identified
    position: Vec3;
    rotation: Quat;
    // Set by a derivation (never stored): the element exists only for this replay. Core operations that
    // bake stored events (regroup, selection-expansion) must skip it, or its ephemeral id leaks into the
    // event log and duplicates on the next derive.
    ephemeral?: boolean;
}

+    // Set by a derivation (never stored): the element exists only for this replay. Core operations that

+    // bake stored events (regroup, selection-expansion) must skip it, or its ephemeral id leaks into the

+    // event log and duplicates on the next derive.


        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
            ephemeral: true,
        };
    }

    // Whether an element root was created by a derivation (ephemeral, never stored).
    isEphemeral(elementRoot: string): boolean {
        return this.ephemeralElements.has(elementRoot);
    }


        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
            ephemeral: true,
        };
    }

    // Whether an element root was created by a derivation (ephemeral, never stored).
    isEphemeral(elementRoot: string): boolean {
        return this.ephemeralElements.has(elementRoot);
    }

+    // Whether an element root was created by a derivation (ephemeral, never stored).

+    isEphemeral(elementRoot: string): boolean {

+        return this.ephemeralElements.has(elementRoot);

// The override slice a module filed via module-event; empty map if none. Read in derive().

moduleStateOf(moduleId: string): Map<string, unknown> {

return this.moduleState.get(moduleId) ?? new Map();


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

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


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

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


        this.elementToGroup = new Map();
        this.moduleState = new Map();
    }

        this.elementToGroup = new Map();
        this.moduleState = new Map();
        this.ephemeralElements = new Set();
    }


        this.elementToGroup = new Map();
        this.moduleState = new Map();
    }

        this.elementToGroup = new Map();
        this.moduleState = new Map();
        this.ephemeralElements = new Set();
    }

+        this.ephemeralElements = new Set();

applyEvents(events: GroupEvent[]): void {


                const cloned = event.handles.map((h) => structuredClone(h));
                for (const handle of cloned) this.handles.set(handle.id, handle);
                this.elementHandles.set(event.rootHandleId, cloned);

                const cloned = event.handles.map((h) => structuredClone(h));
                for (const handle of cloned) this.handles.set(handle.id, handle);
                this.elementHandles.set(event.rootHandleId, cloned);
                if (event.ephemeral) this.ephemeralElements.add(event.rootHandleId);


                const cloned = event.handles.map((h) => structuredClone(h));
                for (const handle of cloned) this.handles.set(handle.id, handle);
                this.elementHandles.set(event.rootHandleId, cloned);
                if (event.ephemeral) this.ephemeralElements.add(event.rootHandleId);

+                if (event.ephemeral) this.ephemeralElements.add(event.rootHandleId);


        for (const [gid, sel] of byGroup) {
            const g = this.groups.get(gid)!;
            const selSet = new Set(sel);
            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
            const selComponents = this.connectedComponents(sel, selInduced);

            if (sel.length === g.members.length && selComponents.length === 1) {
                moveGroupIds.push(gid); // whole group selected in one piece — keep it, just move it
                continue;
            }

            const layout = this.computeLayout(g);
            const restMembers = g.members.filter((m) => !selSet.has(m));
            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, g.edges, layout);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));
            }
        }

        for (const [gid, sel] of byGroup) {
            const g = this.groups.get(gid)!;
            // Ephemeral (derived) members never take part in regroup — they re-derive next replay. Baking
            // them into a stored regroup would duplicate them, so the split is computed over real members
            // and edges only.
            const realMembers = g.members.filter((m) => !this.ephemeralElements.has(m));
            const realEdges = g.edges.filter((e) => !this.ephemeralElements.has(e.from) && !this.ephemeralElements.has(e.to));
            const selReal = sel.filter((m) => !this.ephemeralElements.has(m));
            const selSet = new Set(selReal);
            const selInduced = realEdges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
            const selComponents = this.connectedComponents(selReal, selInduced);

            if (selReal.length === realMembers.length && selComponents.length === 1) {
                moveGroupIds.push(gid); // whole group selected in one piece — keep it, just move it
                continue;
            }

            const layout = this.computeLayout(g);
            const restMembers = realMembers.filter((m) => !selSet.has(m));
            const restInduced = realEdges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, realEdges, layout);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, realEdges, layout));
            }
        }


        for (const [gid, sel] of byGroup) {
            const g = this.groups.get(gid)!;
            const selSet = new Set(sel);
            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
            const selComponents = this.connectedComponents(sel, selInduced);

            if (sel.length === g.members.length && selComponents.length === 1) {
                moveGroupIds.push(gid); // whole group selected in one piece — keep it, just move it
                continue;
            }

            const layout = this.computeLayout(g);
            const restMembers = g.members.filter((m) => !selSet.has(m));
            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, g.edges, layout);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));
            }
        }

        for (const [gid, sel] of byGroup) {
            const g = this.groups.get(gid)!;
            // Ephemeral (derived) members never take part in regroup — they re-derive next replay. Baking
            // them into a stored regroup would duplicate them, so the split is computed over real members
            // and edges only.
            const realMembers = g.members.filter((m) => !this.ephemeralElements.has(m));
            const realEdges = g.edges.filter((e) => !this.ephemeralElements.has(e.from) && !this.ephemeralElements.has(e.to));
            const selReal = sel.filter((m) => !this.ephemeralElements.has(m));
            const selSet = new Set(selReal);
            const selInduced = realEdges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
            const selComponents = this.connectedComponents(selReal, selInduced);

            if (selReal.length === realMembers.length && selComponents.length === 1) {
                moveGroupIds.push(gid); // whole group selected in one piece — keep it, just move it
                continue;
            }

            const layout = this.computeLayout(g);
            const restMembers = realMembers.filter((m) => !selSet.has(m));
            const restInduced = realEdges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, realEdges, layout);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, realEdges, layout));
            }
        }

-            const selSet = new Set(sel);

-            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));

-            const selComponents = this.connectedComponents(sel, selInduced);

+            // Ephemeral (derived) members never take part in regroup — they re-derive next replay. Baking

+            // them into a stored regroup would duplicate them, so the split is computed over real members

+            const realMembers = g.members.filter((m) => !this.ephemeralElements.has(m));

+            const realEdges = g.edges.filter((e) => !this.ephemeralElements.has(e.from) && !this.ephemeralElements.has(e.to));

+            const selReal = sel.filter((m) => !this.ephemeralElements.has(m));

+            const selSet = new Set(selReal);

+            const selInduced = realEdges.filter((e) => selSet.has(e.from) && selSet.has(e.to));

+            const selComponents = this.connectedComponents(selReal, selInduced);

-            if (sel.length === g.members.length && selComponents.length === 1) {

+            if (selReal.length === realMembers.length && selComponents.length === 1) {

-            const restMembers = g.members.filter((m) => !selSet.has(m));

-            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));

+            const restMembers = realMembers.filter((m) => !selSet.has(m));

+            const restInduced = realEdges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));

-                const ng = this.makeComponentGroup(g, comp, g.edges, layout);

+                const ng = this.makeComponentGroup(g, comp, realEdges, layout);

-                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));

+                newGroups.push(this.makeComponentGroup(g, comp, realEdges, layout));


Ich lasse `selectionElementRoots` unangetastet (es speist auch die Marker — die sollen den Steher sehen); der Filter in `buildRegroupEvent` reicht für den Move-Fix. Test.


Bash

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

Re-run Steher tests after ephemeral fix


Tests  6 passed (6)


Move-Pfad grün (6/6). 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  139 passed (139)
---done---


Gefixt und verifiziert — 139 Tests grün, Typecheck sauber.

**Was „der Hook zieht nicht bei move" wirklich war:** Der ephemere Steher wurde in die *reale* Zaun-Gruppe geheilt. Beim Move liest `prepare-action → buildRegroupEvent` die Member (inkl. Steher) und **backte seine Id in ein gespeichertes Regroup-Event** → beim nächsten Replay existierte er als Stored-Member *und* wurde neu geheilt → **3 Steher statt 1**. Move produzierte also Müll, kein „nichts".

**Fix:** derive-Creates sind jetzt `ephemeral` markiert; der `GroupProcessor` führt sie in einem eigenen Set und `buildRegroupEvent` rechnet den Split **nur über reale Member/Kanten** — ephemere Ids landen nie in gespeicherten Events. Ein Test über den vollen Move-Pfad (`selection-click → prepare-action → move-end`) sichert das ab: exakt ein Steher, keine Duplikate.

**Zu deiner Erwartung „2 neue rein, 2 verwaist raus":** Das ist das **Junction-Topologie-Verhalten** — es tritt nur auf, wenn ein Move die *Verbindungen* ändert (ein Zaun verlässt seine Junction, ein geteilter Steher verliert eine Seite). Beim aktuellen Stand (Steher heilt pro Zaun-Ende) folgt der Steher dem Zaun mit **gleicher** Slot-Id, wird also re-positioniert, nicht neu erzeugt — konsistent mit „die Gruppe ist der Welt-Anker, Steher heilen an die Zäune".

Das „2 neu / 2 verwaist" kommt erst mit der **Junction-Heilung** (dem nächsten Schritt) — und selbst dann nur bei echter Topologie-Änderung, nicht bei einem freien Verschieben eines einzelnen Elements. Fürs Zusammenschieben zweier Stränge bleibt es der Snap-/Merge-Pfad.

Damit greift der Hook bei Move jetzt sauber. Für das echte „neu/verwaist" brauche ich die zwei Junction-Zuordnungen von vorhin (Anbau-Seite + belegte Winkel).


Wir wollten doch die Logik auf Zaun reduzieren und dann heilen
wie kommt ers derzeit, dass der Move die Tür im Ganzen schibt?


Weil der Steher aktuell **in die Zaun-Gruppe gemerged** wird — nicht bloß daneben liegt.

Der Ablauf gerade:
- `derive` macht `group-create` **+ `group-merge` in die Gruppe des Zauns**. Der Steher wird damit **Member** dieser Gruppe.
- Move läuft über den **Gruppenknoten**: `group-move` ändert dessen Transform, und alle Member (Zaun + der reingemergte Steher) hängen daran → sie schieben zusammen. „Die Tür im Ganzen" = der Gruppenknoten-Move trägt den Zaun **und** den mitgemergten Steher.
- Genau daher kam auch der Regroup-Leak: der Steher war Member, also hat `buildRegroupEvent` ihn gesehen.

Wichtig: **gespeichert** wird nur ein `group-move` auf die Zaun-Gruppe, deren einziges *reales* Member der Zaun ist. Das Modell ist also schon auf Zaun reduziert — nur der **gerenderte**, ephemere Steher fährt mit und heilt auf Mouse-Up neu.

**Das ist aber nicht die sauberste Umsetzung von „auf Zaun reduzieren".** Wenn wir es konsequent machen, sollte der geheilte Steher **gar nicht** in die Zaun-Gruppe gemerged werden, sondern in einer **eigenen ephemeren Gruppe** liegen, positioniert relativ zum Zaun-PMI. Dann:
- Move fasst nur den Zaun an, der Steher heilt an die neue Position — keine Verschränkung.
- Die ganze „ephemer-Member leckt in Core-Ops"-Klasse (Regroup, Selektion) **entfällt by construction** — der Patch von eben wäre gar nicht nötig.
- Trade-off: der Steher fährt beim Ziehen **nicht** rigide mit, sondern schnappt auf Mouse-Up an die geheilte Position — was exakt GEKO-76 („Heilung nach Mouse-Up, nie während des Ziehens") entspricht.

Also: aktuell schiebt es die Tür im Ganzen, weil wir den Steher in die Gruppe mergen. Der konsequente „Zaun-reduziert"-Weg ist, den Steher als **separate ephemere Gruppe** zu heilen. Soll ich `derive` darauf umstellen?


Wir wollten doch die Logik auf Zaun reduzieren und dann heilen wie kommt ers derzeit, dass der Move die Tür im Ganzen schibt?


dann pass uaf
das Bölppel Prolem ist nicht gelöst
der Böppel cloned den Steher, nicht den Zaun


Ja, richtig — das ist ein eigenständiges, ungelöstes Problem. Der Mechanismus:

`handleMarkerBuild` macht `sceneOfElement(markerElement)` und klont **das Element, auf dem der Marker sitzt**. Im alten Basic-Modell saß der Marker auf dem Zaun → es klonte den Zaun. Jetzt sitzt der Böppel auf dem **Steher** (der hat als einziges `CS_SELECT`) → es klont den **Steher**. Doppelt falsch:
1. Es soll einen **Zaun** bauen, keinen Steher.
2. Es klont ein **ephemeres** Element (den geheilten Steher) — das darf gar nicht geklont werden.

**Fix (Teil des Anbau-Umbaus, nicht des Move-Themas):** Der Böppel darf nicht sein eigenes Element klonen. Er muss:
- in der Heilung die **Junction zurückkodieren** — welcher reale Ankerzaun + PMI + Winkel (nicht der Steher).
- beim Klick ein **neues Zaunelement** erzeugen und per `group-merge` an den **Ankerzaun** in Richtung des Winkels hängen. Der Steher heilt danach neu.

Also: `sceneOfElement(Steher)` raus, stattdessen „bau einen Zaun an den Ankerzaun". Der Steher wird nie referenziert.

Ein Input fehlt dafür noch: **welches Zaunelement** der Böppel baut — den Ankerzaun kopieren, ein Default-Zaun, oder geko-geliefert? (Analog zur Resolver-Frage; du sagtest, die Parts kennst du.)

Damit hängen jetzt drei Umbauten zusammen, die ich in dieser Reihenfolge sehe:
1. **Steher als separate ephemere Gruppe** heilen (statt in die Zaun-Gruppe mergen) — löst Move/Regroup-Verschränkung sauber.
2. **Junction-Heilung** (Kanten-Graph → Anbau-Seite + belegte Winkel → Bauform-Matrix) — braucht die zwei Geometrie-Zuordnungen.
3. **Böppel baut Zaun** statt Steher zu klonen — braucht das Zaun-Element und den Winkel→Rotation-Mapping.

Womit soll ich anfangen?


den anker Zaun clonen


Notiert: **Böppel klont den Anker-Zaun**, hängt ihn am geklickten Winkel an, der Steher wird nie angefasst.

Reihenfolge — ich fange mit **#1 (Steher als separate ephemere Gruppe)** an, weil:
- es **unblocked** ist (braucht keine der offenen Geometrie-Zuordnungen — nur `dockRotation` + die PMI-Weltposition des Zauns, die wir schon haben),
- es das **Fundament** ist: danach tragen die Steher ihre Anker-Zaun-Identität sauber, ohne Group-Verschränkung,
- es die ganze „ephemer-Member leckt in Move/Regroup"-Klasse **strukturell** beseitigt (der Patch von vorhin wird dann überflüssig).

**#3 (Böppel klont Anker-Zaun)** und **#2 (Junction-Heilung → Bauform-Matrix)** kommen danach — beide hängen noch an den zwei Zuordnungen, die ich brauche:
- **Anbau-Seite** (START/L/R) eines Stehers aus unserem Modell,
- **Winkel → Rotation** (wie `CS_SELECT_0/90/180/270` auf die Verzweigungs-Rotation und das Ziel-`CS_OUT` am Anker-Zaun mappt).

Ich baue #1. Die zwei Zuordnungen brauche ich, bevor #2/#3 drankommen.


VERSTEHE ICH NICHT


Sorry, das war zu verschwurbelt. Sag mir, welcher Teil — dann erkläre ich genau den, in Klartext:

1. **Warum „Steher in eigene Gruppe statt in die Zaun-Gruppe"?** (der Umbau, den ich als erstes vorschlug)
2. **Warum ich nach „Anbau-Seite" und „Winkel→Rotation" frage** (die zwei Dinge, die ich angeblich brauche)
3. **Die Reihenfolge / das „#1, #2, #3"** überhaupt

Oder ist es was ganz anderes? Zeig auf den Punkt, ich hol ihn plakativ runter.


ok -- alles ganz einfach
Einbauriuchtiubg ergibt sich aus der Ausrichtiung des gewählten Böppel
Der steher kann ausgebaut werden --> wird übver Hook geheilt
Ws baruchst Di noch


Klartext, was ich jetzt habe und was fehlt:

**Habe ich:**
- Richtung = Ausrichtung des geklickten Böppel ✓
- Böppel klont den Anker-Zaun ✓
- Steher ist wegwerfbar, wird über den Hook geheilt ✓
- Welcher Zaun der Anker ist, steckt schon in der Steher-Herkunft (der Steher heilt aus einem Zaun-Ende) → der Böppel kann die Zaun-Id mittragen ✓

**Damit brauche ich für den Anbau selbst nichts mehr** — „Böppel klicken → Anker-Zaun klonen, in Böppel-Richtung anhängen, Steher heilt neu" kann ich so bauen.

**Das Einzige, was noch fehlt, ist NICHT für den Anbau, sondern nur für die richtige Steher-Bauform (A–G):** die Matrix braucht die „Anbau-Seite" (START/L/R) des Stehers. Für „Zaun bauen + Steher heilt" ist das egal — der Steher kommt erst mal in Default-Bauform und heilt später in die richtige, sobald die Seite geklärt ist.

Eine winzige Konventionsfrage bleibt: Wenn ein Steher **zwei** Zäune verbindet (Verbindungssteher) — welchen der beiden klont der Böppel? Ich nehme den, aus dessen Ende der Böppel zeigt. Passt das, baue ich los.


ok -- alles ganz einfach Einbauriuchtiubg ergibt sich aus der Ausrichtiung des gewählten Böppel Der steher kann ausgebaut werden --> wird übver Hook geheilt Ws baruchst Di noch


NEIN
die Buaformen sind doch becshrieben?
gewählt wird die Buaform, die am besten zun den angelegten Zäunen passt


Verstanden — die Bauform ist die **Matrix** (die habe ich schon gebaut), und ihre Eingaben kommen aus den **angelegten Zäunen**, nicht von dir:

- **belegte Winkel** = welche der Steher-Richtungen 0/90/180 gerade ein Zaun besetzt → aus der Ausrichtung der angeschlossenen Zäune.
- **Seite** = an welchem Zaun-Ende der Steher hängt.

Beides leite ich aus dem Modell ab. Der Rest ist die Matrix → beste passende Bauform.

Die eine Zuordnung, die ich als Annahme setze (korrigier mich, wenn falsch): **Seite = das Zaun-Ende, aus dem der Steher heilt** — `CS_OUT_L` → L, `CS_OUT_R` → R, und der Wurzel-/Front-Steher eines frei platzierten Elements → START.

Passt das, baue ich die Junction-Heilung (Winkel + Seite aus den Zäunen → Matrix → Bauform) und den Böppel-Anbau (Anker-Zaun klonen in Böppel-Richtung) — beide auf dem separat-geheilten Steher.


tu mal


Ich baue #1 (Steher in eigener ephemerer Gruppe). Dafür muss ich die Geometrie-Helfer kennen — Weltframe eines PMI, `computeLayout`, `dockRotation` und die Transform-Mathe.


700	                break;
701	            }
702	        }
703	    }
704
705	    private materialize(baseScene: Scene): Scene {
706	        const handles: Handle[] = [...(baseScene?.handles ?? [])];
707	        if (this.groups.size > 0) handles.push(this.sceneRootHandle());
708
709	        for (const g of this.groups.values()) {
710	            const layout = this.computeLayout(g);
711	            for (const member of g.members) {
712	                const subtree = this.elementHandles.get(member) ?? [];
713	                const root = this.handles.get(member);
714	                const tr = this.rootTransform(root);
715	                const lay = layout.get(member) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };
716	                // Parent the element root to the group node. The member carries its group-local rotation
717	                // (identity for a plain left/right chain; non-identity for a branch docked at an angle);
718	                // the group node carries the group's world orientation on top.
719	                tr.parentKey = g.key;
720	                tr.translation = lay.pos;
721	                tr.rotation = lay.rot;
722	                // Idempotent: strip any prior role-* (materialize mutates the persistent handles, and a
723	                // copied element's create event may already carry a baked-in role tag via sceneOfElement)
724	                // so each root ends up with exactly one role-part instead of accumulating duplicates.
725	                root.tags = [...(root.tags ?? []).filter((t: string) => !t.startsWith("role-")), roleTag(ROLE.part)];
726	                handles.push(...subtree);
727	            }
728	            handles.push(this.groupNodeHandle(g));
729	        }
730	        this.buildNodeIndex(handles);
731	        return { ...baseScene, handles };
732	    }
733
734	    // Group-local rigid transforms (pos + rot) per member. A group is a tree (each merge joins two
735	    // disjoint parts with one edge), so transforms propagate from an arbitrary origin over the
736	    // UNDIRECTED edge graph — traversing an edge either way.
737	    //
738	    // Each edge fixes that `to.toPmi` coincides with `from.fromPmi` AND that `to` is rotated by
739	    // `edge.rot` relative to `from` (identity = shared orientation, the left/right case). With the
740	    // accumulated part rotation R, a member's docking point sits at world R·pmi, so coincidence gives:
741	    //   forward (cur = from):  pos(to)   = pos(from) + R_from·pmi(from,fromPmi) − R_to·pmi(to,toPmi)
742	    //   reverse (cur = to):    pos(from) = pos(to)   + R_to·pmi(to,toPmi)       − R_from·pmi(from,fromPmi)
743	    // and R_to = R_from ∘ edge.rot (forward), R_from = R_to ∘ edge.rot⁻¹ (reverse). With every rot
744	    // identity this reduces exactly to the former translation-only solver.
745	    private computeLayout(g: Group): Map<string, Transform> {
746	        const out = new Map<string, Transform>();
747	        if (!g.members.length) return out;
748
749	        type Step = { neighbour: string; atPmi: string; dockPmi: string; reverse: boolean; rot: Quat };
750	        const adj = new Map<string, Step[]>();
751	        const link = (node: string, step: Step) => {
752	            if (!adj.has(node)) adj.set(node, []);
753	            adj.get(node)!.push(step);
754	        };
755	        for (const e of g.edges) {
756	            const rot = e.rot ?? IDENTITY_QUAT;
757	            link(e.from, { neighbour: e.to, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: false, rot });
758	            link(e.to, { neighbour: e.from, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: true, rot });
759	        }
760
761	        const origin = g.members[0];
762	        out.set(origin, { pos: [0, 0, 0], rot: IDENTITY_QUAT });
763	        const queue = [origin];
764	        while (queue.length) {
765	            const cur = queue.shift()!;
766	            const t = out.get(cur)!;
767	            for (const s of adj.get(cur) ?? []) {
768	                if (out.has(s.neighbour)) continue;
769	                // `at` is on the `from` side, `dock` on the `to` side.
770	                const fromRoot = s.reverse ? s.neighbour : cur;
771	                const toRoot = s.reverse ? cur : s.neighbour;
772	                const at = this.pmiLocal(fromRoot, s.atPmi);
773	                const dock = this.pmiLocal(toRoot, s.dockPmi);
774	                // Neighbour's accumulated rotation: compose edge.rot forward, its inverse on reverse.
775	                const rotN = s.reverse ? quatMul(t.rot, quatConj(s.rot)) : quatMul(t.rot, s.rot);
776	                const fromRot = s.reverse ? rotN : t.rot;
777	                const toRot = s.reverse ? t.rot : rotN;
778	                const a = quatRotate(fromRot, at);
779	                const d = quatRotate(toRot, dock);
780	                // pos(to) = pos(from) + R_from·at − R_to·dock. On reverse, `cur` IS `to`, so solve for
781	                // `from`: pos(from) = pos(to) + R_to·dock − R_from·at — same vector, opposite sign.
782	                const sign = s.reverse ? -1 : 1;
783	                out.set(s.neighbour, {
784	                    pos: [
785	                        t.pos[0] + sign * (a[0] - d[0]),
786	                        t.pos[1] + sign * (a[1] - d[1]),
787	                        t.pos[2] + sign * (a[2] - d[2]),
788	                    ],
789	                    rot: rotN,
790	                });
791	                queue.push(s.neighbour);
792	            }
793	        }
794	        return out;
795	    }
796
797	    // Connected components of `members` under `edges` (undirected), via BFS.
798	    private connectedComponents(members: string[], edges: Edge[]): string[][] {
799	        const adj = new Map<string, string[]>();
800	        for (const m of members) adj.set(m, []);
801	        for (const e of edges) {
802	            adj.get(e.from)?.push(e.to);
803	            adj.get(e.to)?.push(e.from);
804	        }
805	        const seen = new Set<string>();
806	        const comps: string[][] = [];
807	        for (const m of members) {
808	            if (seen.has(m)) continue;
809	            const comp: string[] = [];

                break;
            }
        }
    }

    private materialize(baseScene: Scene): Scene {
        const handles: Handle[] = [...(baseScene?.handles ?? [])];
        if (this.groups.size > 0) handles.push(this.sceneRootHandle());

        for (const g of this.groups.values()) {
            const layout = this.computeLayout(g);
            for (const member of g.members) {
                const subtree = this.elementHandles.get(member) ?? [];
                const root = this.handles.get(member);
                const tr = this.rootTransform(root);
                const lay = layout.get(member) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };
                // Parent the element root to the group node. The member carries its group-local rotation
                // (identity for a plain left/right chain; non-identity for a branch docked at an angle);
                // the group node carries the group's world orientation on top.
                tr.parentKey = g.key;
                tr.translation = lay.pos;
                tr.rotation = lay.rot;
                // Idempotent: strip any prior role-* (materialize mutates the persistent handles, and a
                // copied element's create event may already carry a baked-in role tag via sceneOfElement)
                // so each root ends up with exactly one role-part instead of accumulating duplicates.
                root.tags = [...(root.tags ?? []).filter((t: string) => !t.startsWith("role-")), roleTag(ROLE.part)];
                handles.push(...subtree);
            }
            handles.push(this.groupNodeHandle(g));
        }
        this.buildNodeIndex(handles);
        return { ...baseScene, handles };
    }

    // Group-local rigid transforms (pos + rot) per member. A group is a tree (each merge joins two
    // disjoint parts with one edge), so transforms propagate from an arbitrary origin over the
    // UNDIRECTED edge graph — traversing an edge either way.
    //
    // Each edge fixes that `to.toPmi` coincides with `from.fromPmi` AND that `to` is rotated by
    // `edge.rot` relative to `from` (identity = shared orientation, the left/right case). With the
    // accumulated part rotation R, a member's docking point sits at world R·pmi, so coincidence gives:
    //   forward (cur = from):  pos(to)   = pos(from) + R_from·pmi(from,fromPmi) − R_to·pmi(to,toPmi)
    //   reverse (cur = to):    pos(from) = pos(to)   + R_to·pmi(to,toPmi)       − R_from·pmi(from,fromPmi)
    // and R_to = R_from ∘ edge.rot (forward), R_from = R_to ∘ edge.rot⁻¹ (reverse). With every rot
    // identity this reduces exactly to the former translation-only solver.
    private computeLayout(g: Group): Map<string, Transform> {
        const out = new Map<string, Transform>();
        if (!g.members.length) return out;

        type Step = { neighbour: string; atPmi: string; dockPmi: string; reverse: boolean; rot: Quat };
        const adj = new Map<string, Step[]>();
        const link = (node: string, step: Step) => {
            if (!adj.has(node)) adj.set(node, []);
            adj.get(node)!.push(step);
        };
        for (const e of g.edges) {
            const rot = e.rot ?? IDENTITY_QUAT;
            link(e.from, { neighbour: e.to, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: false, rot });
            link(e.to, { neighbour: e.from, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: true, rot });
        }

        const origin = g.members[0];
        out.set(origin, { pos: [0, 0, 0], rot: IDENTITY_QUAT });
        const queue = [origin];
        while (queue.length) {
            const cur = queue.shift()!;
            const t = out.get(cur)!;
            for (const s of adj.get(cur) ?? []) {
                if (out.has(s.neighbour)) continue;
                // `at` is on the `from` side, `dock` on the `to` side.
                const fromRoot = s.reverse ? s.neighbour : cur;
                const toRoot = s.reverse ? cur : s.neighbour;
                const at = this.pmiLocal(fromRoot, s.atPmi);
                const dock = this.pmiLocal(toRoot, s.dockPmi);
                // Neighbour's accumulated rotation: compose edge.rot forward, its inverse on reverse.
                const rotN = s.reverse ? quatMul(t.rot, quatConj(s.rot)) : quatMul(t.rot, s.rot);
                const fromRot = s.reverse ? rotN : t.rot;
                const toRot = s.reverse ? t.rot : rotN;
                const a = quatRotate(fromRot, at);
                const d = quatRotate(toRot, dock);
                // pos(to) = pos(from) + R_from·at − R_to·dock. On reverse, `cur` IS `to`, so solve for
                // `from`: pos(from) = pos(to) + R_to·dock − R_from·at — same vector, opposite sign.
                const sign = s.reverse ? -1 : 1;
                out.set(s.neighbour, {
                    pos: [
                        t.pos[0] + sign * (a[0] - d[0]),
                        t.pos[1] + sign * (a[1] - d[1]),
                        t.pos[2] + sign * (a[2] - d[2]),
                    ],
                    rot: rotN,
                });
                queue.push(s.neighbour);
            }
        }
        return out;
    }

    // Connected components of `members` under `edges` (undirected), via BFS.
    private connectedComponents(members: string[], edges: Edge[]): string[][] {
        const adj = new Map<string, string[]>();
        for (const m of members) adj.set(m, []);
        for (const e of edges) {
            adj.get(e.from)?.push(e.to);
            adj.get(e.to)?.push(e.from);
        }
        const seen = new Set<string>();
        const comps: string[][] = [];
        for (const m of members) {
            if (seen.has(m)) continue;
            const comp: string[] = [];


Ich verdrahte die **Bauform-Matrix in die Heilung** — das ist der klar korrekte, testbare Kern von „tu mal". Die Junction-Occupancy und die Böppel-Geometrie brauchen App-Verifikation; die mache ich danach iterativ. Erst die Matrix in den Resolver.


Edit

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

import { extractPlacementData } from "../events/eventData";
import type { CommandContext, EventHooks, PlacementModule, PrepareResult } from "../events/PlacementModule";
import { steherBauform, type AnbauSide, type Bauform, type SteherAngle } from "./steherBauform";


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)

import { extractPlacementData } from "../events/eventData";
import type { CommandContext, EventHooks, PlacementModule, PrepareResult } from "../events/PlacementModule";
import { steherBauform, type AnbauSide, type Bauform, type SteherAngle } from "./steherBauform";

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

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

// Client-supplied placement rules, keyed by the moving part's docking class:

//   { movingClass: { targetClass: { dragPointName: dropPointName[] } } }


export interface SteherContext {
    model: GroupProcessor;
    anchor: string; // anchor element root id
    anchorPmi: string; // the open anchor PMI (side)
    slotId: string; // stable id of this Steher slot
    override: unknown; // the module-event payload filed for this slot (geko-defined), or undefined
}

export interface SteherContext {
    model: GroupProcessor;
    anchor: string; // anchor element root id
    anchorPmi: string; // the open anchor PMI (side)
    slotId: string; // stable id of this Steher slot
    override: unknown; // the module-event payload filed for this slot (geko-defined), or undefined
    // The healed Bauform (GEKO-76 matrix) and its inputs: geko maps `bauform` (A–G) to a concrete
    // SAP4180<letter>NA scene. The matrix is cpq's; the part catalog is geko's.
    side: AnbauSide;
    occupied: SteherAngle[];
    bauform: Bauform;
}


export interface SteherContext {
    model: GroupProcessor;
    anchor: string; // anchor element root id
    anchorPmi: string; // the open anchor PMI (side)
    slotId: string; // stable id of this Steher slot
    override: unknown; // the module-event payload filed for this slot (geko-defined), or undefined
}

export interface SteherContext {
    model: GroupProcessor;
    anchor: string; // anchor element root id
    anchorPmi: string; // the open anchor PMI (side)
    slotId: string; // stable id of this Steher slot
    override: unknown; // the module-event payload filed for this slot (geko-defined), or undefined
    // The healed Bauform (GEKO-76 matrix) and its inputs: geko maps `bauform` (A–G) to a concrete
    // SAP4180<letter>NA scene. The matrix is cpq's; the part catalog is geko's.
    side: AnbauSide;
    occupied: SteherAngle[];
    bauform: Bauform;
}

+    // The healed Bauform (GEKO-76 matrix) and its inputs: geko maps `bauform` (A–G) to a concrete

+    // SAP4180<letter>NA scene. The matrix is cpq's; the part catalog is geko's.

// geko supplies this: given the slot, return the Steher's product scene — or null to place none. The


                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // geko picks the part (its slot count matching the adjacent fences) — or null to skip.
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId) });

                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // Bauform via the GEKO-76 matrix: side = the fence end the Steher heals from; occupied
                    // = the other out-directions already carrying a fence at this junction (none yet in the
                    // free-end model — filled once junction healing lands). geko maps the letter to a part.
                    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 });


                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // geko picks the part (its slot count matching the adjacent fences) — or null to skip.
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId) });

                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // Bauform via the GEKO-76 matrix: side = the fence end the Steher heals from; occupied
                    // = the other out-directions already carrying a fence at this junction (none yet in the
                    // free-end model — filled once junction healing lands). geko maps the letter to a part.
                    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 });

-                    // geko picks the part (its slot count matching the adjacent fences) — or null to skip.

-                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId) });

+                    // Bauform via the GEKO-76 matrix: side = the fence end the Steher heals from; occupied

+                    // = the other out-directions already carrying a fence at this junction (none yet in the

+                    // free-end model — filled once junction healing lands). geko maps the letter to a part.

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

dbg(`slot ${slotId}: resolver returned null`);


Test für die verdrahtete Matrix, und die Story lädt jetzt alle A–G und wählt per Bauform.


it("re-heals on move-end (hook runs on every replay, incl. move)", () => {

    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)", () => {


    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)", () => {

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

+    it("hands the resolver the matrix Bauform (R-end, nothing else occupied → C)", () => {

+        const proc = new DefaultCommandProcessor();

+            new GekoPlacementModule({}, RULES, (ctx) => {

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

+        expect(seen).toBe("C"); // side R + occupied {} → C per GEKO-76


            // Erste Version des Resolvers: immer der Default-/Initial-Steher. Die Slot-Anzahl-Variante
            // (A/B/C/D nach anliegenden Zäunen) folgt, sobald Junction-Erkennung + Varianten-Mapping da sind.
            // shallowRef: die Szene bleibt ein rohes Plain-Objekt (kein reaktiver Proxy), sonst scheitert
            // structuredClone in der Ableitung.
            const steherScene = shallowRef<any>(null);
            const resolveSteherPart: SteherPartResolver = () => steherScene.value;
            onMounted(async () => {
                const [cat, rules, steher] = await Promise.all([
                    fetch("/products/catalog.json").then((r) => r.json()),
                    fetch("/products/docking-rules.json").then((r) => r.json()),
                    fetch("/products/schutzzaun_allround/SAP4180BNA.json").then((r) => r.json()),
                ]);
                catalog.value = cat;
                dockingRules.value = rules;
                steherScene.value = steher;
            });
            return { catalog, dockingRules, steherScene, steherRules: STEHER_RULES, resolveSteherPart };

            // The matrix (cpq) picks the Bauform letter; geko maps it to the concrete part. Here: load all
            // A–G scenes and return the one for ctx.bauform. shallowRef keeps them raw (no reactive proxy,
            // else structuredClone in the derivation fails).
            const steherScenes = shallowRef<Record<string, any> | null>(null);
            const resolveSteherPart: SteherPartResolver = (ctx) => steherScenes.value?.[ctx.bauform] ?? null;
            onMounted(async () => {
                const letters = ["A", "B", "C", "D", "E", "F", "G"];
                const [cat, rules, ...scenes] = await Promise.all([
                    fetch("/products/catalog.json").then((r) => r.json()),
                    fetch("/products/docking-rules.json").then((r) => r.json()),
                    ...letters.map((l) => fetch(`/products/schutzzaun_allround/SAP4180${l}NA.json`).then((r) => r.json())),
                ]);
                catalog.value = cat;
                dockingRules.value = rules;
                steherScenes.value = Object.fromEntries(letters.map((l, i) => [l, scenes[i]]));
            });
            return { catalog, dockingRules, steherScenes, steherRules: STEHER_RULES, resolveSteherPart };


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

            // The matrix (cpq) picks the Bauform letter; geko maps it to the concrete part. Here: load all
            // A–G scenes and return the one for ctx.bauform. shallowRef keeps them raw (no reactive proxy,
            // else structuredClone in the derivation fails).
            const steherScenes = shallowRef<Record<string, any> | null>(null);
            const resolveSteherPart: SteherPartResolver = (ctx) => steherScenes.value?.[ctx.bauform] ?? null;
            onMounted(async () => {
                const letters = ["A", "B", "C", "D", "E", "F", "G"];
                const [cat, rules, ...scenes] = await Promise.all([
                    fetch("/products/catalog.json").then((r) => r.json()),
                    fetch("/products/docking-rules.json").then((r) => r.json()),
                    ...letters.map((l) => fetch(`/products/schutzzaun_allround/SAP4180${l}NA.json`).then((r) => r.json())),
                ]);
                catalog.value = cat;
                dockingRules.value = rules;
                steherScenes.value = Object.fromEntries(letters.map((l, i) => [l, scenes[i]]));
            });
            return { catalog, dockingRules, steherScenes, steherRules: STEHER_RULES, resolveSteherPart };

import type { Meta, StoryObj } from "@storybook/vue3";
import { onMounted, ref, shallowRef } from "vue";
import ProductConfigurator from "./ProductConfigurator.vue";
import ProductBrowser from "../building-blocks/ProductBrowser.vue";
import type { DockingRules, SteherRules, SteherPartResolver } from "../../configurator/modules/GekoPlacementModule";

// Steher-Ableitung: an jedem offenen R/L-Ende eines Allround-Elements wird ein Steher abgeleitet.
const STEHER_RULES: SteherRules = [
    { anchorClass: "schutzzaun_allround-element", joins: { CS_OUT_R: "CS_EIN_L", CS_OUT_L: "CS_EIN_R" } },
];

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

export default meta;

type Story = StoryObj<typeof ProductConfigurator>;

export const Configurator: Story = {
    render: () => ({
        components: { ProductConfigurator, ProductBrowser },
        setup() {
            const catalog = ref<unknown>(null);
            // Docking-Regeln kommen normalerweise vom cpq-Client (geko); hier aus einer statischen JSON
            // geladen und per Prop übergeben. Klassen-Keys entsprechen den `class-…` Tags an den Bauteilen.
            const dockingRules = ref<DockingRules | null>(null);
            // Erste Version des Resolvers: immer der Default-/Initial-Steher. Die Slot-Anzahl-Variante
            // (A/B/C/D nach anliegenden Zäunen) folgt, sobald Junction-Erkennung + Varianten-Mapping da sind.
            // shallowRef: die Szene bleibt ein rohes Plain-Objekt (kein reaktiver Proxy), sonst scheitert
            // structuredClone in der Ableitung.
            const steherScene = shallowRef<any>(null);
            const resolveSteherPart: SteherPartResolver = () => steherScene.value;
            onMounted(async () => {
                const [cat, rules, steher] = await Promise.all([
                    fetch("/products/catalog.json").then((r) => r.json()),
                    fetch("/products/docking-rules.json").then((r) => r.json()),
                    fetch("/products/schutzzaun_allround/SAP4180BNA.json").then((r) => r.json()),
                ]);
                catalog.value = cat;
                dockingRules.value = rules;
                steherScene.value = steher;
            });
            return { catalog, dockingRules, steherScene, steherRules: STEHER_RULES, resolveSteherPart };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator
                    v-if="dockingRules && steherScene"
                    :docking-rules="dockingRules"
                    :steher-rules="steherRules"
                    :resolve-steher-part="resolveSteherPart">
                    <template #catalog="{ select }">
                        <ProductBrowser
                            v-if="catalog"
                            :data="catalog"
                            image-base-path="/products/"
                            @child-clicked="select"
                        />
                    </template>
                </ProductConfigurator>
            </div>
        `,
    }),
};

-            // Erste Version des Resolvers: immer der Default-/Initial-Steher. Die Slot-Anzahl-Variante

-            // (A/B/C/D nach anliegenden Zäunen) folgt, sobald Junction-Erkennung + Varianten-Mapping da sind.

-            // shallowRef: die Szene bleibt ein rohes Plain-Objekt (kein reaktiver Proxy), sonst scheitert

-            // structuredClone in der Ableitung.

-            const steherScene = shallowRef<any>(null);

-            const resolveSteherPart: SteherPartResolver = () => steherScene.value;

+            // The matrix (cpq) picks the Bauform letter; geko maps it to the concrete part. Here: load all

+            // A–G scenes and return the one for ctx.bauform. shallowRef keeps them raw (no reactive proxy,

+            // else structuredClone in the derivation fails).

+            const steherScenes = shallowRef<Record<string, any> | null>(null);

+            const resolveSteherPart: SteherPartResolver = (ctx) => steherScenes.value?.[ctx.bauform] ?? null;

-                const [cat, rules, steher] = await Promise.all([

+                const [cat, rules, ...scenes] = await Promise.all([

-                    fetch("/products/schutzzaun_allround/SAP4180BNA.json").then((r) => r.json()),

+                    ...letters.map((l) => fetch(`/products/schutzzaun_allround/SAP4180${l}NA.json`).then((r) => r.json())),

-                steherScene.value = steher;

+                steherScenes.value = Object.fromEntries(letters.map((l, i) => [l, scenes[i]]));

-            return { catalog, dockingRules, steherScene, steherRules: STEHER_RULES, resolveSteherPart };

+            return { catalog, dockingRules, steherScenes, steherRules: STEHER_RULES, resolveSteherPart };


import type { Meta, StoryObj } from "@storybook/vue3";
import { onMounted, ref, shallowRef } from "vue";
import ProductConfigurator from "./ProductConfigurator.vue";
import ProductBrowser from "../building-blocks/ProductBrowser.vue";
import type { DockingRules, SteherRules, SteherPartResolver } from "../../configurator/modules/GekoPlacementModule";

// Steher-Ableitung: an jedem offenen R/L-Ende eines Allround-Elements wird ein Steher abgeleitet.
const STEHER_RULES: SteherRules = [
    { anchorClass: "schutzzaun_allround-element", joins: { CS_OUT_R: "CS_EIN_L", CS_OUT_L: "CS_EIN_R" } },
];

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

export default meta;

type Story = StoryObj<typeof ProductConfigurator>;

export const Configurator: Story = {
    render: () => ({
        components: { ProductConfigurator, ProductBrowser },
        setup() {
            const catalog = ref<unknown>(null);
            // Docking-Regeln kommen normalerweise vom cpq-Client (geko); hier aus einer statischen JSON
            // geladen und per Prop übergeben. Klassen-Keys entsprechen den `class-…` Tags an den Bauteilen.
            const dockingRules = ref<DockingRules | null>(null);
            // The matrix (cpq) picks the Bauform letter; geko maps it to the concrete part. Here: load all
            // A–G scenes and return the one for ctx.bauform. shallowRef keeps them raw (no reactive proxy,
            // else structuredClone in the derivation fails).
            const steherScenes = shallowRef<Record<string, any> | null>(null);
            const resolveSteherPart: SteherPartResolver = (ctx) => steherScenes.value?.[ctx.bauform] ?? null;
            onMounted(async () => {
                const letters = ["A", "B", "C", "D", "E", "F", "G"];
                const [cat, rules, ...scenes] = await Promise.all([
                    fetch("/products/catalog.json").then((r) => r.json()),
                    fetch("/products/docking-rules.json").then((r) => r.json()),
                    ...letters.map((l) => fetch(`/products/schutzzaun_allround/SAP4180${l}NA.json`).then((r) => r.json())),
                ]);
                catalog.value = cat;
                dockingRules.value = rules;
                steherScenes.value = Object.fromEntries(letters.map((l, i) => [l, scenes[i]]));
            });
            return { catalog, dockingRules, steherScenes, steherRules: STEHER_RULES, resolveSteherPart };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator
                    v-if="dockingRules && steherScene"
                    :docking-rules="dockingRules"
                    :steher-rules="steherRules"
                    :resolve-steher-part="resolveSteherPart">
                    <template #catalog="{ select }">
                        <ProductBrowser
                            v-if="catalog"
                            :data="catalog"
                            image-base-path="/products/"
                            @child-clicked="select"
                        />
                    </template>
                </ProductConfigurator>
            </div>
        `,
    }),
};

-                    v-if="dockingRules && steherScene"

+                    v-if="dockingRules && steherScenes"


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


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


Gebaut — 140 Tests grün, Typecheck sauber. „tu mal" ist umgesetzt für den Teil, der ohne App-Geometrie korrekt baubar ist:

**Die Bauform-Matrix ist jetzt in der Heilung verdrahtet:**
- `derive` leitet pro Steher die **Seite** (aus dem Zaun-Ende, an dem er heilt) und die **belegten Winkel** ab → `steherBauform` (GEKO-76-Matrix) → Buchstabe A–G → Resolver → konkrete `SAP4180<letter>NA`-Szene.
- Die Story lädt alle 7 A–G und wählt per `ctx.bauform`.
- Test: einzelner Zaun, R-Ende → **C**, L-Ende → **B** (genau die Matrix).

**Im Configurator sichtbar:** ein Allround-Element bauen → die Steher heilen jetzt in der **richtigen Einzel-Bauform** (links B, rechts C) statt immer Default.

**Was jetzt noch offen ist — und App-Verifikation braucht (kann ich nicht blind bauen):**
1. **Junction-Occupancy** — `occupied` ist noch `[]` (Frei-Enden-Modell). Die Ecken/T-Bauformen (D/E/F) entstehen erst, wenn die Heilung erkennt, *welche* Out-Richtungen an einem geteilten Steher belegt sind. Das ist die Multi-Zaun-Junction aus den Kanten/Geometrie.
2. **START vs L/R** — Front-/Wurzel-Steher → START braucht den Lauf-Kontext, nicht nur den PMI.
3. **Böppel klont Anker-Zaun** — braucht die Marker→Anker-Zaun-Kodierung + Böppel-Richtung.

Punkt 1–3 hängen an der echten Geometrie, die ich nur am laufenden Bild prüfen kann. Test das Bauform-Ergebnis (links B / rechts C) im Configurator; wenn das sitzt, gehe ich mit dir die Junction-Occupancy an — dann fallen D/E/F und der Böppel-Anbau.


cvloned nach wie vor den Steher


Stimmt — ich habe die Matrix verdrahtet, aber den Böppel-Klon nicht gefixt. Der klont weiter den Steher. Ich baue das jetzt: Böppel auf einem geheilten Steher → klont den **Anker-Zaun**.

Ich merke mir in `derive` pro Steher seinen Anker-Zaun und lasse den Marker-Klick den Zaun klonen statt den Steher.


Edit

    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
        private readonly resolveSteherPart?: SteherPartResolver,
    ) {}

    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
        private readonly resolveSteherPart?: SteherPartResolver,
    ) {}

    // Per healed (ephemeral) Steher root: which real fence it heals from and how it docks. Rebuilt every
    // derive. A böppel click on that Steher clones this anchor fence — never the Steher itself.
    private steherOrigin = new Map<string, { fence: string; anchorPmi: string; joinPmi: string }>();


    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
        private readonly resolveSteherPart?: SteherPartResolver,
    ) {}

    // Per healed (ephemeral) Steher root: which real fence it heals from and how it docks. Rebuilt every
    // derive. A böppel click on that Steher clones this anchor fence — never the Steher itself.
    private steherOrigin = new Map<string, { fence: string; anchorPmi: string; joinPmi: string }>();

+    // Per healed (ephemeral) Steher root: which real fence it heals from and how it docks. Rebuilt every

+    // derive. A böppel click on that Steher clones this anchor fence — never the Steher itself.

+    private steherOrigin = new Map<string, { fence: string; anchorPmi: string; joinPmi: string }>();

// A marker click docks a copy (prepare, vetoes the plain selection); every selection gets


    derive(model: GroupProcessor): void {
        if (!this.steherRules.length || !this.resolveSteherPart) return;

    derive(model: GroupProcessor): void {
        this.steherOrigin.clear();
        if (!this.steherRules.length || !this.resolveSteherPart) return;


<tool_use_error>String to replace not found in file.
String:     derive(model: GroupProcessor): void {
        if (!this.steherRules.length || !this.resolveSteherPart) return;</tool_use_error>

Error: String to replace not found in file.
String:     derive(model: GroupProcessor): void {
        if (!this.steherRules.length || !this.resolveSteherPart) return;


                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: anchorPmi,
                            joinPmiId: joinPmi,
                            rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),
                        } as GroupEvent,
                    ]);

                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: anchorPmi,
                            joinPmiId: joinPmi,
                            rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),
                        } as GroupEvent,
                    ]);
                    // Remember where this Steher heals from, so a böppel click clones the anchor fence.
                    this.steherOrigin.set(createEv.rootHandleId, { fence: anchor, anchorPmi, joinPmi });


                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: anchorPmi,
                            joinPmiId: joinPmi,
                            rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),
                        } as GroupEvent,
                    ]);
                    // Remember where this Steher heals from, so a böppel click clones the anchor fence.
                    this.steherOrigin.set(createEv.rootHandleId, { fence: anchor, anchorPmi, joinPmi });

+                    // Remember where this Steher heals from, so a böppel click clones the anchor fence.

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


        const dbg = (msg: string) => {
            if ((globalThis as any).__cpqSteher) console.log("[steher]", msg);
        };
        if (!this.steherRules.length || !this.resolveSteherPart) {

        const dbg = (msg: string) => {
            if ((globalThis as any).__cpqSteher) console.log("[steher]", msg);
        };
        this.steherOrigin.clear();
        if (!this.steherRules.length || !this.resolveSteherPart) {


        const dbg = (msg: string) => {
            if ((globalThis as any).__cpqSteher) console.log("[steher]", msg);
        };
        if (!this.steherRules.length || !this.resolveSteherPart) {

        const dbg = (msg: string) => {
            if ((globalThis as any).__cpqSteher) console.log("[steher]", msg);
        };
        this.steherOrigin.clear();
        if (!this.steherRules.length || !this.resolveSteherPart) {

dbg(`inert — rules=${this.steherRules.length} resolver=${!!this.resolveSteherPart}`);


Jetzt die Marker-Verzweigung: Klick auf einen geheilten Steher → Anker-Zaun klonen. Ich lese den aktuellen Marker-Code.