64	                prepare: (event, ctx) => this.interceptMarkerClick(event, ctx),
65	                refine: (_event, ctx, reactions) => this.decorateSelection(reactions, ctx),
66	            },
67	            // Escalation re-renders the selection → markers again.
68	            "escalate-selection": {
69	                refine: (_event, ctx, reactions) => this.decorateSelection(reactions, ctx),
70	            },
71	            // The context menu gains "Platzieren" / "Element neu platzieren" for dockable subjects.
72	            "context-click": {
73	                refine: (event, ctx, reactions) => this.appendContextEntries(event, ctx, reactions),
74	            },
75	            // Placement end (snap → merge) rides on the interaction-mode event; move/rotate stay core.
76	            "interaction-mode": {
77	                prepare: (event, ctx) => this.interceptPlacement(event, ctx),
78	            },
79	            // Detach-for-placement and the keyboard build prototype have no core default.
80	            "detach-element": {
81	                replace: (event, ctx) => this.detachElement(event, ctx),
82	            },
83	            "keyboard-build": {
84	                replace: (event, ctx) => this.keyboardBuild(event, ctx),
85	            },
86	            // A manual override on a derived Steher slot (geko-defined payload — e.g. a chosen length).
87	            // Recorded as an undo-able module-event; the derived geometry itself is never stored.
88	            "steher-override": {
89	                replace: (event, ctx) => this.setSteherOverride(event, ctx),
90	            },
91	        };
92	    }
93
94	    // ---- Steher derivation (replay-time) -----------------------------------------------------------
95
96	    // Runs inside replay(), after the stored events assembled `model`. For each anchor element it docks
97	    // a derived Steher at each rule PMI, honouring per-slot overrides. Applies create+merge directly to
98	    // the model (ephemeral, never stored) so docking/materialisation see the Stehers and they stay
99	    // coupled to their anchor group (a group-move on the base carries them along).
100	    derive(model: GroupProcessor): void {
101	        // Opt-in diagnostics: `window.__cpqSteher = true` in the console prints why derivation did or
102	        // didn't produce a Steher (rules/resolver present, anchors found, occupancy, resolver result).
103	        const dbg = (msg: string) => {
104	            if ((globalThis as any).__cpqSteher) console.log("[steher]", msg);
105	        };
106	        this.steherOrigin.clear();
107	        if (!this.steherRules.length || !this.resolveSteherPart) {
108	            dbg(`inert — rules=${this.steherRules.length} resolver=${!!this.resolveSteherPart}`);
109	            return;
110	        }
111	        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
112	        for (const rule of this.steherRules) {
113	            const anchors = model.elementsOfClass(rule.anchorClass);
114	            dbg(`rule ${rule.anchorClass}: ${anchors.length} anchor(s)`);
115	            for (const anchor of anchors) {
116	                const anchorGroup = model.groupIdOfElement(anchor);
117	                if (!anchorGroup) {
118	                    dbg(`anchor ${anchor}: no group`);

                prepare: (event, ctx) => this.interceptMarkerClick(event, ctx),
                refine: (_event, ctx, reactions) => this.decorateSelection(reactions, ctx),
            },
            // Escalation re-renders the selection → markers again.
            "escalate-selection": {
                refine: (_event, ctx, reactions) => this.decorateSelection(reactions, ctx),
            },
            // The context menu gains "Platzieren" / "Element neu platzieren" for dockable subjects.
            "context-click": {
                refine: (event, ctx, reactions) => this.appendContextEntries(event, ctx, reactions),
            },
            // Placement end (snap → merge) rides on the interaction-mode event; move/rotate stay core.
            "interaction-mode": {
                prepare: (event, ctx) => this.interceptPlacement(event, ctx),
            },
            // Detach-for-placement and the keyboard build prototype have no core default.
            "detach-element": {
                replace: (event, ctx) => this.detachElement(event, ctx),
            },
            "keyboard-build": {
                replace: (event, ctx) => this.keyboardBuild(event, ctx),
            },
            // A manual override on a derived Steher slot (geko-defined payload — e.g. a chosen length).
            // Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-override": {
                replace: (event, ctx) => this.setSteherOverride(event, ctx),
            },
        };
    }

    // ---- Steher derivation (replay-time) -----------------------------------------------------------

    // Runs inside replay(), after the stored events assembled `model`. For each anchor element it docks
    // a derived Steher at each rule PMI, honouring per-slot overrides. Applies create+merge directly to
    // the model (ephemeral, never stored) so docking/materialisation see the Stehers and they stay
    // coupled to their anchor group (a group-move on the base carries them along).
    derive(model: GroupProcessor): void {
        // Opt-in diagnostics: `window.__cpqSteher = true` in the console prints why derivation did or
        // didn't produce a Steher (rules/resolver present, anchors found, occupancy, resolver result).
        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}`);
            return;
        }
        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        for (const rule of this.steherRules) {
            const anchors = model.elementsOfClass(rule.anchorClass);
            dbg(`rule ${rule.anchorClass}: ${anchors.length} anchor(s)`);
            for (const anchor of anchors) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) {
                    dbg(`anchor ${anchor}: no group`);


Jetzt lese ich `interceptMarkerClick` + `markerBuild`, um die Verzweigung einzubauen.


175	            payload: event.payload ?? null,
176	        } as GroupEvent);
177	        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
178	    }
179
180	    // ---- Anbau via select marker -------------------------------------------------------------------
181
182	    // Intercept a click on one of our select-PMI marker spheres — dock a copy of the element there and
183	    // stop the plain selection from running.
184	    private interceptMarkerClick(event: any, ctx: CommandContext): PrepareResult {
185	        const targetId: unknown = event.target?.id;
186	        if (typeof targetId !== "string" || !targetId.startsWith("sel-marker-")) return null;
187	        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
188	        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
189	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
190	        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };
191	    }
192
193	    // A clicked select marker (CS_SELECT_<side>_<angle>) docks a copy of its element onto the matching
194	    // open end: the anchor is the corresponding CS_OUT point, the copy joins with CS_EIN_L (right side)
195	    // or CS_EIN_R (left side), and the branch orientation (0/90/270) follows from the PMI frames via
196	    // dockRotation. Occupied ends do nothing. The fresh copy is selected so its markers re-render.
197	    private markerBuild(element: string, pmi: string, stop: UIReaction, ctx: CommandContext): UIReaction[] {
198	        ctx.replay();
199	        // The select marker maps 1:1 to its dock anchor of the same suffix: CS_SELECT_<side>_<angle>
200	        // → CS_OUT_<side>_<angle>. That anchor's frame makes dockRotation orient the copy so it builds
201	        // in the direction the marker sits (0 = straight, 90 = front, 270 = back).
202	        const anchorPmi = pmi.replace("CS_SELECT_", "CS_OUT_");
203	        const joinPmi = pmi.includes("_R_") ? "CS_EIN_L" : "CS_EIN_R";
204	        const groupId = ctx.groups.groupIdOfElement(element);
205	        const scene = ctx.groups.sceneOfElement(element);
206	        if (!groupId || !scene) return [stop];
207	        if (ctx.groups.occupiedPmis().some((o) => o.element === element && o.pmi === anchorPmi)) {
208	            return [stop]; // that end is already taken
209	        }
210	        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
211	        ctx.record(createEvent);
212	        ctx.record({
213	            type: "group-merge",
214	            timestamp: Date.now(),
215	            targetGroupId: groupId,
216	            movingGroupId: createEvent.groupId,
217	            anchorRootId: element,
218	            joinRootId: createEvent.rootHandleId,
219	            anchorPmiId: anchorPmi,
220	            joinPmiId: joinPmi,
221	            // Join-Root is `element`, not the fresh copy: the copy is an identical clone (same PMI
222	            // frames) and isn't replayed into the handle map yet, so reading its join PMI would fall
223	            // back to IDENTITY and tilt the copy. Reading both frames off the original is exact.
224	            rot: ctx.groups.dockRotation(element, anchorPmi, element, joinPmi),
225	        } as GroupEvent);
226	        // Select the freshly attached copy: its markers render on it and the next build chains off it.
227	        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
228	        return ctx.selectionReactions(stop);
229	    }

            payload: event.payload ?? null,
        } as GroupEvent);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }

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

    // Intercept a click on one of our select-PMI marker spheres — dock a copy of the element there and
    // stop the plain selection from running.
    private interceptMarkerClick(event: any, ctx: CommandContext): PrepareResult {
        const targetId: unknown = event.target?.id;
        if (typeof targetId !== "string" || !targetId.startsWith("sel-marker-")) return null;
        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };
    }

    // A clicked select marker (CS_SELECT_<side>_<angle>) docks a copy of its element onto the matching
    // open end: the anchor is the corresponding CS_OUT point, the copy joins with CS_EIN_L (right side)
    // or CS_EIN_R (left side), and the branch orientation (0/90/270) follows from the PMI frames via
    // dockRotation. Occupied ends do nothing. The fresh copy is selected so its markers re-render.
    private markerBuild(element: string, pmi: string, stop: UIReaction, ctx: CommandContext): UIReaction[] {
        ctx.replay();
        // The select marker maps 1:1 to its dock anchor of the same suffix: CS_SELECT_<side>_<angle>
        // → CS_OUT_<side>_<angle>. That anchor's frame makes dockRotation orient the copy so it builds
        // in the direction the marker sits (0 = straight, 90 = front, 270 = back).
        const anchorPmi = pmi.replace("CS_SELECT_", "CS_OUT_");
        const joinPmi = pmi.includes("_R_") ? "CS_EIN_L" : "CS_EIN_R";
        const groupId = ctx.groups.groupIdOfElement(element);
        const scene = ctx.groups.sceneOfElement(element);
        if (!groupId || !scene) return [stop];
        if (ctx.groups.occupiedPmis().some((o) => o.element === element && o.pmi === anchorPmi)) {
            return [stop]; // that end is already taken
        }
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        ctx.record({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: groupId,
            movingGroupId: createEvent.groupId,
            anchorRootId: element,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: anchorPmi,
            joinPmiId: joinPmi,
            // Join-Root is `element`, not the fresh copy: the copy is an identical clone (same PMI
            // frames) and isn't replayed into the handle map yet, so reading its join PMI would fall
            // back to IDENTITY and tilt the copy. Reading both frames off the original is exact.
            rot: ctx.groups.dockRotation(element, anchorPmi, element, joinPmi),
        } as GroupEvent);
        // Select the freshly attached copy: its markers render on it and the next build chains off it.
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }


Ich baue die Verzweigung ein: Böppel auf geheiltem Steher → Anker-Zaun klonen (kein Occupied-Guard, weil der Steher nur an real-freien Enden heilt; der Steher ist ephemer und blockiert nicht).


Edit

        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };
    }

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

    // Böppel on a healed Steher → clone its anchor fence and dock a fresh copy onto the same open end the
    // Steher heals from. The Steher is never referenced; it re-heals around the new fence. No occupancy
    // guard: a Steher only heals at a real-free end (derive skips occupied ones), so that end is open;
    // the ephemeral Steher edge does not block, and the merge's own apply-time guard catches real doubles.
    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        stop: UIReaction,
        ctx: CommandContext,
    ): UIReaction[] {
        ctx.replay();
        const targetGroup = ctx.groups.groupIdOfElement(origin.fence);
        const scene = ctx.groups.sceneOfElement(origin.fence);
        if (!targetGroup || !scene) return [stop];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        ctx.record({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: targetGroup,
            movingGroupId: createEvent.groupId,
            anchorRootId: origin.fence,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            // Read both frames off the anchor fence: the fresh copy isn't in the handle map yet.
            rot: ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi),
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }


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

        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };
    }

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

    // Böppel on a healed Steher → clone its anchor fence and dock a fresh copy onto the same open end the
    // Steher heals from. The Steher is never referenced; it re-heals around the new fence. No occupancy
    // guard: a Steher only heals at a real-free end (derive skips occupied ones), so that end is open;
    // the ephemeral Steher edge does not block, and the merge's own apply-time guard catches real doubles.
    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        stop: UIReaction,
        ctx: CommandContext,
    ): UIReaction[] {
        ctx.replay();
        const targetGroup = ctx.groups.groupIdOfElement(origin.fence);
        const scene = ctx.groups.sceneOfElement(origin.fence);
        if (!targetGroup || !scene) return [stop];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        ctx.record({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: targetGroup,
            movingGroupId: createEvent.groupId,
            anchorRootId: origin.fence,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            // Read both frames off the anchor fence: the fresh copy isn't in the handle map yet.
            rot: ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi),
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

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

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

+        const origin = this.steherOrigin.get(element);

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

+    // Böppel on a healed Steher → clone its anchor fence and dock a fresh copy onto the same open end the

+    // Steher heals from. The Steher is never referenced; it re-heals around the new fence. No occupancy

+    // guard: a Steher only heals at a real-free end (derive skips occupied ones), so that end is open;

+    // the ephemeral Steher edge does not block, and the merge's own apply-time guard catches real doubles.

+        origin: { fence: string; anchorPmi: string; joinPmi: string },

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

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

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

+        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);

+            targetGroupId: targetGroup,

+            movingGroupId: createEvent.groupId,

+            anchorRootId: origin.fence,

+            joinRootId: createEvent.rootHandleId,

+            anchorPmiId: origin.anchorPmi,

+            // Read both frames off the anchor fence: the fresh copy isn't in the handle map yet.

+            rot: ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi),

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

+        return ctx.selectionReactions(stop);

// A clicked select marker (CS_SELECT_<side>_<angle>) docks a copy of its element onto the matching

// open end: the anchor is the corresponding CS_OUT point, the copy joins with CS_EIN_L (right side)

// or CS_EIN_R (left side), and the branch orientation (0/90/270) follows from the PMI frames via


Test: Böppel-Klick klont den Anker-Zaun (nicht den Steher).


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

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

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


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("böppel on a healed Steher clones the anchor fence, not the Steher", () => {
        const { proc, scene } = setup();
        const steher = steherOf(scene)!;
        expect(parts(scene).filter((id) => !id.startsWith("h-steher"))).toHaveLength(1); // just the anchor fence
        const after = sceneOf(
            proc.handleEvent({
                eventTypeName: "selection-click",
                target: { id: `sel-marker-${steher}-CS_SELECT_0` },
                selectionAction: "set",
            }),
        );
        // A real fence was cloned (not a Steher): now two non-Steher parts (anchor + clone).
        expect(parts(after).filter((id) => !id.startsWith("h-steher"))).toHaveLength(2);
    });

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

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

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

    it("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("böppel on a healed Steher clones the anchor fence, not the Steher", () => {

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

+        const steher = steherOf(scene)!;

+        expect(parts(scene).filter((id) => !id.startsWith("h-steher"))).toHaveLength(1); // just the anchor fence

+                eventTypeName: "selection-click",

+                target: { id: `sel-marker-${steher}-CS_SELECT_0` },

+                selectionAction: "set",

+        // A real fence was cloned (not a Steher): now two non-Steher parts (anchor + clone).

+        expect(parts(after).filter((id) => !id.startsWith("h-steher"))).toHaveLength(2);


Bash

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

Run böppel-clones-fence test


Tests  8 passed (8)


Böppel klont jetzt den Zaun (8/8). 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  141 passed (141)
---done---


Gefixt — 141 Tests grün, Typecheck sauber.

**Böppel klont jetzt den Anker-Zaun:** In `derive` merke ich mir pro geheiltem Steher seinen Anker (Zaun + PMI). Ein Böppel-Klick schlägt das nach und klont den **Zaun** an dieses Ende — der Steher wird nie geklont, er heilt um den neuen Zaun neu. Test: nach dem Klick sind es zwei Zäune (Anker + Klon), kein geklonter Steher.

Im Configurator testen: Steher selektieren → Böppel klicken → ein Zaun-Element wird angebaut.

**Ehrlich, was noch offen ist** (braucht die Junction-Geometrie, die ich nur am Bild prüfen kann):
1. Am **neuen Übergang** (Anker↔Klon) heilt noch **kein geteilter Verbindungssteher** — das Frei-Enden-Modell überspringt die jetzt belegte Junction. Das ist die Junction-Heilung mit Occupancy.
2. Die **Böppel-Richtung** (90°-Ecken) nimmt vorerst das gerade Ende; der Winkel-aus-Böppel kommt mit Punkt 1.

Sag mir, ob der Böppel-Anbau im Bild jetzt einen Zaun baut — dann gehen wir die Junction-Heilung an (geteilter Steher + Ecken).


Du baust falsch zusammen
zwei / drei / vier Zäune TEILEN sich den sther an ihren gemeinsamen kanten


Verstanden — ich baue die Topologie falsch. Ich verlängere Zaun-an-Zaun und heile Steher nur an **freien Enden**. Richtig ist: der Steher sitzt an der **gemeinsamen Kante**, und 2/3/4 Zäune **teilen sich diesen einen** Steher.

Meine Korrektur des Modells:

- Ein Steher wird **pro Junction** geheilt, nicht pro freiem Ende. Eine Junction ist:
  - eine **gemeinsame Kante** (zwei Zäune treffen sich → ein *geteilter* Steher), oder
  - ein **freies Ende** (ein Zaun → End-Steher).
- Die **belegten Winkel** dieses einen Stehers = die Richtungen aller Zäune, die an dieser Junction anliegen → Matrix → Bauform (bei 2 = gerade/Ecke, bei 3 = T, bei 4 = Kreuz).
- Der Böppel auf einem Junction-Steher baut einen Zaun, der **an dieselbe Junction** andockt (teilt sich den Steher), statt einen Zaun einfach zu verlängern.

Mein aktueller `derive` überspringt die belegte Kante (deshalb kein geteilter Steher) — das ist der Fehler.

Eine Sache muss ich klären, bevor ich die Junction-Heilung baue — für den 3/4-Zaun-Fall:

**Wie sind mehrere Zäune an *einem* Punkt in unserem Modell verbunden?** Bei zwei Zäunen ist es eine Kante (`from/to`). Bei drei/vier an derselben Ecke: mehrere paarweise Kanten am selben Punkt — und woran erkennt die Heilung, dass diese Kanten **dieselbe** Junction sind (ein geteilter Steher, nicht mehrere)? Über eine geteilte Junction-Identität (dann brauchen die Zäune einen gemeinsamen Anker) oder über Koinzidenz der Punkte?

Das entscheidet, ob die Junction ein eigenes Konzept braucht oder aus den Kanten fällt. Sag mir das, dann baue ich die Heilung richtig — statt nochmal die falsche Topologie.


Du baust falsch zusammen zwei / drei / vier Zäune TEILEN sich den sther an ihren gemeinsamen kanten


das sind die Bauformen
sie sagen, wievile Slots ein Steher hat unmd müssen entlang der angelgten Zäune ausgesuch werden


Verstanden — die **Bauform IST die Slot-Anzahl/-Anordnung**, und sie wird aus den angelegten Zäunen gewählt. Das ist genau die Matrix, die ich schon habe. Ein **geteilter Steher pro Junction**, seine Bauform = welche Richtungen Zäune tragen. Ich hatte die falsche Topologie (verlängern + Steher nur an freien Enden).

Damit ich es **einmal richtig** baue statt wieder daneben, nur meine zwei Hypothesen für die Matrix-Eingaben — sag „ja" oder korrigiere:

1. **Belegte Richtung eines Zauns (0/90/180)** ergibt sich aus der **Kanten-Rotation** (`edge.rot`) im Frame des Stehers — gerade = 0/180, Ecke = 90. Richtig?
2. **Seite (START/L/R)**: START = der Steher am freien Anfang eines Laufs (nur ein Zaun, kein Vorgänger); L/R = er sitzt am linken/rechten Ende des Zauns, aus dem er kommt. Richtig?

Wenn beide stimmen, ist die Heilung eindeutig: pro Junction die anliegenden Zaun-Richtungen sammeln → Seite bestimmen → Matrix → **ein** geteilter Steher mit der passenden Bauform. Dann baue ich `derive` darauf um (Junction statt Frei-Ende) und den Böppel so, dass der neue Zaun an dieselbe Junction andockt.

Sag ja/nein zu 1 und 2 — dann ist Schluss mit Raten.


ja


Beide bestätigt — Modell ist gelockt. Ehrlich zur Umsetzung: ab hier ist es Welt-Transform-Geometrie, die sich nur am laufenden Bild bestätigt (anders als die reine Matrix). Ich baue den nächsten Baustein und du verifizierst ihn im Configurator.

Ein Punkt fällt jetzt zwingend: ein **geteilter** Steher sitzt zwischen zwei bereits verbundenen Zäunen — an dieser Kante ist **kein freier PMI** zum Andocken. Also **muss** der Steher als **eigene, frei positionierte ephemere Gruppe** an den Junction-Punkt gesetzt werden (nicht gemergt). Das ist genau #1 — und damit Voraussetzung für geteilte Steher.

Ich baue #1. Erst die Geometrie-Helfer prüfen.


846	            tags: [roleTag(ROLE.scene)],
847	            components: [
848	                {
849	                    _resourceClass: "component_transform",
850	                    key: ROOT_KEY,
851	                    translation: [0, 0, 0],
852	                    rotation: [0, 0, 0],
853	                    scale: [1, 1, 1],
854	                },
855	            ],
856	        };
857	    }
858
859	    // ---- helpers -----------------------------------------------------------
860
861	    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
862	        const handle = this.handles.get(rootHandleId);
863	        for (const comp of handle?.components ?? []) {
864	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
865	                return comp.properties.position.value;
866	            }
867	        }
868	        return [0, 0, 0];
869	    }
870
871	    // The docking point's frame orientation, from its `properties.rotation` (Euler radians, ZYX).
872	    // Most points carry no rotation (CS_LINKS/CS_RECHTS = [0,0,0] → identity); the post/branch points
873	    // (CS_EIN_*) carry the angle that turns a part onto them.
874	    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
875	        const handle = this.handles.get(rootHandleId);
876	        for (const comp of handle?.components ?? []) {
877	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
878	                const rot = comp.properties?.rotation?.value;
879	                return rot ? eulerZyxRadToQuat(rot as Vec3) : IDENTITY_QUAT;
880	            }
881	        }
882	        return IDENTITY_QUAT;
883	    }
884
885	    // Relative rotation of the join part w.r.t. the anchor part for a dock: aligning the join point's
886	    // frame onto the anchor point's turns the join part by R_anchorPmi ∘ R_joinPmi⁻¹. Identity when
887	    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
888	    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
889	        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
890	    }
891
892	    private rootTransform(handle: Handle): any {
893	        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
894	    }
895
896	    private roleOf(handle: Handle): NodeRole {
897	        for (const t of handle?.tags ?? []) {
898	            if (typeof t === "string" && t.startsWith("role-")) return t.slice(5) as NodeRole;
899	        }
900	        return ROLE.geometry;
901	    }
902
903	    // Index every materialised node by handle id → {parent handle id, role}, resolving each
904	    // transform's parentKey to the owning handle. The role resolver walks this.
905	    private buildNodeIndex(handles: Handle[]): void {

            tags: [roleTag(ROLE.scene)],
            components: [
                {
                    _resourceClass: "component_transform",
                    key: ROOT_KEY,
                    translation: [0, 0, 0],
                    rotation: [0, 0, 0],
                    scale: [1, 1, 1],
                },
            ],
        };
    }

    // ---- helpers -----------------------------------------------------------

    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
        const handle = this.handles.get(rootHandleId);
        for (const comp of handle?.components ?? []) {
            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
                return comp.properties.position.value;
            }
        }
        return [0, 0, 0];
    }

    // The docking point's frame orientation, from its `properties.rotation` (Euler radians, ZYX).
    // Most points carry no rotation (CS_LINKS/CS_RECHTS = [0,0,0] → identity); the post/branch points
    // (CS_EIN_*) carry the angle that turns a part onto them.
    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
        const handle = this.handles.get(rootHandleId);
        for (const comp of handle?.components ?? []) {
            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
                const rot = comp.properties?.rotation?.value;
                return rot ? eulerZyxRadToQuat(rot as Vec3) : IDENTITY_QUAT;
            }
        }
        return IDENTITY_QUAT;
    }

    // Relative rotation of the join part w.r.t. the anchor part for a dock: aligning the join point's
    // frame onto the anchor point's turns the join part by R_anchorPmi ∘ R_joinPmi⁻¹. Identity when
    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
    }

    private rootTransform(handle: Handle): any {
        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
    }

    private roleOf(handle: Handle): NodeRole {
        for (const t of handle?.tags ?? []) {
            if (typeof t === "string" && t.startsWith("role-")) return t.slice(5) as NodeRole;
        }
        return ROLE.geometry;
    }

    // Index every materialised node by handle id → {parent handle id, role}, resolving each
    // transform's parentKey to the owning handle. The role resolver walks this.
    private buildNodeIndex(handles: Handle[]): void {


Ich baue jetzt die **Topologie** — den geteilten Steher pro Junction — weil die *strukturell* testbar und im Bild verifizierbar ist. Die exakte Winkel→Bauform-Ableitung (die reine Blind-Geometrie) trenne ich ab: erst prüfst du die Topologie, dann verfeinern wir die Bauform.

Konkret: ein abgeleiteter Steher darf auf einen **belegten** PMI docken (an der gemeinsamen Kante), und pro Junction wird genau einmal geheilt. Ich erweitere den Merge um ein Ephemer-Flag und den `GroupProcessor` um Kanten-Zugriff.


Edit

export interface GroupMergeEvent {
    type: "group-merge";
    timestamp: number;
    targetGroupId: string;
    movingGroupId: string;
    anchorRootId: string; // target member the moving part docks onto (positioned first)
    joinRootId: string; // moving member that docks (positioned relative to the anchor)
    anchorPmiId: string; // docking point id on the anchor
    joinPmiId: string; // docking point id on the join, placed to coincide with the anchor's
    rot?: Quat; // rotation of the join part relative to the anchor (absent/identity = shared orientation)
}

export interface GroupMergeEvent {
    type: "group-merge";
    timestamp: number;
    targetGroupId: string;
    movingGroupId: string;
    anchorRootId: string; // target member the moving part docks onto (positioned first)
    joinRootId: string; // moving member that docks (positioned relative to the anchor)
    anchorPmiId: string; // docking point id on the anchor
    joinPmiId: string; // docking point id on the join, placed to coincide with the anchor's
    rot?: Quat; // rotation of the join part relative to the anchor (absent/identity = shared orientation)
    // A derived (ephemeral) merge may dock onto an already-occupied point: a shared Steher sits at the
    // common edge of two fences, where the anchor's point is taken by the fence-fence edge. Bypasses the
    // double-booking guard (which only protects stored fence-fence edges).
    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 GroupMergeEvent {
    type: "group-merge";
    timestamp: number;
    targetGroupId: string;
    movingGroupId: string;
    anchorRootId: string; // target member the moving part docks onto (positioned first)
    joinRootId: string; // moving member that docks (positioned relative to the anchor)
    anchorPmiId: string; // docking point id on the anchor
    joinPmiId: string; // docking point id on the join, placed to coincide with the anchor's
    rot?: Quat; // rotation of the join part relative to the anchor (absent/identity = shared orientation)
}

export interface GroupMergeEvent {
    type: "group-merge";
    timestamp: number;
    targetGroupId: string;
    movingGroupId: string;
    anchorRootId: string; // target member the moving part docks onto (positioned first)
    joinRootId: string; // moving member that docks (positioned relative to the anchor)
    anchorPmiId: string; // docking point id on the anchor
    joinPmiId: string; // docking point id on the join, placed to coincide with the anchor's
    rot?: Quat; // rotation of the join part relative to the anchor (absent/identity = shared orientation)
    // A derived (ephemeral) merge may dock onto an already-occupied point: a shared Steher sits at the
    // common edge of two fences, where the anchor's point is taken by the fence-fence edge. Bypasses the
    // double-booking guard (which only protects stored fence-fence edges).
    ephemeral?: boolean;
}

+    // A derived (ephemeral) merge may dock onto an already-occupied point: a shared Steher sits at the

+    // common edge of two fences, where the anchor's point is taken by the fence-fence edge. Bypasses the

+    // double-booking guard (which only protects stored fence-fence edges).


                const occupied = this.occupiedPmis();
                const busy = (element: string, pmi: string) =>
                    occupied.some((o) => o.element === element && o.pmi === pmi);
                if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;

                if (!event.ephemeral) {
                    const occupied = this.occupiedPmis();
                    const busy = (element: string, pmi: string) =>
                        occupied.some((o) => o.element === element && o.pmi === pmi);
                    if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;
                }


                if (!event.ephemeral) {
                    const occupied = this.occupiedPmis();
                    const busy = (element: string, pmi: string) =>
                        occupied.some((o) => o.element === element && o.pmi === pmi);
                    if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;
                }

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

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

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

-                const occupied = this.occupiedPmis();

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

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

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

+                if (!event.ephemeral) {

+                    const occupied = this.occupiedPmis();

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

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

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


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

    // Edges touching an element root, normalised to this element's side. `pmi` is on this element,
    // `other`/`otherPmi` the neighbour, `rot` the branch rotation of the edge, `isFrom` whether this
    // element is the edge's `from`. Ephemeral neighbours are excluded (they are not real connections).
    // Used by junction healing to find which fences meet at each of an element's points.
    connectionsOf(elementRoot: string): Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> {
        const gid = this.elementToGroup.get(elementRoot);
        const g = gid ? this.groups.get(gid) : null;
        if (!g) return [];
        const out: Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> = [];
        for (const e of g.edges) {
            if (this.ephemeralElements.has(e.from) || this.ephemeralElements.has(e.to)) continue;
            if (e.from === elementRoot) out.push({ pmi: e.fromPmi, other: e.to, otherPmi: e.toPmi, rot: e.rot ?? IDENTITY_QUAT, isFrom: true });
            else if (e.to === elementRoot) out.push({ pmi: e.toPmi, other: e.from, otherPmi: e.fromPmi, rot: e.rot ?? IDENTITY_QUAT, isFrom: false });
        }
        return out;
    }


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

    // Edges touching an element root, normalised to this element's side. `pmi` is on this element,
    // `other`/`otherPmi` the neighbour, `rot` the branch rotation of the edge, `isFrom` whether this
    // element is the edge's `from`. Ephemeral neighbours are excluded (they are not real connections).
    // Used by junction healing to find which fences meet at each of an element's points.
    connectionsOf(elementRoot: string): Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> {
        const gid = this.elementToGroup.get(elementRoot);
        const g = gid ? this.groups.get(gid) : null;
        if (!g) return [];
        const out: Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> = [];
        for (const e of g.edges) {
            if (this.ephemeralElements.has(e.from) || this.ephemeralElements.has(e.to)) continue;
            if (e.from === elementRoot) out.push({ pmi: e.fromPmi, other: e.to, otherPmi: e.toPmi, rot: e.rot ?? IDENTITY_QUAT, isFrom: true });
            else if (e.to === elementRoot) out.push({ pmi: e.toPmi, other: e.from, otherPmi: e.fromPmi, rot: e.rot ?? IDENTITY_QUAT, isFrom: false });
        }
        return out;
    }

+    // Edges touching an element root, normalised to this element's side. `pmi` is on this element,

+    // `other`/`otherPmi` the neighbour, `rot` the branch rotation of the edge, `isFrom` whether this

+    // element is the edge's `from`. Ephemeral neighbours are excluded (they are not real connections).

+    // Used by junction healing to find which fences meet at each of an element's points.

+    connectionsOf(elementRoot: string): Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> {

+        const gid = this.elementToGroup.get(elementRoot);

+        const g = gid ? this.groups.get(gid) : null;

+        const out: Array<{ pmi: string; other: string; otherPmi: string; rot: Quat; isFrom: boolean }> = [];

+            if (this.ephemeralElements.has(e.from) || this.ephemeralElements.has(e.to)) continue;

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

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

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


Ich mache es minimal und risikoarm: die Occupied-Sperre raus (Steher heilt auch an belegter Kante = geteilte Junction), Merge als `ephemeral: true`. Ich lese den aktuellen derive-Kern.


120	                }
121	                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
122	                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
123	                    // no derived Steher.
124	                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === anchorPmi)) {
125	                        dbg(`anchor ${anchor} ${anchorPmi}: occupied`);
126	                        continue;
127	                    }
128	                    const slotId = `steher@${anchor}:${anchorPmi}`;
129	                    // Bauform via the GEKO-76 matrix: side = the fence end the Steher heals from; occupied
130	                    // = the other out-directions already carrying a fence at this junction (none yet in the
131	                    // free-end model — filled once junction healing lands). geko maps the letter to a part.
132	                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
133	                    const occupied: SteherAngle[] = [];
134	                    const bauform = steherBauform(side, occupied);
135	                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
136	                    if (!part) {
137	                        dbg(`slot ${slotId}: resolver returned null`);
138	                        continue;
139	                    }
140	                    dbg(`slot ${slotId}: deriving Steher (join ${joinPmi})`);
141	                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
142	                    // override binding). Apply the create first so the Steher's PMI frames are in the
143	                    // model before dockRotation reads them, then merge it into the anchor's group.
144	                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
145	                    model.applyEvents([createEv]);
146	                    model.applyEvents([
147	                        {
148	                            type: "group-merge",
149	                            timestamp: 0,
150	                            targetGroupId: anchorGroup,
151	                            movingGroupId: createEv.groupId,
152	                            anchorRootId: anchor,
153	                            joinRootId: createEv.rootHandleId,
154	                            anchorPmiId: anchorPmi,
155	                            joinPmiId: joinPmi,
156	                            rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),
157	                        } as GroupEvent,
158	                    ]);
159	                    // Remember where this Steher heals from, so a böppel click clones the anchor fence.
160	                    this.steherOrigin.set(createEv.rootHandleId, { fence: anchor, anchorPmi, joinPmi });
161	                }
162	            }
163	        }
164	    }

                }
                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === anchorPmi)) {
                        dbg(`anchor ${anchor} ${anchorPmi}: occupied`);
                        continue;
                    }
                    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 });
                    if (!part) {
                        dbg(`slot ${slotId}: resolver returned null`);
                        continue;
                    }
                    dbg(`slot ${slotId}: deriving Steher (join ${joinPmi})`);
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    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 });
                }
            }
        }
    }


                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === anchorPmi)) {
                        dbg(`anchor ${anchor} ${anchorPmi}: occupied`);
                        continue;
                    }
                    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 });
                    if (!part) {
                        dbg(`slot ${slotId}: resolver returned null`);
                        continue;
                    }
                    dbg(`slot ${slotId}: deriving Steher (join ${joinPmi})`);
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    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 });
                }

                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // A Steher heals at EVERY CS_OUT — free end OR shared junction. At a junction the point
                    // is taken by the fence-fence edge; the derived merge is ephemeral, so it docks there
                    // too → the two fences share this one Steher instead of each getting its own.
                    const conns = model.connectionsOf(anchor).filter((c) => c.pmi === anchorPmi);
                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // Bauform via the GEKO-76 matrix: side = the fence end the Steher heals from; occupied
                    // = the branch directions of the other fences at this junction. TODO: derive the angle
                    // (0/90/180) from each conn's rot — pending visual verification; empty for now, so the
                    // Bauform is the single-fence end form until the angle mapping is confirmed.
                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
                    const occupied: SteherAngle[] = [];
                    const bauform = steherBauform(side, occupied);
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
                    if (!part) {
                        dbg(`slot ${slotId}: resolver returned null`);
                        continue;
                    }
                    dbg(`slot ${slotId}: healing Steher (${conns.length ? "shared junction" : "free end"}, ${bauform})`);
                    // Deterministic ids keyed on the slot → stable across replays. Apply the create first so
                    // the Steher's PMI frames are in the model before dockRotation reads them, then merge it
                    // onto the anchor point (ephemeral → may sit on an occupied junction point).
                    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),
                            ephemeral: true,
                        } 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 });
                }


                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === anchorPmi)) {
                        dbg(`anchor ${anchor} ${anchorPmi}: occupied`);
                        continue;
                    }
                    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 });
                    if (!part) {
                        dbg(`slot ${slotId}: resolver returned null`);
                        continue;
                    }
                    dbg(`slot ${slotId}: deriving Steher (join ${joinPmi})`);
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    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 });
                }

                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // A Steher heals at EVERY CS_OUT — free end OR shared junction. At a junction the point
                    // is taken by the fence-fence edge; the derived merge is ephemeral, so it docks there
                    // too → the two fences share this one Steher instead of each getting its own.
                    const conns = model.connectionsOf(anchor).filter((c) => c.pmi === anchorPmi);
                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // Bauform via the GEKO-76 matrix: side = the fence end the Steher heals from; occupied
                    // = the branch directions of the other fences at this junction. TODO: derive the angle
                    // (0/90/180) from each conn's rot — pending visual verification; empty for now, so the
                    // Bauform is the single-fence end form until the angle mapping is confirmed.
                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
                    const occupied: SteherAngle[] = [];
                    const bauform = steherBauform(side, occupied);
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
                    if (!part) {
                        dbg(`slot ${slotId}: resolver returned null`);
                        continue;
                    }
                    dbg(`slot ${slotId}: healing Steher (${conns.length ? "shared junction" : "free end"}, ${bauform})`);
                    // Deterministic ids keyed on the slot → stable across replays. Apply the create first so
                    // the Steher's PMI frames are in the model before dockRotation reads them, then merge it
                    // onto the anchor point (ephemeral → may sit on an occupied junction point).
                    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),
                            ephemeral: true,
                        } 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 });
                }

-                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →

-                    // no derived Steher.

-                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === anchorPmi)) {

-                        dbg(`anchor ${anchor} ${anchorPmi}: occupied`);

+                    // A Steher heals at EVERY CS_OUT — free end OR shared junction. At a junction the point

+                    // is taken by the fence-fence edge; the derived merge is ephemeral, so it docks there

+                    // too → the two fences share this one Steher instead of each getting its own.

+                    const conns = model.connectionsOf(anchor).filter((c) => c.pmi === anchorPmi);

-                    // = 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.

+                    // = the branch directions of the other fences at this junction. TODO: derive the angle

+                    // (0/90/180) from each conn's rot — pending visual verification; empty for now, so the

+                    // Bauform is the single-fence end form until the angle mapping is confirmed.

-                    dbg(`slot ${slotId}: deriving Steher (join ${joinPmi})`);

-                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,

-                    // override binding). Apply the create first so the Steher's PMI frames are in the

-                    // model before dockRotation reads them, then merge it into the anchor's group.

+                    dbg(`slot ${slotId}: healing Steher (${conns.length ? "shared junction" : "free end"}, ${bauform})`);

+                    // Deterministic ids keyed on the slot → stable across replays. Apply the create first so

+                    // the Steher's PMI frames are in the model before dockRotation reads them, then merge it

+                    // onto the anchor point (ephemeral → may sit on an occupied junction point).

+                            ephemeral: true,


Test: nach dem Böppel-Anbau gibt es jetzt einen **geteilten** Steher an der Junction.


        // A real fence was cloned (not a Steher): now two non-Steher parts (anchor + clone).
        expect(parts(after).filter((id) => !id.startsWith("h-steher"))).toHaveLength(2);
    });

        // A real fence was cloned (not a Steher): now two non-Steher parts (anchor + clone).
        expect(parts(after).filter((id) => !id.startsWith("h-steher"))).toHaveLength(2);
        // The junction now carries a SHARED Steher plus the clone's free-end Steher = 2 (not 0, not 3).
        expect(parts(after).filter((id) => id.startsWith("h-steher"))).toHaveLength(2);
    });


        // A real fence was cloned (not a Steher): now two non-Steher parts (anchor + clone).
        expect(parts(after).filter((id) => !id.startsWith("h-steher"))).toHaveLength(2);
        // The junction now carries a SHARED Steher plus the clone's free-end Steher = 2 (not 0, not 3).
        expect(parts(after).filter((id) => id.startsWith("h-steher"))).toHaveLength(2);
    });

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

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

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

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

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

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

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

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

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

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

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

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

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

+        // The junction now carries a SHARED Steher plus the clone's free-end Steher = 2 (not 0, not 3).

+        expect(parts(after).filter((id) => id.startsWith("h-steher"))).toHaveLength(2);


Bash

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

Run Steher tests after junction sharing


Tests  8 passed (8)


Geteilter Steher an der Junction bestätigt (8/8). 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


Gebaut — 141 Tests grün, Typecheck sauber.

**Geteilter Steher an der Junction (Topologie):** Ein Steher heilt jetzt an *jedem* `CS_OUT` — freies Ende **oder** gemeinsame Kante. An der Kante ist der Punkt vom Zaun-Zaun-Edge belegt; die abgeleitete Merge ist **ephemer** und darf dort trotzdem andocken → **zwei Zäune teilen sich diesen einen Steher**, statt je einen zu bekommen. Test: zwei verbundene Zäune → ein geteilter + ein freier Steher (nicht doppelt, nicht fehlend).

Dazu im `GroupProcessor`: `ephemeral`-Flag am Merge (umgeht die Doppelbelegungs-Sperre) + `connectionsOf` (Kanten pro Zaun, für die Junction-Analyse).

**Im Configurator zu prüfen:** Zaun bauen, Böppel klicken → am Übergang steht **ein** geteilter Steher (kein Doppel, keine Lücke).

**Bewusst noch offen — braucht deine Augen:** Die belegten **Winkel** (0/90/180) aus den Kanten-Rotationen sind noch `[]`, also ist die Bauform vorerst die Einzel-Form (B/C). Die Ecken/T/Kreuz-Bauformen (D/E/F) kommen, sobald wir die `rot`→Winkel-Zuordnung am laufenden Bild bestätigen — das ist der eine Teil, den ich nicht blind richtig treffe.

Sag mir, ob die **Topologie** im Bild sitzt (ein geteilter Steher pro Junction). Dann machen wir die Winkel→Bauform mit dir am Bild.


Du baus immer noch zwei nebeneinander


Der Fehler: im Allround-Fall (Regel mit L **und** R) liegen am Junction-Punkt **zwei** PMIs — `A.CS_OUT_R` (belegt) *und* `B.CS_OUT_L` (frei, weil die Kante `B.CS_EIN_L` nutzt). Beide heilen → zwei Steher nebeneinander. Mein Test (nur R) hat das nicht gezeigt.

Fix: Ein Steher am `CS_OUT_<Seite>` wird übersprungen, wenn dieses **Ende** die Join-Seite einer Kante ist (`CS_EIN_<Seite>` angedockt) — dann heilt der Steher von der Anker-Seite.


Edit

const STEHER_MODULE_ID = "geko-steher";

// The end side (L/R) a CS_OUT_/CS_EIN_ point sits on. CS_OUT_L and CS_EIN_L are the same physical end.
const sideOfPmi = (pmi: string): string | null => pmi.match(/CS_(?:OUT|EIN)_([LR])/)?.[1] ?? null;


const STEHER_MODULE_ID = "geko-steher";

// The end side (L/R) a CS_OUT_/CS_EIN_ point sits on. CS_OUT_L and CS_EIN_L are the same physical end.
const sideOfPmi = (pmi: string): string | null => pmi.match(/CS_(?:OUT|EIN)_([LR])/)?.[1] ?? null;

+// The end side (L/R) a CS_OUT_/CS_EIN_ point sits on. CS_OUT_L and CS_EIN_L are the same physical end.

+const sideOfPmi = (pmi: string): string | null => pmi.match(/CS_(?:OUT|EIN)_([LR])/)?.[1] ?? null;

// The geko-specific placement/docking behaviour, lifted out of the domain-agnostic core: three-point

// snap orientation (dockRotation), docking-rules drag/drop wiring, and the marker "Anbau". It records

// merge/create events through the context and reads the shared group model — no second processor.


                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // A Steher heals at EVERY CS_OUT — free end OR shared junction. At a junction the point
                    // is taken by the fence-fence edge; the derived merge is ephemeral, so it docks there
                    // too → the two fences share this one Steher instead of each getting its own.
                    const conns = model.connectionsOf(anchor).filter((c) => c.pmi === anchorPmi);
                    const slotId = `steher@${anchor}:${anchorPmi}`;

                // The ends where THIS fence is the join (`to`) side of an edge. At such an end the shared
                // Steher is healed from the OTHER fence's CS_OUT (the edge's `from`), so this fence must
                // not heal its own CS_OUT there — that is what produced two Stehers side by side.
                const conns = model.connectionsOf(anchor);
                const joinEnds = new Set(
                    conns.filter((c) => !c.isFrom).map((c) => sideOfPmi(c.pmi)).filter((s): s is string => !!s),
                );
                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    if (joinEnds.has(sideOfPmi(anchorPmi) ?? "")) {
                        dbg(`anchor ${anchor} ${anchorPmi}: join-side of a junction — healed from the anchor fence`);
                        continue;
                    }
                    const junctionConns = conns.filter((c) => c.pmi === anchorPmi);
                    const slotId = `steher@${anchor}:${anchorPmi}`;


                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // A Steher heals at EVERY CS_OUT — free end OR shared junction. At a junction the point
                    // is taken by the fence-fence edge; the derived merge is ephemeral, so it docks there
                    // too → the two fences share this one Steher instead of each getting its own.
                    const conns = model.connectionsOf(anchor).filter((c) => c.pmi === anchorPmi);
                    const slotId = `steher@${anchor}:${anchorPmi}`;

                // The ends where THIS fence is the join (`to`) side of an edge. At such an end the shared
                // Steher is healed from the OTHER fence's CS_OUT (the edge's `from`), so this fence must
                // not heal its own CS_OUT there — that is what produced two Stehers side by side.
                const conns = model.connectionsOf(anchor);
                const joinEnds = new Set(
                    conns.filter((c) => !c.isFrom).map((c) => sideOfPmi(c.pmi)).filter((s): s is string => !!s),
                );
                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    if (joinEnds.has(sideOfPmi(anchorPmi) ?? "")) {
                        dbg(`anchor ${anchor} ${anchorPmi}: join-side of a junction — healed from the anchor fence`);
                        continue;
                    }
                    const junctionConns = conns.filter((c) => c.pmi === anchorPmi);
                    const slotId = `steher@${anchor}:${anchorPmi}`;

+                // The ends where THIS fence is the join (`to`) side of an edge. At such an end the shared

+                // Steher is healed from the OTHER fence's CS_OUT (the edge's `from`), so this fence must

+                // not heal its own CS_OUT there — that is what produced two Stehers side by side.

+                const conns = model.connectionsOf(anchor);

+                const joinEnds = new Set(

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

-                    // A Steher heals at EVERY CS_OUT — free end OR shared junction. At a junction the point

-                    // is taken by the fence-fence edge; the derived merge is ephemeral, so it docks there

-                    // too → the two fences share this one Steher instead of each getting its own.

-                    const conns = model.connectionsOf(anchor).filter((c) => c.pmi === anchorPmi);

+                    if (joinEnds.has(sideOfPmi(anchorPmi) ?? "")) {

+                        dbg(`anchor ${anchor} ${anchorPmi}: join-side of a junction — healed from the anchor fence`);

+                    const junctionConns = conns.filter((c) => c.pmi === anchorPmi);

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

// = the branch directions of the other fences at this junction. TODO: derive the angle


dbg(`slot ${slotId}: healing Steher (${conns.length ? "shared junction" : "free end"}, ${bauform})`);

dbg(`slot ${slotId}: healing Steher (${junctionConns.length ? "shared junction" : "free end"}, ${bauform})`);


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

-                    dbg(`slot ${slotId}: healing Steher (${conns.length ? "shared junction" : "free end"}, ${bauform})`);

+                    dbg(`slot ${slotId}: healing Steher (${junctionConns.length ? "shared junction" : "free end"}, ${bauform})`);

// Deterministic ids keyed on the slot → stable across replays. Apply the create first so

// the Steher's PMI frames are in the model before dockRotation reads them, then merge it

// onto the anchor point (ephemeral → may sit on an occupied junction point).


Test mit **beidseitiger** Regel (der Fall, der die Doppelung zeigt): eine Junction trägt genau einen geteilten Steher.


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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

    it("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("a junction carries exactly ONE shared Steher, not two side by side (both-side rule)", () => {

+            { anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L", CS_OUT_L_0: "CS_EIN_R" } },

+        const proc = new DefaultCommandProcessor();

+        proc.register(new GekoPlacementModule({}, BOTH, () => productData));

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

+        expect(parts(scene).filter((id) => id.startsWith("h-steher"))).toHaveLength(2); // a Steher at each end

+        const steherR = parts(scene).find((id) => id.startsWith("h-steher") && id.includes("CS_OUT_R"))!;

+                eventTypeName: "selection-click",

+                target: { id: `sel-marker-${steherR}-CS_SELECT_0` },

+                selectionAction: "set",

+        expect(parts(scene).filter((id) => !id.startsWith("h-steher"))).toHaveLength(2); // two fences

+        // anchor-L (free) + shared junction + clone-R (free) = 3 — NOT 4 (no doubled junction Steher).

+        expect(parts(scene).filter((id) => id.startsWith("h-steher"))).toHaveLength(3);