Bash

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

Run junction dedup test


Tests  9 passed (9)


Kein Doppel mehr (9/9). Volle Suite + Typecheck.


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

Full suite and typecheck


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


Gefixt — 142 Tests grün, Typecheck sauber.

**Ursache der zwei nebeneinander:** Am Junction-Punkt liegen zwei PMIs — `CS_OUT` des Anker-Zauns (belegt) *und* `CS_OUT` des Anbau-Zauns am selben Ende (frei, weil die Kante `CS_EIN` nutzt). Beide heilten. Jetzt heilt der Steher **nur von der Anker-Seite**; das Ende, an dem ein Zaun die **Join-Seite** einer Kante ist, wird übersprungen.

Test mit beidseitiger Regel (der Fall, der es zeigt): eine Junction → **ein** geteilter Steher, nicht zwei.

Im Configurator prüfen: Zaun bauen → am Übergang steht jetzt genau **ein** Steher.

Offen bleibt weiterhin nur die **Winkel→Bauform**-Ableitung (Ecken D/E/F) — die machen wir am Bild, sobald die Topologie für dich sitzt.


ok -- es steher zahl passt aber es ird nur gerade angebaut
kein 90 / 270 Grad


Steher-Zahl passt — jetzt fehlt der Winkel. Der Böppel-Anbau ignoriert die geklickte `CS_SELECT_<Winkel>`-Richtung und dockt gerade. Ich muss den Winkel des Böppel in die Rotation bringen. Erst prüfe ich die Quaternion-Helfer in `transformMath`.


grep -nE 'export (function|const) (quat|euler|vec|axis)|export type (Quat|Vec3)' packages/frontend/src/configurator/model/transformMath.ts | head -30

List transformMath exports


5:export type Vec3 = [number, number, number];
6:export type Quat = [number, number, number, number]; // [w, x, y, z]
15:export function quatMul(a: Quat, b: Quat): Quat {
26:export function quatConj(q: Quat): Quat {
31:export function quatRotate(q: Quat, v: Vec3): Vec3 {
62:export function eulerZyxRadToQuat(rad: Vec3): Quat {


ok -- es steher zahl passt aber es ird nur gerade angebaut kein 90 / 270 Grad


Ich habe `quatMul`, `eulerZyxRadToQuat`, `quatRotate`. Ich bringe den geklickten Böppel-Winkel in die Rotation: parse `CS_SELECT_<Winkel>` → Y-Achsen-Drehung, komponiert mit der geraden Dock-Rotation. **Wichtig:** Achse/Reihenfolge/Vorzeichen sind der Blind-Geometrie-Teil — den tunen wir am Bild.


Edit

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

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


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

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

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

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

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

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

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

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


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

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


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

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

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

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

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

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


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

    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: string,
        stop: UIReaction,
        ctx: CommandContext,
    ): UIReaction[] {
        ctx.replay();
        const targetGroup = ctx.groups.groupIdOfElement(origin.fence);
        const scene = ctx.groups.sceneOfElement(origin.fence);
        if (!targetGroup || !scene) return [stop];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        // Straight dock (both frames off the anchor fence; the fresh copy isn't in the handle map yet)
        // composed with the branch angle from the clicked böppel (CS_SELECT_<angle>) around the vertical
        // (Y) axis. NOTE: axis / order / sign of `branch` are the un-verifiable-blind bit — tune at the
        // running image (0 = straight, 90 / 270 = the two corners).
        const angleDeg = Number(clickedPmi.match(/(\d+)\s*$/)?.[1] ?? "0");
        const branch = eulerZyxRadToQuat([0, (angleDeg * Math.PI) / 180, 0]);
        const straight = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi);
        const rot = angleDeg ? quatMul(branch, straight) : straight;
        ctx.record({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: targetGroup,
            movingGroupId: createEvent.groupId,
            anchorRootId: origin.fence,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }


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

    private buildFenceFromSteher(
        origin: { fence: string; anchorPmi: string; joinPmi: string },
        clickedPmi: string,
        stop: UIReaction,
        ctx: CommandContext,
    ): UIReaction[] {
        ctx.replay();
        const targetGroup = ctx.groups.groupIdOfElement(origin.fence);
        const scene = ctx.groups.sceneOfElement(origin.fence);
        if (!targetGroup || !scene) return [stop];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        // Straight dock (both frames off the anchor fence; the fresh copy isn't in the handle map yet)
        // composed with the branch angle from the clicked böppel (CS_SELECT_<angle>) around the vertical
        // (Y) axis. NOTE: axis / order / sign of `branch` are the un-verifiable-blind bit — tune at the
        // running image (0 = straight, 90 / 270 = the two corners).
        const angleDeg = Number(clickedPmi.match(/(\d+)\s*$/)?.[1] ?? "0");
        const branch = eulerZyxRadToQuat([0, (angleDeg * Math.PI) / 180, 0]);
        const straight = ctx.groups.dockRotation(origin.fence, origin.anchorPmi, origin.fence, origin.joinPmi);
        const rot = angleDeg ? quatMul(branch, straight) : straight;
        ctx.record({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: targetGroup,
            movingGroupId: createEvent.groupId,
            anchorRootId: origin.fence,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: origin.anchorPmi,
            joinPmiId: origin.joinPmi,
            rot,
        } as GroupEvent);
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

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

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

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

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

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

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

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

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

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

-            // 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),


Bash

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

Run tests and typecheck after angle wiring


Gebaut — 142 grün, Typecheck sauber.

Der geklickte Böppel-Winkel (`CS_SELECT_0/90/180/270`) geht jetzt als **Y-Achsen-Verzweigung** in die Rotation, komponiert mit dem geraden Dock. 0 = gerade, 90/270 = die zwei Ecken.

**Das ist der Blind-Geometrie-Teil** — Achse, Reihenfolge und Vorzeichen der Drehung kann ich nicht raten. Bitte am Bild prüfen und mir sagen, was du siehst, dann tune ich in einem Schritt:

- Klick auf den **90°-Böppel** → baut der Zaun in die richtige Ecke, in die falsche Richtung (dann Vorzeichen/270 tauschen), oder in einer falschen Ebene/schief (dann ist die **Achse** falsch — nicht Y, sondern X/Z)?
- Klick auf **270°** → das Gegenstück?

Sag mir konkret „90 dreht falsch herum" / „dreht um die falsche Achse" / „passt" — dann ist die eine offene Stelle zu.


Die richtung passt
was sagt denn das Ticket zu den Bauformen?


Das Ticket gibt eine **Matrix**: Steher-Bauform (A–G) = Funktion aus der **eigenen Anbau-Seite** (STARTPUNKT / L / R) und den **belegten Out-Winkeln** (0/90/180).

| Anbau-Seite | belegte PMIs | Bauform |
|---|---|---|
| **STARTPUNKT** | 0 + 180 | A |
| | 180 | B |
| | 0 | C |
| | 90 + 180 | D |
| | 0 + 90 | E |
| | 0 + 90 + 180 | F |
| | sonstige | G |
| **L** | – | B |
| | 0 | A |
| | 90 | D |
| | 0 + 90 | F |
| | sonstige | G |
| **R** | – | C |
| | 180 | A |
| | 90 + 180 | F |
| | sonstige | G |

(Dazu: L bietet nie C/E an, R nie B/D/E — das ist die UI-Beschränkung fürs Override-Dropdown.)

**Das ist schon gebaut** — `steherBauform(side, occupied)` in `steherBauform.ts`, gegen jede Ticket-Zeile getestet. Verdrahtet ist es auch: `derive` ruft es, geko mappt den Buchstaben auf `SAP4180<letter>NA`.

**Was noch fehlt, ist der Input `occupied`** — der ist aktuell `[]`, deshalb kommt immer die Einzel-Form (B/C). Und **genau das** kann ich jetzt füllen: der Böppel-Anbau schreibt den Winkel in `edge.rot`. Also liefert `connectionsOf` an einer Junction die Kanten mit ihren Rotationen → daraus die belegten Winkel (0/90/180) → Matrix → D/E/F.

Die eine offene Umrechnung: **`edge.rot` → Winkel 0/90/180** (Yaw extrahieren, auf die drei Werte bucketen). Soll ich das einbauen — dann heilt der Steher an Ecken/T in die richtige Bauform?


gut - snapiing?


Snapping (der Drag-Place-Pfad) verträgt sich mit dem Steher-Modell **noch nicht** — aus zwei Gründen:

1. **Die Drop-Points schrumpfen.** `placementDragPoints` whitelistet offene Enden über `blockedPmis`. Der ephemere Steher belegt aber das `CS_OUT` des Zauns → dieses Ende gilt als blockiert → wird nicht mehr als Snap-Ziel angeboten. Also snappt es gerade an den falschen (zu wenigen) Punkten.
2. **Snap auf einen Steher-Slot wäre unspeicherbar.** Wenn man auf die offene Steher-Richtung (`CS_OUT_90` am Steher) snappt, zeigt der Merge auf den **ephemeren** Steher — genau das, was nicht in den EventStore darf.

Der saubere Weg ist derselbe wie beim Böppel — **beide Pfade konvergieren**:

- **Drop-Points = die offenen Slots des Stehers** (die Richtungen 0/90/180/270, an denen ein Zaun fehlt) — nicht die Zaun-Enden.
- **Ein Snap dort löst auf `(Anker-Zaun, Winkel)` auf** und schreibt einen **Zaun-zu-Zaun-Merge** mit der Winkel-Rotation — exakt die `buildFenceFromSteher`-Logik, nur aus dem Drag statt dem Klick.

Damit ist Snapping = Böppel-Anbau über den Drag: du ziehst ein Element (Klon des Ankers oder ein gewähltes Katalog-Teil) auf einen offenen Steher-Slot, es snappt in die Winkel-Richtung, der Steher heilt neu.

Soll ich Snapping durch dieselbe Junction-Auflösung routen (Drop-Points aus den offenen Steher-Slots, Snap → Zaun-zu-Zaun am Winkel)? Dann sind Klick und Drag konsistent.


Siund ephemere steher tatsächlich so gut?


Ehrliche Antwort: **eher nicht** — sie sind elegant in der Isolation, aber sie kämpfen an *jeder* Schnittstelle, und sie weichen vom Ticket ab.

**Was ephemer bringt:** Auto-Heilung, keine Undo-Verschmutzung, verwaiste Steher verschwinden gratis, eine einzige Wahrheit (die Zäune).

**Was es kostet — und das ist systematisch:** Weil ein ephemerer Steher **nie in einem gespeicherten Event referenziert** werden darf, braucht *jeder* Berührungspunkt eine Übersetzungsschicht:
- Böppel: muss den Anker-Zaun klonen, nicht den Steher (Origin-Map).
- Snapping: Drop-Points müssen Steher-Slots sein, der Snap muss auf Zaun-zu-Zaun übersetzen; der ephemere Steher blockiert obendrein die Zaun-Enden.
- Move/Regroup: ephemere Member lecken in gespeicherte Events (mit Exclusion-Flags geflickt).
- Select/Delete/Override: der Steher hat keine echte Identität — Overrides laufen über Slot-Id-Seitenkanäle, Löschen über „lösch die Zäune".

**Und der Punkt, der wiegt:** GEKO-76 beschreibt **echte** Steher-Knoten. „splittet die Engine … in drei 3D-Komponenten", „der hintere Steher wird zum Verbindungssteher", separat selektierbar, kaskadierend löschbar, manuell überschreibbar. Das ist ein *erstklassiger, interaktiver Knoten* — dessen **Bauform und Höhe** heilen, nicht seine Existenz. Wir haben das Modell umgedreht: wir leiten den ganzen Knoten ab, statt nur seine Eigenschaften.

**Die Alternative:** Steher als **echte, gespeicherte Knoten** an den Junctions. Geheilt wird dann:
- **Bauform (A–G)** und **Höhe** — aus den anliegenden Zäunen nach Mouse-Up (Eigenschafts-Update, keine Neu-Erzeugung).
- **Existenz** wird explizit verwaltet: bei Platzierung erzeugt, beim Anbau als Verbindungssteher wiederverwendet, per Kaskade gelöscht — genau die Ticket-Regeln.

Dann sind Select/Delete/Override/Snap/Böppel **natürlich** (der Steher ist ein realer Knoten, an den Zäune andocken), und die ganze Übersetzungsschicht fällt weg. Preis: Existenz-Verwaltung (erzeugen/teilen/kaskadiert löschen) läuft über Events statt „einfach nicht ableiten" — aber genau das will das Ticket (undo-bar, löschbar).

Mein Fazit: Für einen *rein abgeleiteten Dekor* wäre ephemer richtig. Für einen Knoten, den der Nutzer **anklickt, anbaut, löscht, überschreibt** — und den das Ticket als real beschreibt — arbeiten wir gegen den Strich. Ich würde auf **echte Steher, Eigenschaften geheilt** umstellen.

Das ist ein echter Umbau (die Ephemer-Flags, Origin-Maps, Exclusions fallen weg, dafür kommt Existenz-/Kaskaden-Logik). Deshalb lege ich's als Entscheidung hin, nicht als Alleingang: umschwenken auf echte Steher?


Wir haben doch schon groupirte Events, die über einen schritt im undó / redo gebündert sind


Genau — das kippt meinen Haupteinwand. Der Undo-Pollution-Punkt gegen echte Steher fällt weg, weil `pushGroup` schon existiert: eine Aktion = **ein** gebündelter Undo-Schritt.

Damit wird das Modell mit **echten** Stehern sauber:

- **Platzierung** → eine Gruppe: `[Zaun anlegen, vorderer Steher, hinterer Steher, Merges]` = ein Undo-Schritt.
- **Anbau** → eine Gruppe: `[neuer Zaun, gemeinsamer Steher wiederverwenden/erzeugen, Nachbar-Bauform anpassen]` = ein Schritt.
- **Löschen** → eine Gruppe: `[Zaun weg, verwaiste Steher kaskadiert löschen, Nachbarn re-heilen]` = ein Schritt.
- **Bauform-/Höhen-Heilung** nach Mouse-Up gehört in **dieselbe** Gruppe der auslösenden Aktion → kein separater Undo-Schritt, und Undo nimmt Zaun + Steher gemeinsam zurück.

Der „Hook" ändert damit seine Form — und wird ticket-treuer:
- **vorher (ephemer):** läuft in *jedem* `replay()`, leitet den ganzen Knoten ab, referenzierbar nirgends.
- **nachher (echt):** läuft **einmal nach Commit** (Mouse-Up), gleicht das Ziel-Steher-Set (Existenz + Bauform + Höhe) gegen den Ist-Stand ab und schreibt den **Diff als Events in die laufende Gruppe**. Genau GEKO-76: „nach Mouse-Up prüft ein Callback … tauscht die Bauform."

Was wegfällt: Ephemer-Flag, Origin-Map, `connectionsOf`-Sonderweg, Regroup-Exclusions, die ganze Böppel-/Snap-Übersetzung. Steher werden reale Knoten — Select/Delete/Override/Snap/Böppel sind dann direkt, weil Zäune an einen **echten** Steher andocken.

Was neu dazukommt: eine **Reconciliation-Funktion** (Ziel-Steher aus den Zäunen → Diff-Events) und die Kaskaden-Lösch-Regeln.

Das ist der Umbau, den ich für richtig halte. Groß, aber gradliniger als die Ephemer-Flickerei. Soll ich auf echte Steher umstellen — Reconciliation-Hook, der pro Aktion Steher-Diff-Events in die Undo-Gruppe schreibt?


go


Go. Das ist ein Umbau in Stufen — ich baue Stufe 1 jetzt und teste sie, den Rest (Kaskaden-Löschen, Bauform-Swap) in Folgeschritten.

**Stufe 1: Reconciliation-Infrastruktur + echte Steher.** Ich beginne mit dem EventStore-Mechanismus, um Reconciler-Events in die Undo-Gruppe der Aktion zu bündeln.


28	    // A single event is its own one-step group.
29	    push(event: DomainEvent): void {
30	        this.pushGroup([event]);
31	    }
32
33	    // A group of events that undo/redo together as a single step.
34	    pushGroup(events: DomainEvent[]): void {
35	        if (this.cursor < this.groups.length - 1) {
36	            this.groups = this.groups.slice(0, this.cursor + 1);
37	        }
38	        this.groups.push(events);
39	        this.cursor = this.groups.length - 1;
40	    }
41
42	    undo(): number {
43	        if (!this.canUndo) return this.cursor;
44	        this.cursor--;
45	        return this.cursor;
46	    }
47
48	    redo(): number {
49	        if (!this.canRedo) return this.cursor;
50	        this.cursor++;
51	        return this.cursor;
52	    }
53
54	    getEventsToReplay(): DomainEvent[] {
55	        if (this.cursor < 0) return [];
56	        const result: DomainEvent[] = [];
57	        for (let i = 0; i <= this.cursor; i++) {

    // A single event is its own one-step group.
    push(event: DomainEvent): void {
        this.pushGroup([event]);
    }

    // A group of events that undo/redo together as a single step.
    pushGroup(events: DomainEvent[]): void {
        if (this.cursor < this.groups.length - 1) {
            this.groups = this.groups.slice(0, this.cursor + 1);
        }
        this.groups.push(events);
        this.cursor = this.groups.length - 1;
    }

    undo(): number {
        if (!this.canUndo) return this.cursor;
        this.cursor--;
        return this.cursor;
    }

    redo(): number {
        if (!this.canRedo) return this.cursor;
        this.cursor++;
        return this.cursor;
    }

    getEventsToReplay(): DomainEvent[] {
        if (this.cursor < 0) return [];
        const result: DomainEvent[] = [];
        for (let i = 0; i <= this.cursor; i++) {


Edit

    // A group of events that undo/redo together as a single step.
    pushGroup(events: DomainEvent[]): void {

    // Append events to the most recent group so they undo/redo with it as one step. Used by the Steher
    // reconciler to bundle its diff into the interaction that triggered it. No prior group → own group.
    appendToLastGroup(events: DomainEvent[]): void {
        if (!events.length) return;
        if (this.cursor < 0) {
            this.pushGroup(events);
            return;
        }
        this.groups[this.cursor].push(...events);
    }

    // A group of events that undo/redo together as a single step.
    pushGroup(events: DomainEvent[]): void {


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

    // Append events to the most recent group so they undo/redo with it as one step. Used by the Steher
    // reconciler to bundle its diff into the interaction that triggered it. No prior group → own group.
    appendToLastGroup(events: DomainEvent[]): void {
        if (!events.length) return;
        if (this.cursor < 0) {
            this.pushGroup(events);
            return;
        }
        this.groups[this.cursor].push(...events);
    }

    // A group of events that undo/redo together as a single step.
    pushGroup(events: DomainEvent[]): void {

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

export type DomainEvent = GroupEvent;

export class EventStore {
    // Each entry is one undo/redo step: a group of events applied/reverted together.
    // The cursor indexes groups, not individual events.
    private groups: DomainEvent[][] = [];
    private cursor: number = -1;
    private initialSceneGraph: any = null;

    get canUndo(): boolean {
        return this.cursor >= 0;
    }

    get canRedo(): boolean {
        return this.cursor < this.groups.length - 1;
    }

    setInitialState(sceneGraph: any): void {
        this.initialSceneGraph = structuredClone(sceneGraph);
    }

    getInitialState(): any {
        return structuredClone(this.initialSceneGraph);
    }

    // A single event is its own one-step group.
    push(event: DomainEvent): void {
        this.pushGroup([event]);
    }

    // A group of events that undo/redo together as a single step.
    pushGroup(events: DomainEvent[]): void {
        if (this.cursor < this.groups.length - 1) {
            this.groups = this.groups.slice(0, this.cursor + 1);
        }
        this.groups.push(events);
        this.cursor = this.groups.length - 1;
    }

    undo(): number {
        if (!this.canUndo) return this.cursor;
        this.cursor--;
        return this.cursor;
    }

    redo(): number {
        if (!this.canRedo) return this.cursor;
        this.cursor++;
        return this.cursor;
    }

    getEventsToReplay(): DomainEvent[] {
        if (this.cursor < 0) return [];
        const result: DomainEvent[] = [];
        for (let i = 0; i <= this.cursor; i++) {
            for (let j = 0; j < this.groups[i].length; j++) {
                result.push(this.groups[i][j]);
            }
        }
        return result;
    }
}

+    // Append events to the most recent group so they undo/redo with it as one step. Used by the Steher

+    // reconciler to bundle its diff into the interaction that triggered it. No prior group → own group.

+    appendToLastGroup(events: DomainEvent[]): void {

+        this.groups[this.cursor].push(...events);


export type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent
    | GroupModuleEvent;

// Remove an element from the model entirely (and its group if it becomes empty). Used by the Steher
// reconciler to drop a Steher whose junction is gone, and for cascade delete.
export interface GroupRemoveEvent {
    type: "group-remove";
    timestamp: number;
    elementId: string;
}

export type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent
    | GroupModuleEvent
    | GroupRemoveEvent;


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 type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent
    | GroupModuleEvent;

// Remove an element from the model entirely (and its group if it becomes empty). Used by the Steher
// reconciler to drop a Steher whose junction is gone, and for cascade delete.
export interface GroupRemoveEvent {
    type: "group-remove";
    timestamp: number;
    elementId: string;
}

export type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent
    | GroupModuleEvent
    | GroupRemoveEvent;

+// Remove an element from the model entirely (and its group if it becomes empty). Used by the Steher

+// reconciler to drop a Steher whose junction is gone, and for cascade delete.


            case "module-event": {
                let slice = this.moduleState.get(event.moduleId);

            case "group-remove": {
                const gid = this.elementToGroup.get(event.elementId);
                const g = gid ? this.groups.get(gid) : null;
                if (g) {
                    g.members = g.members.filter((m) => m !== event.elementId);
                    g.edges = g.edges.filter((e) => e.from !== event.elementId && e.to !== event.elementId);
                    if (!g.members.length) this.groups.delete(gid);
                }
                this.elementToGroup.delete(event.elementId);
                this.handles.delete(event.elementId);
                this.elementHandles.delete(event.elementId);
                this.ephemeralElements.delete(event.elementId);
                break;
            }
            case "module-event": {
                let slice = this.moduleState.get(event.moduleId);


            case "group-remove": {
                const gid = this.elementToGroup.get(event.elementId);
                const g = gid ? this.groups.get(gid) : null;
                if (g) {
                    g.members = g.members.filter((m) => m !== event.elementId);
                    g.edges = g.edges.filter((e) => e.from !== event.elementId && e.to !== event.elementId);
                    if (!g.members.length) this.groups.delete(gid);
                }
                this.elementToGroup.delete(event.elementId);
                this.handles.delete(event.elementId);
                this.elementHandles.delete(event.elementId);
                this.ephemeralElements.delete(event.elementId);
                break;
            }
            case "module-event": {
                let slice = this.moduleState.get(event.moduleId);

+                const gid = this.elementToGroup.get(event.elementId);

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

+                    g.members = g.members.filter((m) => m !== event.elementId);

+                    g.edges = g.edges.filter((e) => e.from !== event.elementId && e.to !== event.elementId);

+                    if (!g.members.length) this.groups.delete(gid);

+                this.elementToGroup.delete(event.elementId);

+                this.handles.delete(event.elementId);

+                this.elementHandles.delete(event.elementId);

+                this.ephemeralElements.delete(event.elementId);


    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string): GroupCreateEvent {
        const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");
        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
            ephemeral: true,
        };
    }

    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string, ephemeral = true): GroupCreateEvent {
        const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");
        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
            ephemeral,
        };
    }


    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string): GroupCreateEvent {
        const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");
        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
            ephemeral: true,
        };
    }

    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string, ephemeral = true): GroupCreateEvent {
        const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");
        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
            ephemeral,
        };
    }

// Deterministic counterpart of buildCreateEvent for derived (never-stored) elements: same ids on

// every replay, keyed off `seed` (a slot id), so selection/docking/override binding stay stable.

-    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string): GroupCreateEvent {

+    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string, ephemeral = true): GroupCreateEvent {

handles,
position,
rotation: IDENTITY_QUAT,
-            ephemeral: true,
+            ephemeral,
};
}


Bash

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

Test primitives are green


 Test Files  10 passed (10)
      Tests  142 passed (142)
src/configurator/model/GroupProcessor.ts(730,63): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
---done---


725	                const gid = this.elementToGroup.get(event.elementId);
726	                const g = gid ? this.groups.get(gid) : null;
727	                if (g) {
728	                    g.members = g.members.filter((m) => m !== event.elementId);
729	                    g.edges = g.edges.filter((e) => e.from !== event.elementId && e.to !== event.elementId);
730	                    if (!g.members.length) this.groups.delete(gid);
731	                }
732	                this.elementToGroup.delete(event.elementId);
733	                this.handles.delete(event.elementId);
734	                this.elementHandles.delete(event.elementId);

                const gid = this.elementToGroup.get(event.elementId);
                const g = gid ? this.groups.get(gid) : null;
                if (g) {
                    g.members = g.members.filter((m) => m !== event.elementId);
                    g.edges = g.edges.filter((e) => e.from !== event.elementId && e.to !== event.elementId);
                    if (!g.members.length) this.groups.delete(gid);
                }
                this.elementToGroup.delete(event.elementId);
                this.handles.delete(event.elementId);
                this.elementHandles.delete(event.elementId);


g.members = g.members.filter((m) => m !== event.elementId);

g.edges = g.edges.filter((e) => e.from !== event.elementId && e.to !== event.elementId);

-                    if (!g.members.length) this.groups.delete(gid);

+                    if (!g.members.length) this.groups.delete(g.id);


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

Verify primitives green


      Tests  142 passed (142)
---done---

      Tests  142 passed (142)
---done---


Stufe 1 steht — 142 Tests grün, Typecheck sauber. Ich habe die **Primitive** gebaut, die echte Steher mit sauberem Undo möglich machen:

- **`EventStore.appendToLastGroup`** — Reconciler-Events bündeln in die Undo-Gruppe der auslösenden Aktion (Zaun + seine Steher = ein Undo-Schritt).
- **`group-remove`-Event** — ein Element (Steher, dessen Junction weg ist) entfernen; Basis für Kaskaden-Löschen.
- **`buildCreateEventDeterministic(..., ephemeral=false)`** — deterministische, **reale** (gespeicherte) Steher-Creates statt ephemer.

Das ist additiv, ändert noch kein Verhalten — der laufende Ephemer-Weg funktioniert unverändert weiter.

**Stufe 2 (der eigentliche Umschwung) — bewusst getrennt**, weil sie den Kern-Commit-Fluss anfasst und Regressionsrisiko hat:
1. **Reconcile-Hook** am Modul (`reconcile(model): GroupEvent[]`) + Verdrahtung: nach jedem mutierenden Handler läuft Reconcile, schreibt den Steher-Diff via `appendToLastGroup` in dieselbe Gruppe, dann Re-Render.
2. **`GekoPlacementModule.reconcile`** ersetzt `derive`: Ziel-Steher pro Junction berechnen (die vorhandene, funktionierende Geometrie wiederverwenden), gegen die realen Steher diffen → create/remove/swap.
3. Ephemer-Weg raus (Flags, Origin-Map, Regroup-Exclusions), Böppel/Snap docken an **reale** Steher.

Ich habe Stufe 1 sauber gelandet, ohne den grünen Stand zu brechen. Stufe 2 ist der Fluss-Umbau — den mache ich als nächsten fokussierten Schritt (nicht halb im selben Rutsch, sonst reiße ich den funktionierenden Stand auf). Sag „weiter", dann baue ich Stufe 2 und wir verifizieren sie am Bild.


weiter


Stufe 2. Ich verdrahte den Reconcile-Hook: nach jedem mutierenden Handler → Steher-Diff in die Undo-Gruppe, dann Re-Render. Erst der Hook im Interface + Tag-Zugriff im `GroupProcessor`.


Edit

export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
    derive?(model: GroupProcessor): void;
}

export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
    derive?(model: GroupProcessor): void;
    // Runs once after a mutating interaction commits (not every replay). Returns the diff of REAL events
    // (create/remove/swap of Stehers) to converge the model to its target; the core appends them to the
    // interaction's undo group. May read the model and apply its own creates to compute rotations.
    reconcile?(model: GroupProcessor): GroupEvent[];
}


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

export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
    derive?(model: GroupProcessor): void;
    // Runs once after a mutating interaction commits (not every replay). Returns the diff of REAL events
    // (create/remove/swap of Stehers) to converge the model to its target; the core appends them to the
    // interaction's undo group. May read the model and apply its own creates to compute rotations.
    reconcile?(model: GroupProcessor): GroupEvent[];
}

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

// A selection pick: picked geometry plus its escalation level (part → group → …).
export type Pick = { pickedId: string; level: number };

// The handle a registered module drives the core through. The core owns the event store, the group
// model and the selection; the module reads and records through this context instead of re-owning any
// of it. `groups` is the shared GroupProcessor — passed whole so a module never mirrors its queries.
export interface CommandContext {
    readonly groups: GroupProcessor;
    // Append to the event store. Undo/redo/init stay on the core.
    record(event: GroupEvent): void;
    recordGroup(events: GroupEvent[]): void;
    // Recompute the model from the recorded events → scene.
    replay(): any;
    // Selection state.
    getSelection(): Pick[];
    setSelection(picks: Pick[]): void;
    // Core selection resolution/presentation, reused so a module never re-implements it:
    // the standard [stop, setScene, set_selection] WITHOUT decoration (markers are a refine).
    selectionReactions(stop: UIReaction): UIReaction[];
    // The element roots covered by the current selection (a group expands to its members).
    selectionElementRoots(): string[];
    resolveGroupId(handleId: string): string | null;
    groupSelectorOf(handleId: string): string | null;
}

// A prepare hook may stop the default handling and/or contribute its own reactions. Returning a result
// with `stopDefault` short-circuits: no replace, no core default — the returned reactions stand.
export type PrepareResult = { stopDefault?: boolean; reactions?: UIReaction[] } | null;

// The three phases a module can hook per event type.
//  - prepare: runs before the default, may veto it (stopDefault) and supply the alternative reactions.
//  - replace: supplants the default entirely (at most one per event type — a second is rejected).
//  - refine:  post-processes the produced reactions (e.g. decorate the setScene with markers).
export interface EventHooks {
    prepare?(event: any, ctx: CommandContext): PrepareResult;
    replace?(event: any, ctx: CommandContext): UIReaction[];
    refine?(event: any, ctx: CommandContext, reactions: UIReaction[]): UIReaction[];
}

// A registered extension. `hooks()` maps an event type name to its phase hooks. `derive()` is the
// separate replay-time capability: it runs inside replay(), after the stored events assembled the
// model, and injects *derived* geometry (e.g. auto-inserted Stehers) directly into the model so
// docking and materialisation see it. It must be a pure function of the model + the module's own
// override state (model.moduleStateOf) — recomputed every replay, never recorded.
export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
    derive?(model: GroupProcessor): void;
}

+    // Runs once after a mutating interaction commits (not every replay). Returns the diff of REAL events

+    // (create/remove/swap of Stehers) to converge the model to its target; the core appends them to the

+    // interaction's undo group. May read the model and apply its own creates to compute rotations.

+    reconcile?(model: GroupProcessor): GroupEvent[];


// Every element root in the scene whose docking class equals `klass` (its `class-…` tag). Lets the

    // Every element root carrying a tag that starts with `prefix`, with that tag's value (the part after
    // the prefix). Used by the reconciler to find its existing Stehers (tag `steher-slot:<slotId>`) and
    // diff them against the target.
    elementsTagged(prefix: string): Array<{ root: string; value: string }> {
        const out: Array<{ root: string; value: string }> = [];
        for (const g of this.groups.values()) {
            for (const member of g.members) {
                for (const t of this.handles.get(member)?.tags ?? []) {
                    if (typeof t === "string" && t.startsWith(prefix)) out.push({ root: member, value: t.slice(prefix.length) });
                }
            }
        }
        return out;
    }

    // Every element root in the scene whose docking class equals `klass` (its `class-…` tag). Lets the


    // Every element root carrying a tag that starts with `prefix`, with that tag's value (the part after
    // the prefix). Used by the reconciler to find its existing Stehers (tag `steher-slot:<slotId>`) and
    // diff them against the target.
    elementsTagged(prefix: string): Array<{ root: string; value: string }> {
        const out: Array<{ root: string; value: string }> = [];
        for (const g of this.groups.values()) {
            for (const member of g.members) {
                for (const t of this.handles.get(member)?.tags ?? []) {
                    if (typeof t === "string" && t.startsWith(prefix)) out.push({ root: member, value: t.slice(prefix.length) });
                }
            }
        }
        return out;
    }

    // Every element root in the scene whose docking class equals `klass` (its `class-…` tag). Lets the

+    // Every element root carrying a tag that starts with `prefix`, with that tag's value (the part after

+    // the prefix). Used by the reconciler to find its existing Stehers (tag `steher-slot:<slotId>`) and

+    elementsTagged(prefix: string): Array<{ root: string; value: string }> {

+        const out: Array<{ root: string; value: string }> = [];

+        for (const g of this.groups.values()) {

+            for (const member of g.members) {

+                for (const t of this.handles.get(member)?.tags ?? []) {

+                    if (typeof t === "string" && t.startsWith(prefix)) out.push({ root: member, value: t.slice(prefix.length) });

// placement build an id-keyed whitelist of concrete snap targets per target class.

elementsOfClass(klass: string): string[] {


Jetzt `DefaultCommandProcessor`: Mutation-Flag, Reconcile vor Refine, `derive` aus dem Replay raus.


    private replayDirty = true;
    private cachedScene: any = null;

    private replayDirty = true;
    private cachedScene: any = null;
    // Set when the current handleEvent recorded new events — triggers reconciliation of derived nodes.
    private mutated = false;


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

    private replayDirty = true;
    private cachedScene: any = null;
    // Set when the current handleEvent recorded new events — triggers reconciliation of derived nodes.
    private mutated = false;

// the whole graph — the dominant cost on large graphs (a full replay is O(events × graph size)).

+    // Set when the current handleEvent recorded new events — triggers reconciliation of derived nodes.

// Registered placement modules (geko etc.). Domain-specific placement/docking lives there; the core

// is domain-agnostic. Hooks are indexed per event type so dispatch stays O(1).


    private pushEvent(event: GroupEvent): void {
        this.eventStore.push(event);
        this.replayDirty = true;
    }

    private pushEventGroup(events: GroupEvent[]): void {
        this.eventStore.pushGroup(events);
        this.replayDirty = true;
    }

    private pushEvent(event: GroupEvent): void {
        this.eventStore.push(event);
        this.replayDirty = true;
        this.mutated = true;
    }

    private pushEventGroup(events: GroupEvent[]): void {
        this.eventStore.pushGroup(events);
        this.replayDirty = true;
        this.mutated = true;
    }


    private pushEvent(event: GroupEvent): void {
        this.eventStore.push(event);
        this.replayDirty = true;
    }

    private pushEventGroup(events: GroupEvent[]): void {
        this.eventStore.pushGroup(events);
        this.replayDirty = true;
    }

    private pushEvent(event: GroupEvent): void {
        this.eventStore.push(event);
        this.replayDirty = true;
        this.mutated = true;
    }

    private pushEventGroup(events: GroupEvent[]): void {
        this.eventStore.pushGroup(events);
        this.replayDirty = true;
        this.mutated = true;
    }


            this.groups.reset();
            this.groups.applyEvents(stored);
            // Replay-time derivation: registered modules inject ephemeral geometry (e.g. auto Stehers)
            // on top of the assembled model, so docking and materialisation see it. Never stored —
            // recomputed here on every replay, which is exactly what keeps it coupled to the base.
            for (const m of this.modules) m.derive?.(this.groups);
            return this.groups.materializeScene(this.eventStore.getInitialState());

            this.groups.reset();
            this.groups.applyEvents(stored);
            return this.groups.materializeScene(this.eventStore.getInitialState());


            this.groups.reset();
            this.groups.applyEvents(stored);
            // Replay-time derivation: registered modules inject ephemeral geometry (e.g. auto Stehers)
            // on top of the assembled model, so docking and materialisation see it. Never stored —
            // recomputed here on every replay, which is exactly what keeps it coupled to the base.
            for (const m of this.modules) m.derive?.(this.groups);
            return this.groups.materializeScene(this.eventStore.getInitialState());

            this.groups.reset();
            this.groups.applyEvents(stored);
            return this.groups.materializeScene(this.eventStore.getInitialState());

const stored = this.pendingRegroup ? [...events, this.pendingRegroup] : events;

-            // Replay-time derivation: registered modules inject ephemeral geometry (e.g. auto Stehers)

-            // on top of the assembled model, so docking and materialisation see it. Never stored —

-            // recomputed here on every replay, which is exactly what keeps it coupled to the base.

-            for (const m of this.modules) m.derive?.(this.groups);


    handleEvent(event: any): UIReaction[] {
        const type: string = event?.eventTypeName;
        for (const prepare of this.prepareHooks.get(type) ?? []) {
            const result = prepare(event, this.ctx);
            if (result?.stopDefault) return this.refine(type, event, result.reactions ?? []);
        }
        const replace = this.replaceHooks.get(type);
        const reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);
        return this.refine(type, event, reactions);
    }

    handleEvent(event: any): UIReaction[] {
        this.mutated = false;
        const type: string = event?.eventTypeName;
        let reactions: UIReaction[] | null = null;
        for (const prepare of this.prepareHooks.get(type) ?? []) {
            const result = prepare(event, this.ctx);
            if (result?.stopDefault) {
                reactions = result.reactions ?? [];
                break;
            }
        }
        if (reactions === null) {
            const replace = this.replaceHooks.get(type);
            reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);
        }
        // Reconcile derived nodes (Stehers) BEFORE refine, so the markers decorate the final scene.
        reactions = this.reconcile(reactions);
        return this.refine(type, event, reactions);
    }

    // After a mutating interaction, registered modules reconcile their derived nodes: the returned diff
    // (create/remove/swap of real Stehers) is appended to the SAME undo group and the scene re-rendered.
    private reconcile(reactions: UIReaction[]): UIReaction[] {
        if (!this.mutated) return reactions;
        this.replay(); // ensure this.groups reflects the events the handler just recorded
        const diff: GroupEvent[] = [];
        for (const m of this.modules) {
            const r = m.reconcile?.(this.groups);
            if (r?.length) diff.push(...r);
        }
        if (!diff.length) return reactions;
        this.eventStore.appendToLastGroup(diff);
        this.replayDirty = true;
        return this.withScene(reactions, this.replay());
    }

    private withScene(reactions: UIReaction[], scene: any): UIReaction[] {
        const idx = reactions.findIndex((r) => r.type === "sendCommand" && r.cmd === "setScene");
        if (idx < 0) return reactions;
        const out = reactions.slice();
        out[idx] = { type: "sendCommand", cmd: "setScene", args: [scene] };
        return out;
    }


    handleEvent(event: any): UIReaction[] {
        const type: string = event?.eventTypeName;
        for (const prepare of this.prepareHooks.get(type) ?? []) {
            const result = prepare(event, this.ctx);
            if (result?.stopDefault) return this.refine(type, event, result.reactions ?? []);
        }
        const replace = this.replaceHooks.get(type);
        const reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);
        return this.refine(type, event, reactions);
    }

    handleEvent(event: any): UIReaction[] {
        this.mutated = false;
        const type: string = event?.eventTypeName;
        let reactions: UIReaction[] | null = null;
        for (const prepare of this.prepareHooks.get(type) ?? []) {
            const result = prepare(event, this.ctx);
            if (result?.stopDefault) {
                reactions = result.reactions ?? [];
                break;
            }
        }
        if (reactions === null) {
            const replace = this.replaceHooks.get(type);
            reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);
        }
        // Reconcile derived nodes (Stehers) BEFORE refine, so the markers decorate the final scene.
        reactions = this.reconcile(reactions);
        return this.refine(type, event, reactions);
    }

    // After a mutating interaction, registered modules reconcile their derived nodes: the returned diff
    // (create/remove/swap of real Stehers) is appended to the SAME undo group and the scene re-rendered.
    private reconcile(reactions: UIReaction[]): UIReaction[] {
        if (!this.mutated) return reactions;
        this.replay(); // ensure this.groups reflects the events the handler just recorded
        const diff: GroupEvent[] = [];
        for (const m of this.modules) {
            const r = m.reconcile?.(this.groups);
            if (r?.length) diff.push(...r);
        }
        if (!diff.length) return reactions;
        this.eventStore.appendToLastGroup(diff);
        this.replayDirty = true;
        return this.withScene(reactions, this.replay());
    }

    private withScene(reactions: UIReaction[], scene: any): UIReaction[] {
        const idx = reactions.findIndex((r) => r.type === "sendCommand" && r.cmd === "setScene");
        if (idx < 0) return reactions;
        const out = reactions.slice();
        out[idx] = { type: "sendCommand", cmd: "setScene", args: [scene] };
        return out;
    }

// prepare → (replace | core default) → refine. A prepare that stops the default short-circuits the

// middle; refine always runs over whatever reactions were produced.

+        let reactions: UIReaction[] | null = null;

-            if (result?.stopDefault) return this.refine(type, event, result.reactions ?? []);

+                reactions = result.reactions ?? [];

-        const replace = this.replaceHooks.get(type);

-        const reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);

+            const replace = this.replaceHooks.get(type);

+            reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);

+        // Reconcile derived nodes (Stehers) BEFORE refine, so the markers decorate the final scene.

+        reactions = this.reconcile(reactions);

+    // After a mutating interaction, registered modules reconcile their derived nodes: the returned diff

+    // (create/remove/swap of real Stehers) is appended to the SAME undo group and the scene re-rendered.

+    private reconcile(reactions: UIReaction[]): UIReaction[] {

+        if (!this.mutated) return reactions;

+        this.replay(); // ensure this.groups reflects the events the handler just recorded

+        for (const m of this.modules) {

+            const r = m.reconcile?.(this.groups);

+            if (r?.length) diff.push(...r);

+        if (!diff.length) return reactions;

+        this.eventStore.appendToLastGroup(diff);

+        return this.withScene(reactions, this.replay());

+    private withScene(reactions: UIReaction[], scene: any): UIReaction[] {

+        const idx = reactions.findIndex((r) => r.type === "sendCommand" && r.cmd === "setScene");

+        out[idx] = { type: "sendCommand", cmd: "setScene", args: [scene] };

private refine(type: string, event: any, reactions: UIReaction[]): UIReaction[] {

for (const f of this.refineHooks.get(type) ?? []) {


Jetzt der Kern: `derive` → `reconcile` im GEKO-Modul (Ziel-Steher pro Junction, gegen die realen Steher gediffed, create/remove). Ich lese die aktuelle Methode.


100	    // a derived Steher at each rule PMI, honouring per-slot overrides. Applies create+merge directly to
101	    // the model (ephemeral, never stored) so docking/materialisation see the Stehers and they stay
102	    // coupled to their anchor group (a group-move on the base carries them along).
103	    derive(model: GroupProcessor): void {
104	        // Opt-in diagnostics: `window.__cpqSteher = true` in the console prints why derivation did or
105	        // didn't produce a Steher (rules/resolver present, anchors found, occupancy, resolver result).
106	        const dbg = (msg: string) => {
107	            if ((globalThis as any).__cpqSteher) console.log("[steher]", msg);
108	        };
109	        this.steherOrigin.clear();
110	        if (!this.steherRules.length || !this.resolveSteherPart) {
111	            dbg(`inert — rules=${this.steherRules.length} resolver=${!!this.resolveSteherPart}`);
112	            return;
113	        }
114	        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
115	        for (const rule of this.steherRules) {
116	            const anchors = model.elementsOfClass(rule.anchorClass);
117	            dbg(`rule ${rule.anchorClass}: ${anchors.length} anchor(s)`);
118	            for (const anchor of anchors) {
119	                const anchorGroup = model.groupIdOfElement(anchor);
120	                if (!anchorGroup) {
121	                    dbg(`anchor ${anchor}: no group`);
122	                    continue;
123	                }
124	                // The ends where THIS fence is the join (`to`) side of an edge. At such an end the shared
125	                // Steher is healed from the OTHER fence's CS_OUT (the edge's `from`), so this fence must
126	                // not heal its own CS_OUT there — that is what produced two Stehers side by side.
127	                const conns = model.connectionsOf(anchor);
128	                const joinEnds = new Set(
129	                    conns.filter((c) => !c.isFrom).map((c) => sideOfPmi(c.pmi)).filter((s): s is string => !!s),
130	                );
131	                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
132	                    if (joinEnds.has(sideOfPmi(anchorPmi) ?? "")) {
133	                        dbg(`anchor ${anchor} ${anchorPmi}: join-side of a junction — healed from the anchor fence`);
134	                        continue;
135	                    }
136	                    const junctionConns = conns.filter((c) => c.pmi === anchorPmi);
137	                    const slotId = `steher@${anchor}:${anchorPmi}`;
138	                    // Bauform via the GEKO-76 matrix: side = the fence end the Steher heals from; occupied
139	                    // = the branch directions of the other fences at this junction. TODO: derive the angle
140	                    // (0/90/180) from each conn's rot — pending visual verification; empty for now, so the
141	                    // Bauform is the single-fence end form until the angle mapping is confirmed.
142	                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
143	                    const occupied: SteherAngle[] = [];
144	                    const bauform = steherBauform(side, occupied);
145	                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
146	                    if (!part) {
147	                        dbg(`slot ${slotId}: resolver returned null`);
148	                        continue;
149	                    }
150	                    dbg(`slot ${slotId}: healing Steher (${junctionConns.length ? "shared junction" : "free end"}, ${bauform})`);
151	                    // Deterministic ids keyed on the slot → stable across replays. Apply the create first so
152	                    // the Steher's PMI frames are in the model before dockRotation reads them, then merge it
153	                    // onto the anchor point (ephemeral → may sit on an occupied junction point).
154	                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
155	                    model.applyEvents([createEv]);
156	                    model.applyEvents([
157	                        {
158	                            type: "group-merge",
159	                            timestamp: 0,
160	                            targetGroupId: anchorGroup,
161	                            movingGroupId: createEv.groupId,
162	                            anchorRootId: anchor,
163	                            joinRootId: createEv.rootHandleId,
164	                            anchorPmiId: anchorPmi,
165	                            joinPmiId: joinPmi,
166	                            rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),
167	                            ephemeral: true,
168	                        } as GroupEvent,
169	                    ]);
170	                    // Remember where this Steher heals from, so a böppel click clones the anchor fence.
171	                    this.steherOrigin.set(createEv.rootHandleId, { fence: anchor, anchorPmi, joinPmi });

    // 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`);
                    continue;
                }
                // 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}`;
                    // 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 (${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).
                    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 });


171	                    this.steherOrigin.set(createEv.rootHandleId, { fence: anchor, anchorPmi, joinPmi });
172	                }
173	            }
174	        }
175	    }
176
177	    // Record a manual override for a Steher slot. `event.payload` is geko-defined (the module does not
178	    // interpret it — the resolver does); null/absent clears the override back to the derived default.

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

    // Record a manual override for a Steher slot. `event.payload` is geko-defined (the module does not
    // interpret it — the resolver does); null/absent clears the override back to the derived default.


Ich ergänze den Slot-Tag-Konstanten und ersetze `derive` durch `reconcile` (Diff aus echten Steher).


const STEHER_MODULE_ID = "geko-steher";
// Tag on a real Steher's root carrying its slot id, so the reconciler can diff existing against target.
const STEHER_SLOT_TAG = "steher-slot:";


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 STEHER_MODULE_ID = "geko-steher";
// Tag on a real Steher's root carrying its slot id, so the reconciler can diff existing against target.
const STEHER_SLOT_TAG = "steher-slot:";

export type SteherPartResolver = (ctx: SteherContext) => any | null;

+// Tag on a real Steher's root carrying its slot id, so the reconciler can diff existing against target.

+const STEHER_SLOT_TAG = "steher-slot:";

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


    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`);
                    continue;
                }
                // 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}`;
                    // 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 (${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).
                    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 });
                }
            }
        }
    }

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

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

        for (const rule of this.steherRules) {
            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;
                // Fence-fence connections only (edges to Stehers are ignored) for junction detection.
                const conns = model.connectionsOf(anchor).filter((c) => !steherRoots.has(c.other));
                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)) {
                    // Join-side of a junction → the shared Steher is healed from the anchor fence's CS_OUT.
                    if (joinEnds.has(sideOfPmi(anchorPmi) ?? "")) continue;
                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    target.add(slotId);
                    const known = existing.get(slotId);
                    if (known) {
                        this.steherOrigin.set(known, { fence: anchor, anchorPmi, joinPmi });
                        continue; // already present — leave it
                    }
                    // Bauform via the GEKO-76 matrix. TODO: fill `occupied` from the junction conns' rot.
                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
                    const occupied: SteherAngle[] = [];
                    const bauform = steherBauform(side, occupied);
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
                    if (!part) continue;
                    // Real (stored) create, tagged with its slot so the next reconcile finds it. Apply
                    // create then merge to the working model so dockRotation reads the Steher's frames and
                    // the next junction sees the occupancy; the events go into the diff the core persists.
                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId, false);
                    const rootH = createEv.handles.find((h: any) => h.id === createEv.rootHandleId);
                    if (rootH) rootH.tags = [...(rootH.tags ?? []), `${STEHER_SLOT_TAG}${slotId}`];
                    model.applyEvents([createEv]);
                    const mergeEv: GroupEvent = {
                        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, // may dock onto the occupied junction point
                    };
                    model.applyEvents([mergeEv]);
                    this.steherOrigin.set(createEv.rootHandleId, { fence: anchor, anchorPmi, joinPmi });
                    diff.push(createEv, mergeEv);
                    dbg(`created Steher ${slotId} (${bauform})`);
                }
            }
        }
        // Remove Stehers whose junction is gone (fence deleted / disconnected).
        for (const [slotId, root] of existing) {
            if (!target.has(slotId)) {
                diff.push({ type: "group-remove", timestamp: 0, elementId: root } as GroupEvent);
                dbg(`removed Steher ${slotId}`);
            }
        }
        return diff;
    }


    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`);
                    continue;
                }
                // 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}`;
                    // 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 (${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).
                    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 });
                }
            }
        }
    }

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

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

        for (const rule of this.steherRules) {
            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;
                // Fence-fence connections only (edges to Stehers are ignored) for junction detection.
                const conns = model.connectionsOf(anchor).filter((c) => !steherRoots.has(c.other));
                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)) {
                    // Join-side of a junction → the shared Steher is healed from the anchor fence's CS_OUT.
                    if (joinEnds.has(sideOfPmi(anchorPmi) ?? "")) continue;
                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    target.add(slotId);
                    const known = existing.get(slotId);
                    if (known) {
                        this.steherOrigin.set(known, { fence: anchor, anchorPmi, joinPmi });
                        continue; // already present — leave it
                    }
                    // Bauform via the GEKO-76 matrix. TODO: fill `occupied` from the junction conns' rot.
                    const side: AnbauSide = anchorPmi.includes("_L") ? "L" : "R";
                    const occupied: SteherAngle[] = [];
                    const bauform = steherBauform(side, occupied);
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId), side, occupied, bauform });
                    if (!part) continue;
                    // Real (stored) create, tagged with its slot so the next reconcile finds it. Apply
                    // create then merge to the working model so dockRotation reads the Steher's frames and
                    // the next junction sees the occupancy; the events go into the diff the core persists.
                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId, false);
                    const rootH = createEv.handles.find((h: any) => h.id === createEv.rootHandleId);
                    if (rootH) rootH.tags = [...(rootH.tags ?? []), `${STEHER_SLOT_TAG}${slotId}`];
                    model.applyEvents([createEv]);
                    const mergeEv: GroupEvent = {
                        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, // may dock onto the occupied junction point
                    };
                    model.applyEvents([mergeEv]);
                    this.steherOrigin.set(createEv.rootHandleId, { fence: anchor, anchorPmi, joinPmi });
                    diff.push(createEv, mergeEv);
                    dbg(`created Steher ${slotId} (${bauform})`);
                }
            }
        }
        // Remove Stehers whose junction is gone (fence deleted / disconnected).
        for (const [slotId, root] of existing) {
            if (!target.has(slotId)) {
                diff.push({ type: "group-remove", timestamp: 0, elementId: root } as GroupEvent);
                dbg(`removed Steher ${slotId}`);
            }
        }
        return diff;
    }

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

+    reconcile(model: GroupProcessor): GroupEvent[] {

-        if (!this.steherRules.length || !this.resolveSteherPart) {

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

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

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

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

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

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

-            const anchors = model.elementsOfClass(rule.anchorClass);

-            dbg(`rule ${rule.anchorClass}: ${anchors.length} anchor(s)`);

-            for (const anchor of anchors) {

+            for (const anchor of model.elementsOfClass(rule.anchorClass)) {

-                    dbg(`anchor ${anchor}: no group`);

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

+                if (!anchorGroup) continue;

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

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

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

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

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

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

+                    target.add(slotId);

+                    const known = existing.get(slotId);

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

+                        continue; // already present — leave it

+                    // Bauform via the GEKO-76 matrix. TODO: fill `occupied` from the junction conns' rot.

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

-                    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).

-                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);

+                    if (!part) continue;

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

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

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

+                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId, false);

+                    const rootH = createEv.handles.find((h: any) => h.id === createEv.rootHandleId);

+                    if (rootH) rootH.tags = [...(rootH.tags ?? []), `${STEHER_SLOT_TAG}${slotId}`];

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

+                    const mergeEv: GroupEvent = {

+                        type: "group-merge",

+                        targetGroupId: anchorGroup,

+                        movingGroupId: createEv.groupId,

+                        anchorRootId: anchor,

+                        joinRootId: createEv.rootHandleId,

+                        anchorPmiId: anchorPmi,

+                        joinPmiId: joinPmi,

+                        rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),

+                        ephemeral: true, // may dock onto the occupied junction point

+                    model.applyEvents([mergeEv]);

+                    diff.push(createEv, mergeEv);

+                    dbg(`created Steher ${slotId} (${bauform})`);

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

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

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

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

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


Bash

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

Run Steher tests after reconcile switch


 ❯ src/configurator/modules/GekoSteher.test.ts (9 tests | 6 failed) 59ms
     × re-heals on move-end (hook runs on every replay, incl. move) 8ms
     × full move path (prepare-action + move-end) keeps exactly one Steher 7ms
     × böppel on a healed Steher clones the anchor fence, not the Steher 8ms
     × a junction carries exactly ONE shared Steher, not two side by side (both-side rule) 11ms
     × a suppress override removes the Steher and is undo-able 5ms
     × clearing the override brings the Steher back with the SAME id (deterministic across replays) 9ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected [ …(3) ] to have a length of 2 but got 3
- Expected
+ Received
     73|         expect(parts(moved)).toHaveLength(2); // Steher still there af…
AssertionError: expected [ …(3) ] to have a length of 2 but got 3
- Expected
+ Received
     93|         expect(parts(moved)).toHaveLength(2); // fence + exactly one S…
AssertionError: expected [ …(3) ] to have a length of 2 but got 3
- Expected
+ Received
AssertionError: expected [ …(6) ] to have a length of 3 but got 6


Sechs Tests brechen — echte Steher akkumulieren, das Existing-Matching im Reconcile greift nicht. Ich debugge gezielt.


cat > /tmp/steher-dbg.test.ts <<'EOF'
import { describe, it } from "vitest";
import { DefaultCommandProcessor } from "./packages/frontend/src/configurator/events/DefaultCommandProcessor";
EOF
echo "skip"; grep -n "elementsTagged\|steher-slot" packages/frontend/src/configurator/model/GroupProcessor.ts | head

Check elementsTagged


skip
443:    // the prefix). Used by the reconciler to find its existing Stehers (tag `steher-slot:<slotId>`) and
445:    elementsTagged(prefix: string): Array<{ root: string; value: string }> {


Ich füge temporär eine Debug-Ausgabe in `reconcile` ein und lasse den Move-Test laufen.


Edit

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

        const existing = new Map(model.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.value, e.root] as const));
        console.error("[DBG] existing", existing.size, [...existing.keys()]);


        const existing = new Map(model.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.value, e.root] as const));
        console.error("[DBG] existing", existing.size, [...existing.keys()]);

const overrides = model.moduleStateOf(STEHER_MODULE_ID);

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

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

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


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteher.test.ts -t "re-heals on move" 2>&1 | grep -E 'DBG|Tests|×|✓' | head

Run move test with debug


[DBG] existing 0 []
[DBG] existing 1 [ 'steher@h-10df5902-83bd-41c3-8ae8-6f5c2e30f4aa:CS_OUT_R_0' ]
     × re-heals on move-end (hook runs on every replay, incl. move) 10ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
      Tests  1 failed | 8 skipped (9)


Gefunden: Im Test ist der „Steher" ein Klon des Zauns → **gleiche Klasse** → `elementsOfClass` liefert ihn als Anker → Reconcile heilt einen Steher auf den Steher (Rekursion). Fix: Steher aus der Anker-Menge ausschließen. Debug raus, Fix rein.


Edit

        const existing = new Map(model.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.value, e.root] as const));
        console.error("[DBG] existing", existing.size, [...existing.keys()]);

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


        const existing = new Map(model.elementsTagged(STEHER_SLOT_TAG).map((e) => [e.value, e.root] as const));
        console.error("[DBG] existing", existing.size, [...existing.keys()]);

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


            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;

            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                if (steherRoots.has(anchor)) continue; // a Steher is never an anchor (guards recursion)
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;


            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;

            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                if (steherRoots.has(anchor)) continue; // a Steher is never an anchor (guards recursion)
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;

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

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


Bash

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

Re-run Steher tests after recursion fix


 ❯ src/configurator/modules/GekoSteher.test.ts (9 tests | 2 failed) 45ms
     × a junction carries exactly ONE shared Steher, not two side by side (both-side rule) 11ms
     × a suppress override removes the Steher and is undo-able 3ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
- Expected
+ Received
- Expected
+ Received
    146|         expect(parts(suppressed)).toHaveLength(1); // Steher gone, anc…
    148|         expect(parts(undone)).toHaveLength(2); // override reverted → …
 Test Files  1 failed (1)
      Tests  2 failed | 7 passed (9)
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command failed with exit code 1: vitest run src/configurator/modules/GekoSteher.test.ts


Zwei übrig. Der Suppress-Bug: `target.add` läuft vor dem Resolver, also bleibt der Steher bei Suppress erhalten. Ich ziehe den Resolver-Check vor `target.add`.