AskUserQuestion

Question: Wo läuft die Grenze zwischen GEKO-Modul und CPQ-Core beim Placement?

Choices:
Snap-Merge, Detach, Drag/Drop-Wiring, Marker-Anbau, dockingRules und dockRotation komplett ins GEKO-Modul. CPQ-Core behält nur Selektion/Move/Rotate/Add/Undo. Klarste Trennung, Core kennt kein PMI-Docking mehr.
Nur die geometrische Dreipunkt-Rotation (dockRotation) und der Marker-Anbau wandern aus. Generischer PMI-Merge (group-merge, detach, Drag/Drop) bleibt als CPQ-Core-Mechanismus, das Modul steuert nur Orientierung und UX.


Your questions have been answered: "Wo läuft die Grenze zwischen GEKO-Modul und CPQ-Core beim Placement?"="Teil der Frage - Im Idealfall bleibt alles Logik, die nicht Produktspezifisch in cpq und kann dort vom zweiten Kunden verwendet werden.
Das sind für: Element-Selektion / Einbua uf PMIs / Do und Undo. Grouping Logik und Einbaulogik der Marker muss - denke ich - aber ganz nach Gec ko ". You can now continue with these answers in mind.

Question: Wo läuft die Grenze zwischen GEKO-Modul und CPQ-Core beim Placement?

Choices:
Snap-Merge, Detach, Drag/Drop-Wiring, Marker-Anbau, dockingRules und dockRotation komplett ins GEKO-Modul. CPQ-Core behält nur Selektion/Move/Rotate/Add/Undo. Klarste Trennung, Core kennt kein PMI-Docking mehr.
Nur die geometrische Dreipunkt-Rotation (dockRotation) und der Marker-Anbau wandern aus. Generischer PMI-Merge (group-merge, detach, Drag/Drop) bleibt als CPQ-Core-Mechanismus, das Modul steuert nur Orientierung und UX.

Teil der Frage - Im Idealfall bleibt alles Logik, die nicht Produktspezifisch in cpq und kann dort vom zweiten Kunden verwendet werden.
Das sind für: Element-Selektion / Einbua uf PMIs / Do und Undo. Grouping Logik und Einbaulogik der Marker muss - denke ich - aber ganz nach Gec ko


Deine Antwort ist da teilweise — und sie stößt an eine Abhängigkeit im Code, die ich vor dem Schneiden klären muss.

Der `GroupProcessor` ist geteilt: Selektion, Move/Rotate und Undo (die in CPQ bleiben sollen) bauen alle darauf auf — `replay()`, `buildRegroupEvent`, `selectableAncestors`, `group-move/-rotate`. „Grouping-Logik ganz nach GEKO" geht deshalb nicht, ohne Move/Rotate/Selektion mitzunehmen.

Ich lese deine Antwort so:

**CPQ-Core (kundenneutral, zweiter Kunde nutzbar):** Element-Selektion, der generische Gruppen-Mechanismus (Gruppen bilden, `replay`, move/rotate, Escalation), reiner PMI-Einbau (`group-merge` als Verbindung zweier PMIs), Do/Undo.

**GEKO-Modul:** die *Verbau-spezifische* Grouping-Logik obendrauf — Dreipunkt-Orientierung (`dockRotation`), Junction-Regeln (`occupiedPmis`/`blockedPmis`, 90°/270°-Steher), Detach-für-Placement, Docking-Rules-Wiring — plus der komplette Marker-Anbau (`withSelectionMarkers`, `handleMarkerBuild`, `handleKeyboardBuild`).

Der Knackpunkt ist `group-merge` selbst: die Verbindung zweier PMIs ist der reine Einbau (Core), aber die *Orientierung* dieser Verbindung (`dockRotation`, Junction-Sperren) ist GEKO. Der Merge-Event bleibt also Core-Mechanismus, seine Berechnung kommt vom Modul.


Question: Trifft diese verfeinerte Grenze deine Absicht? group-merge (PMI-Verbindung) bleibt Core, seine Orientierung/Junction-Regeln + Marker-Anbau kommen vom GEKO-Modul.

Choices:
Generischer Gruppen-Mechanismus + PMI-Merge-Event bleiben Core; dockRotation, occupied/blockedPmis-Junction-Regeln, Detach-Placement, Docking-Wiring und kompletter Marker-Anbau gehen ins GEKO-Modul. Core kennt keine Verbau-Orientierung mehr.
Der gesamte GroupProcessor inkl. move/rotate/replay wandert nach GEKO; CPQ-Core behält nur Selektion, roher PMI-Merge und Do/Undo ohne Gruppen. Größerer Schnitt, Core wird sehr dünn.


Your questions have been answered: "Trifft diese verfeinerte Grenze deine Absicht? group-merge (PMI-Verbindung) bleibt Core, seine Orientierung/Junction-Regeln + Marker-Anbau kommen vom GEKO-Modul."="Das ist genau, was ich sagen wollte - Gecko braucht für die DrepunktAnbau einen EIGENE Gruppen Verarbeitung
dsas kann core nicht leisten --> Wir müssen Gecko eben den KOntext geben, dass es das leisten kann". You can now continue with these answers in mind.

Question: Trifft diese verfeinerte Grenze deine Absicht? group-merge (PMI-Verbindung) bleibt Core, seine Orientierung/Junction-Regeln + Marker-Anbau kommen vom GEKO-Modul.

Choices:
Generischer Gruppen-Mechanismus + PMI-Merge-Event bleiben Core; dockRotation, occupied/blockedPmis-Junction-Regeln, Detach-Placement, Docking-Wiring und kompletter Marker-Anbau gehen ins GEKO-Modul. Core kennt keine Verbau-Orientierung mehr.
Der gesamte GroupProcessor inkl. move/rotate/replay wandert nach GEKO; CPQ-Core behält nur Selektion, roher PMI-Merge und Do/Undo ohne Gruppen. Größerer Schnitt, Core wird sehr dünn.

Das ist genau, was ich sagen wollte - Gecko braucht für die DrepunktAnbau einen EIGENE Gruppen Verarbeitung
dsas kann core nicht leisten --> Wir müssen Gecko eben den KOntext geben, dass es das leisten kann


Gut - eine Änderung nach dem Anbau soll das neu angebaute Teil selektiert werden


Grenze steht: Option 1, und GEKO bringt seine **eigene** Gruppen-Verarbeitung für Dreipunkt/Anbau mit; Core liefert nur den Kontext, damit das Modul das leisten kann.

Konkreter Umsetzungsplan:

**Neue Dateien**
- `configurator/events/PlacementModule.ts` — Interface `PlacementModule` (Hooks: `handleEvent`, `interceptSelectionClick`, `decorateScene`, `contextEntries`, jeweils `null` = nicht zuständig) + Typ `CommandContext`.
- `configurator/modules/GekoPlacementModule.ts` — die ausgelagerte GEKO-Logik samt **eigenem** Docking-Prozessor (`GekoDockingProcessor`): `dockRotation`, `occupiedPmis`/`blockedPmis` (Junction-/Steher-Regeln), sowie `handlePlacementEnd`/`placementEvent`, `handleDetachElement`-Aufruf, `placementDragPoints`, `classOf`, `handleKeyboardBuild`, `handleMarkerBuild`, `withSelectionMarkers`, Marker-Abfang, Placement/Detach-Context-Einträge. Hält `dockingRules`.

**`CommandContext`** (Core reicht dem Modul durch, „damit GEKO es leisten kann"):
- Aufzeichnen: `record(event)`, `recordGroup(events[])` (kapselt `eventStore.push/pushGroup`)
- Gruppen-Lesezugriff: `elementRootOf`, `groupIdOfElement`, `groupKeyOf`, `groupByKey`, `membersOfKey`, `sceneOfElement`, `transformOf`, `buildCreateEvent`, `buildDetachEvent`, `elementsOfClass`, `classOfGroup` + PMI-Frame-Zugriff (`pmiLocal`/`pmiRotation`) — damit das Modul `dockRotation` selbst rechnet
- `replay()` und Selektion get/set

**`DefaultCommandProcessor.ts` (Edit)**
- `register(module: PlacementModule)`, interne Modul-Liste, `dockingRules`-Ctor-Arg entfällt.
- `handleEvent`: unbekannte/GEKO-Events (`interaction-mode`→placement, `detach-element`, `keyboard-build`) zuerst den Modulen anbieten, sonst Core-`switch`.
- `handleSelectionClickEvent`: `module.interceptSelectionClick` vor der Default-Selektion.
- `selectionReactions`: Szene durch `module.decorateScene` (Marker) statt festes `withSelectionMarkers`.
- `getContextForSelection`: `module.contextEntries` anhängen.
- Extrahierte Methoden raus.

**`GroupProcessor.ts` (Edit)**
- `dockRotation`, `pmiRotation`, `pmiLocal`, `occupiedPmis`, `blockedPmis` wandern auf die GEKO-Seite; Core behält den generischen Mechanismus (`replay`, `buildCreateEvent`, `group-move/-rotate`, `apply`/`group-merge` inkl. `computeLayout`, `buildRegroupEvent`, `buildDetachEvent`, `selectableAncestors`). Der `rot`-Wert steckt bereits im Merge-Event → `computeLayout` bleibt Core-tauglich. Vor dem Schnitt prüfe ich, ob ein Core-Pfad `occupied/blockedPmis` nutzt.

**Neue Anforderung:** `handleMarkerBuild` selektiert nach dem Anbau das neue Teil (`selection = [{ pickedId: createEvent.rootHandleId, level: 0 }]` statt `[]`), Marker rendern auf dem neuen Teil neu.

**Storybook**
- `Fire3d.stories.ts`: `new DefaultCommandProcessor()` ohne Modul → reines CPQ (Selektion/Move/Rotate/Add).
- `ProductConfigurator` (Story + `.vue`): statt `dockingRules` in den Ctor → `p.register(new GekoPlacementModule(dockingRules))` → volle Dreipunkt+Anbau-Kette.

Auf Go setze ich das um.


NEIN
das ist zu viel. das doppelst du Logik


Verstanden — kein zweiter Prozessor, keine verschobene Mathematik. Der `GroupProcessor` bleibt unangetastet an einem Ort; das Modul nutzt ihn über den Kontext.

Korrigierter, schlanker Plan:

**Neue Dateien**
- `configurator/events/PlacementModule.ts` — Interface + `CommandContext`.
- `configurator/modules/GekoPlacementModule.ts` — nur die Command-Glue: `handlePlacementEnd`/`placementEvent`, `handleDetachElement`, `placementDragPoints`, `classOf`, `handleKeyboardBuild`, `handleMarkerBuild`, `withSelectionMarkers`, Marker-Abfang, Placement/Detach-Context-Einträge. Hält `dockingRules`.

**`GroupProcessor.ts`** — unverändert. `dockRotation`, `occupiedPmis`, `blockedPmis` bleiben; das Modul ruft sie über `ctx.groups` auf. Keine Logik-Dopplung.

**`CommandContext`** — reicht dem Modul den vorhandenen `groups` (GroupProcessor), `eventStore` (als `record`/`recordGroup`), `replay()` und Selektion get/set durch.

**`DefaultCommandProcessor.ts` (Edit)**
- `register(module)` + Modul-Liste, `dockingRules`-Ctor-Arg entfällt.
- Seams: `handleEvent`


[Request interrupted by user]


Du denkst gerade falsch

Gecko muss sich für das selktion event registrieren, richtig?
Frage 1 - ERSETZT Gecko die default Selection oder ergänzt es die Selection (mur Nachbearbeitung) oder Sollt register beides erluaben (registerReplace / registerRefine / regsiterPrepare)
Erst mal das
wie denkst du?


GEKO registriert sich für Selektion — ja. Aber es braucht zwei verschiedene Eingriffe, und keiner davon ist ein echtes *Replace*:

**1. Prepare (vor der Default-Selektion, mit Veto).** Der Marker-Abfang: Klick auf `sel-marker-*` → Anbau statt Selektion. Das läuft *vor* der Default-Logik und bricht sie ab (short-circuit). Ein Prepare-Hook, der optional Reaktionen zurückgibt und damit die Default-Selektion unterdrückt, deckt das exakt.

**2. Refine (nach der Default-Selektion, Nachbearbeitung).** `withSelectionMarkers`: Default-Selektion läuft normal, danach hängt GEKO die Marker-Kugeln an die Szene. Reine Ergänzung des Ergebnisses.

**Replace braucht GEKO nicht.** Die Selektions-*Auflösung* selbst — Escalation (element→group), additiv/toggle — ist kundenneutral und bleibt Core. Würde GEKO die Selektion ganz ersetzen, müsste es genau diese Escalation/Additiv-Logik nachbauen — das ist die Dopplung, die du gerade abgelehnt hast.

Also: **Prepare + Refine, kein Replace.** Der Marker-Fall ist ein Prepare-mit-Veto, kein Ersetzen des Algorithmus.

Ein `registerReplace` würde ich weglassen, solange kein GEKO-Verhalten die Default-Selektion komplett verwerfen muss — sonst baust du eine Erweiterungsstelle für einen Fall, den es nicht gibt.


Du denkst gerade falsch  Gecko muss sich für das selktion event registrieren, richtig? Frage 1 - ERSETZT Gecko die default Selection oder ergänzt es die Selection (mur Nachbearbeitung) oder Sollt regi…


DU SOLLST AUS EINER ALLGEMEINEN PERSPEKTIVE DEN REGISTRIERUNGS MECHANISMUS BETRACHTEN


ok - passt
Sorry - registerReplace aber bitte vorsehen
Das hißt im Schritt 1 ermöglichen wir für events (selction / click / doubleClioch) eine Registrierung für prepare mit Stoppropagation / stopDefault?. ein replafce, das Gecko nicht braucht und ein refine, dass dann die Marker anzeigt
passt?


Passt. Also `register` mit drei Phasen pro Event-Typ (`selection-click`, `click`, `double-click`, …):

- **prepare(event, ctx)** — läuft vor der Default-Verarbeitung, kann `stopDefault`/`stopPropagation` signalisieren und eigene Reaktionen liefern (Marker-Abfang → Anbau).
- **replace(event, ctx)** — ersetzt die Default-Verarbeitung komplett (GEKO nutzt es nicht, ist vorgesehen).
- **refine(event, ctx, reactions)** — läuft nach der Verarbeitung, bearbeitet Ergebnis/Szene nach (Marker anzeigen).

Kontrollfluss pro Event:
1. `prepare` der registrierten Module. Signalisiert eines `stopDefault` → Default und `replace` entfallen, dessen Reaktionen gelten.
2. sonst: registriertes `replace` falls vorhanden, sonst Core-Default.
3. `refine` der Module über die entstandenen Reaktionen/Szene.

Ein Punkt, den ich beim Bauen festlege: was passiert, wenn zwei Module dasselbe Event `replace`-en — ich lasse genau ein `replace` pro Event-Typ zu und werfe bei Kollision hart (kein stilles Überschreiben). `prepare`/`refine` dürfen mehrere Module haben, in Registrierungsreihenfolge verkettet.

Auf Go baue ich Schritt 1 — das Registrierungs-/Hook-Gerüst im `DefaultCommandProcessor` samt `PlacementModule`-Interface und `CommandContext`, noch ohne GEKO-Logik zu verschieben.


NICHT BAUEN. in RUHE weiter
Zweites replace hart ablehenen. error loggen
Die Verarbeitung würde im prepare Handler mit SoptDefault negtoßen?


Zweites `replace`: harte Ablehnung mit Error-Log — festgehalten.

Zu deiner Frage: ja. Im Anbau-Fall macht der `prepare`-Handler die Arbeit selbst — Marker-Target erkennen, Kopie bauen, Merge-Event aufzeichnen, Selektion auf das neue Teil setzen — und gibt seine Reaktionen zusammen mit `stopDefault` zurück. Die Default-Selektion entfällt dann komplett.

`prepare` ist also nicht nur ein Veto, sondern der ausführende Zweig: „stoppe Default *und* hier ist stattdessen mein Ergebnis". Der Marker-Anbau *ist* die Alternativaktion, daher gehört sie in denselben Handler, der das Veto setzt.

Danach läuft `refine` trotzdem — auch im stopDefault-Pfad — und legt die Marker auf das jetzt selektierte neue Teil. Das ist der Grund, warum „Select nach Anbau" (prepare setzt die Selektion) und „Marker rendern" (refine) sauber getrennt bleiben: der prepare-Handler entscheidet *was* selektiert ist, refine entscheidet *wie* die Selektion in der Szene aussieht.

Ein Detail, das daraus folgt und das ich beim Bauen berücksichtige: `refine` bekommt die vom prepare/Default erzeugten Reaktionen und muss die `setScene`-Reaktion darin finden, um deren Szene mit Markern anzureichern — refine arbeitet auf dem Reaktions-Ergebnis, nicht auf einer nackten Szene.


das ist jetzt genau die Frage
WIE baut Gecko die Teile ein?
Was meinst Du mit - gibt reachtions zurück?


Zwei Sachen — erst was „Reactions" sind, dann *wie* der Einbau daraus entsteht.

**Was „Reactions" sind**

Der `CommandProcessor` fasst die Fire-Engine nie direkt an. Er ist eine reine Funktion: Event rein → Liste `UIReaction` raus. Eine Reaction ist eine deklarative Anweisung an den Host (die Vue-/Fire3D-Schicht), z. B.:

```ts
{ type: "sendCommand", cmd: "setScene", args: [scene] }
{ type: "sendCommand", cmd: "set_selection", args: [selectors] }
{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }
```

Der Host nimmt diese Liste und führt sie gegen den Fire-Viewer aus. „Gibt Reactions zurück" heißt also nur: der Handler liefert diese Anweisungsliste — er baut nicht selbst im 3D.

**Wie GEKO einbaut**

Der Einbau ist event-sourced, kein direkter Engine-Aufruf. Genau das, was heute `handleMarkerBuild` (Z. 193–225) macht:

1. `replay()` — internes Modell aus dem EventStore neu aufbauen.
2. `buildCreateEvent(scene, …)` — eine Kopie des Teils als neue Gruppe erzeugen, **Event in den EventStore schreiben**.
3. Ein `group-merge`-Event schreiben (anchorPmi/joinPmi + `dockRotation`) — das ist der eigentliche Verbau.
4. Selektion auf die Kopie setzen.
5. `replay()` — der `GroupProcessor` rechnet aus den Events die Geometrie und liefert die **komplette neue Szene**.
6. Reactions zurückgeben: `setScene(scene)` + `set_selection(...)`.

Der Kern: GEKO „baut" nichts im Viewer. Es **hängt zwei Domänen-Events an den EventStore** (create + merge), lässt `replay()` daraus die Szene berechnen und gibt diese Szene als `setScene`-Reaction zurück. Fire rendert dann neu.

Deshalb braucht das Modul im `CommandContext` genau: `record()`/`recordGroup()` (Events schreiben), Lesezugriff auf `groups` (für `buildCreateEvent`, `dockRotation`) und `replay()`. Mehr nicht — der Einbau *ist* „Events schreiben und neu replayen".


was bekommt ee alst KJontext?


Genau die Lese-/Schreibpunkte, die die verschobenen Methoden heute anfassen — nicht mehr. Aus dem Code (`handleMarkerBuild`, `handleKeyboardBuild`, `handlePlacementEnd`, `handleDetachElement`, `placementDragPoints`, `withSelectionMarkers`) fällt zusammen:

```ts
interface CommandContext {
  // Der vorhandene GroupProcessor als Ganzes — Lese- und Bau-Queries.
  // Deckt: elementRootOf, groupIdOfElement, groupByKey, groupKeyOf, classOfGroup,
  // membersOfKey, elementsOfClass, sceneOfElement, transformOf, selectableAncestors,
  // roleOfId, buildCreateEvent, buildDetachEvent, dockRotation, occupiedPmis, blockedPmis.
  groups: GroupProcessor;

  // Schreibzugriff auf den EventStore — nur anhängen, kein undo/redo/init.
  record(event: GroupEvent): void;        // ein Event
  recordGroup(events: GroupEvent[]): void; // mehrere als ein Undo-Schritt

  // Modell neu rechnen → Szene.
  replay(): Scene;

  // Selektion lesen/setzen (für „Select nach Anbau").
  getSelection(): Pick[];
  setSelection(picks: Pick[]): void;
}
```

Der Punkt: `groups` wird **als Ganzes** durchgereicht, nicht als 16 einzelne Kontext-Methoden. Der `GroupProcessor` ist schon das gemeinsame Query-Objekt — ihn zu übergeben vermeidet, jede Methode nochmal im Kontext zu spiegeln (das wäre die Dopplung, die du abgelehnt hast). Das Modul ruft `ctx.groups.dockRotation(...)`, `ctx.groups.buildCreateEvent(...)` usw. direkt.

Vom EventStore bekommt das Modul bewusst nur `record`/`recordGroup` — `undo`, `redo`, `setInitialState`, `canUndo` bleiben Core, damit das Do/Undo (kundenneutral) nicht ins Modul leckt.

Reine Utilities (`extractPlacementData`, `IDENTITY_QUAT`, `dockingRules`) laufen nicht über den Kontext — die importiert das Modul direkt bzw. hält sie selbst.


ok
--> dan werden die commands ausgeführt und was bekommt refine als KOntext?


Kurze Korrektur zur Reihenfolge, weil deine Formulierung sie umdreht: `refine` läuft **vor** der Ausführung, nicht danach.

Ablauf:
1. `prepare` → `replace`/Default erzeugen die Reaction-Liste.
2. `refine` bekommt diese Liste und darf sie umschreiben.
3. **Dann** übergibt der Core die *refinete* Liste an den Host, der die Commands ausführt.

Grund: `withSelectionMarkers` reichert die Szene an, die in der `setScene`-Reaction steckt. Das muss passieren, *bevor* `setScene` an Fire geht — sonst bräuchte man einen zweiten `setScene`-Roundtrip nach der Ausführung.

Was `refine` als Kontext bekommt:

```ts
refine(event, ctx: CommandContext, reactions: UIReaction[]): UIReaction[]
```

- **`ctx`** — derselbe `CommandContext` wie prepare (`groups` für `blockedPmis`, `getSelection()` für die selektierten Roots, `replay()` falls nötig).
- **`reactions`** — die bisher erzeugte Liste. `refine` sucht darin die `setScene`-Reaction, ersetzt deren Szene durch die mit Markern angereicherte, und gibt die Liste zurück.

`refine` braucht also nichts aus der Ausführung — `withSelectionMarkers` kennt alles aus `ctx.groups` + `getSelection()` + der Szene in den Reactions. Deshalb kann es vor der Ausführung laufen, und genau deshalb ist es ein reiner Reactions-Transformator, kein Post-Execution-Callback.

Der einzige Sonderfall: enthält die Liste gar keine `setScene` (z. B. ein Event, das nur `set_selection` liefert), muss `refine` selbst entscheiden, ob es eine `setScene` mit dekorierter Szene ergänzt oder nichts tut — das lege ich beim Bauen fest, wenn ich sehe, welche Selektions-Pfade ohne `setScene` enden.


warum werden die Marker nicht in der Prepare eingebuat?
das ziehst du atomare Logik auseinander


Weil die Marker nicht zum Anbau gehören, sondern zur Selektion. Sie müssen bei *jedem* Selektionswechsel erscheinen — normaler Klick, Escalation, nach Move — nicht nur nach einem Anbau.

Im Code hängt `withSelectionMarkers` heute in `selectionReactions` (Z. 248–256), und das rufen drei Pfade auf:
- `handleSelectionClickEvent` — normaler Klick auf ein Teil (kein prepare, kein Anbau)
- `handleEscalateSelection` — Rechtsklick-Escalation
- `handleMarkerBuild` — der Anbau

Bei „normaler Klick" läuft kein prepare-Handler. Steckten die Marker im prepare-Anbau, hätte der normale Klick keine Marker — oder ich müsste den Marker-Code in jeden Selektionspfad kopieren. *Das* wäre die Dopplung.

Die atomare Einheit ist nicht „Anbau + Marker". Sie ist:
- **prepare:** *wie* ändert sich die Selektion (Anbau baut ein Teil und wählt es).
- **refine:** *wie sieht* die aktuelle Selektion in der Szene aus (Marker drauf) — unabhängig davon, wodurch sie entstanden ist.

Deshalb ist es kein Auseinanderreißen, sondern die richtige Naht: refine bedient auch die Pfade, die gar kein prepare haben. Der Anbau selbst (Kopie bauen, Merge-Event, Selektion setzen) bleibt als *ein* Stück im prepare-Handler — nur das Darstellen der Selektion ist ausgelagert, weil es geteilt ist.


passt
gut
wie fült es sich an - mit blick auf weitere Logik, die kommen wird (z.B. SteherEinbau nach Platzierrung eines Advancede Scutzzaun elements)


Für die *Interaktions*-Logik fühlt es sich tragfähig an: neuer Klick-/Kontextmenü-/Placement-Fall = neues Modul oder neuer Hook, kein Anfassen des Core-`switch`. Prepare/replace/refine deckt „abfangen", „ersetzen", „Ergebnis nachbearbeiten" sauber ab.

Aber dein Steher-Beispiel liegt bewusst *nicht* in diesem Layer — und das ist der ehrliche Prüfstein. „Steher-Einbau nach Platzierung eines Advanced-Elements" ist kein Reaction-Transform auf ein User-Event. Es ist eine **Ableitung aus dem Modell**: sobald das Advanced-Element im Modell steht, *gehört* dort ein Steher hin — unabhängig davon, durch welches Event es hineinkam (Klick, Anbau, Undo/Redo, Move).

Das trifft genau die Trennung aus `cpq-configurator-modul-registry.md`: Ableitung/Heilung nie in den EventStore, nur manuelle Overrides als undo-bare Events. Der Steher darf also **kein** aufgezeichnetes Event sein — sonst:
- liegt bei jeder Platzierung ein Steher-Create im Undo-Stack,
- verschiebst du später das Basiselement, wandert der Steher nicht mit (das Event ist eingefroren),
- Redo/Replay dupliziert.

Er muss bei *jedem* `replay()` neu abgeleitet werden. Das ist ein **zweiter Hook-Typ**, den prepare/replace/refine nicht abbildet: ein *Replay-Derivations-Hook* („nach replay()"), der aus dem Modell Geometrie ableitet, ohne Events zu schreiben — idempotent, jedes Mal neu.

Der Punkt: `withSelectionMarkers` ist schon fast dieser Fall — es hängt abgeleitete Handles an die Szene, statt sie zu speichern. Der Unterschied: Marker sind selektions-getrieben und kosmetisch (→ refine auf Reactions), der Auto-Steher ist modell-getrieben und *strukturell* — er muss im replayten Modell stehen, damit die nächste Platzierung ihn sieht (er belegt PMIs, man baut an ihn an). Deshalb replay, nicht refine.

Fazit zum Gefühl:
- Die drei Command-Phasen sind die richtige *Interaktions*-Hälfte. Die skaliert.
- Der Steher zeigt die *andere* Hälfte: eine Ableitungsstelle im Replay. Beide gehören in dasselbe GEKO-Modul und teilen sich den `CommandContext` (der gibt `groups` + `replay()` schon her).
- Wichtig für den Schnitt jetzt: das `PlacementModule`-Interface **nicht** so eng auf Command-Events ziehen, dass der Replay-Derivations-Hook später keinen Platz hat. Ich sehe ihn als vierte, gleichrangige Fähigkeit des Moduls vor — bauen später, aber die Naht offen lassen.

Kein Code jetzt.


passt gut wie fült es sich an - mit blick auf weitere Logik, die kommen wird (z.B. SteherEinbau nach Platzierrung eines Advancede Scutzzaun elements)


Das weiß ich nicht
das passt - fäürchte ich - nicht so ganz
Dann las uns das gleiuch imAnschluss nach denm erstwen Umbau diskttieren
baust mal los?


Go. Ich lese zuerst die Dateien, die ich zum korrekten Schnitt brauche, dann baue ich.


1	// Reactions the command processor returns to the host. The host (Fire3D) applies them: dispatches
2	// engine commands, shows menus/notifications/dialogs, or feeds an event back into the processor.
3	export type MenuEntry = {
4	    label: string;
5	    icon?: string;
6	    reactions: UIReaction[];
7	};
8
9	export type UIReaction =
10	    | { type: "contextMenu"; entries: MenuEntry[] }
11	    | { type: "sendCommand"; cmd: string; args: unknown[] }
12	    | { type: "notification"; message: string }
13	    | { type: "dialog"; title: string; message: string }
14	    // Feed an event back into the processor (e.g. a menu entry that records a domain change before
15	    // issuing engine commands). Fire3D routes it through processor.handleEvent and applies the result.
16	    | { type: "dispatchEvent"; event: unknown };
17

// Reactions the command processor returns to the host. The host (Fire3D) applies them: dispatches
// engine commands, shows menus/notifications/dialogs, or feeds an event back into the processor.
export type MenuEntry = {
    label: string;
    icon?: string;
    reactions: UIReaction[];
};

export type UIReaction =
    | { type: "contextMenu"; entries: MenuEntry[] }
    | { type: "sendCommand"; cmd: string; args: unknown[] }
    | { type: "notification"; message: string }
    | { type: "dialog"; title: string; message: string }
    // Feed an event back into the processor (e.g. a menu entry that records a domain change before
    // issuing engine commands). Fire3D routes it through processor.handleEvent and applies the result.
    | { type: "dispatchEvent"; event: unknown };


1	import type { GroupEvent } from "../model/GroupProcessor";
2
3	export type DomainEvent = GroupEvent;
4
5	export class EventStore {
6	    // Each entry is one undo/redo step: a group of events applied/reverted together.
7	    // The cursor indexes groups, not individual events.
8	    private groups: DomainEvent[][] = [];
9	    private cursor: number = -1;
10	    private initialSceneGraph: any = null;
11
12	    get canUndo(): boolean {
13	        return this.cursor >= 0;
14	    }
15
16	    get canRedo(): boolean {
17	        return this.cursor < this.groups.length - 1;
18	    }
19
20	    setInitialState(sceneGraph: any): void {
21	        this.initialSceneGraph = structuredClone(sceneGraph);
22	    }
23
24	    getInitialState(): any {
25	        return structuredClone(this.initialSceneGraph);
26	    }
27
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++) {
58	            for (let j = 0; j < this.groups[i].length; j++) {
59	                result.push(this.groups[i][j]);
60	            }
61	        }
62	        return result;
63	    }
64	}
65

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


1	// The placement event's selectors are absolute paths down to the PMI; we only need the
2	// globally unique part-instance handle id (the last `handle#<id>` segment) to bind the relation
3	// between the two parts (not the PMIs — moving a PMI doesn't move the part).
4	function lastHandleId(fullSelector: string | null | undefined): string | null {
5	    if (!fullSelector) return null;
6	    const handles = [...fullSelector.matchAll(/handle#([^.:\s>]+)/g)];
7	    return handles.length ? handles[handles.length - 1][1] : null;
8	}
9
10	function lastPmiId(fullSelector: string | null | undefined): string | null {
11	    if (!fullSelector) return null;
12	    const pmis = [...fullSelector.matchAll(/pmi#([^.:\s>]+)/g)];
13	    return pmis.length ? pmis[pmis.length - 1][1] : null;
14	}
15
16	export function extractPlacementData(event: any) {
17	    const entity = event.subject?.entity;
18	    return {
19	        // The subject is the moving group node; `position`/`rotation` are the snap target.
20	        handleId: entity?.id,
21	        position: event.position?.values,
22	        rotation: event.rotation?.values,
23	        // Which element/CS docked: the drag side belongs to the moving group, the drop side to the
24	        // target group. The drag CS (CS_LINKS/CS_RECHTS) fixes the side; the handle ids fix the
25	        // anchor (target end) and join (moving end) elements of the merge.
26	        dragHandleId: lastHandleId(event.dragPointSelector),
27	        dragPmiId: lastPmiId(event.dragPointSelector),
28	        dropHandleId: lastHandleId(event.dropPointSelector),
29	        dropPmiId: lastPmiId(event.dropPointSelector),
30	    };
31	}
32	export function extractMoveData(event: any) {
33	    const targets = event.subjects ?? [];
34	    const positions = event.positions ?? [];
35	    const delta = event.delta?.values;
36
37	    if (targets.length !== positions.length) {
38	        console.error("[extractMoveData] targets/positions length mismatch:", targets.length, positions.length);
39	        return [];
40	    }
41
42	    const result = [];
43	    for (let i = 0; i < targets.length; i++) {
44	        const entity = targets[i]?.entity;
45	        result.push({
46	            // The selected subject is the group node itself.
47	            handleId: entity?.id ?? entity?.parent?.id,
48	            delta,
49	            position: positions[i]?.values,
50	        });
51	    }
52	    return result;
53	}
54	export function extractRotateData(event: any) {
55	    const targets = event.subjects ?? [];
56	    // Rotation runs around the pivot, so it changes the part's position too — the mode provides
57	    // localRotations/localPositions; we record both (relative to the part's parent).
58	    const rotations = event.localRotations ?? [];
59	    const positions = event.localPositions ?? [];
60	    const axis = event.axis?.values;
61	    const deltaAngle = event.deltaAngle;
62
63	    if (targets.length !== rotations.length) {
64	        console.error("[extractRotateData] targets/rotations length mismatch:", targets.length, rotations.length);
65	        return [];
66	    }
67
68	    const result = [];
69	    for (let i = 0; i < targets.length; i++) {
70	        const entity = targets[i]?.entity;
71	        result.push({
72	            // The selected subject is the group node itself.
73	            handleId: entity?.id ?? entity?.parent?.id,
74	            axis,
75	            deltaAngle,
76	            rotation: rotations[i]?.values,
77	            position: positions[i]?.values,
78	        });
79	    }
80	    return result;
81	}
82

// The placement event's selectors are absolute paths down to the PMI; we only need the
// globally unique part-instance handle id (the last `handle#<id>` segment) to bind the relation
// between the two parts (not the PMIs — moving a PMI doesn't move the part).
function lastHandleId(fullSelector: string | null | undefined): string | null {
    if (!fullSelector) return null;
    const handles = [...fullSelector.matchAll(/handle#([^.:\s>]+)/g)];
    return handles.length ? handles[handles.length - 1][1] : null;
}

function lastPmiId(fullSelector: string | null | undefined): string | null {
    if (!fullSelector) return null;
    const pmis = [...fullSelector.matchAll(/pmi#([^.:\s>]+)/g)];
    return pmis.length ? pmis[pmis.length - 1][1] : null;
}

export function extractPlacementData(event: any) {
    const entity = event.subject?.entity;
    return {
        // The subject is the moving group node; `position`/`rotation` are the snap target.
        handleId: entity?.id,
        position: event.position?.values,
        rotation: event.rotation?.values,
        // Which element/CS docked: the drag side belongs to the moving group, the drop side to the
        // target group. The drag CS (CS_LINKS/CS_RECHTS) fixes the side; the handle ids fix the
        // anchor (target end) and join (moving end) elements of the merge.
        dragHandleId: lastHandleId(event.dragPointSelector),
        dragPmiId: lastPmiId(event.dragPointSelector),
        dropHandleId: lastHandleId(event.dropPointSelector),
        dropPmiId: lastPmiId(event.dropPointSelector),
    };
}
export function extractMoveData(event: any) {
    const targets = event.subjects ?? [];
    const positions = event.positions ?? [];
    const delta = event.delta?.values;

    if (targets.length !== positions.length) {
        console.error("[extractMoveData] targets/positions length mismatch:", targets.length, positions.length);
        return [];
    }

    const result = [];
    for (let i = 0; i < targets.length; i++) {
        const entity = targets[i]?.entity;
        result.push({
            // The selected subject is the group node itself.
            handleId: entity?.id ?? entity?.parent?.id,
            delta,
            position: positions[i]?.values,
        });
    }
    return result;
}
export function extractRotateData(event: any) {
    const targets = event.subjects ?? [];
    // Rotation runs around the pivot, so it changes the part's position too — the mode provides
    // localRotations/localPositions; we record both (relative to the part's parent).
    const rotations = event.localRotations ?? [];
    const positions = event.localPositions ?? [];
    const axis = event.axis?.values;
    const deltaAngle = event.deltaAngle;

    if (targets.length !== rotations.length) {
        console.error("[extractRotateData] targets/rotations length mismatch:", targets.length, rotations.length);
        return [];
    }

    const result = [];
    for (let i = 0; i < targets.length; i++) {
        const entity = targets[i]?.entity;
        result.push({
            // The selected subject is the group node itself.
            handleId: entity?.id ?? entity?.parent?.id,
            axis,
            deltaAngle,
            rotation: rotations[i]?.values,
            position: positions[i]?.values,
        });
    }
    return result;
}


1	import "./css/theme.css";
2	import "./css/base.css";
3	import "./css/product-browser.css";
4
5	// Inputs
6	export { default as FieldWrapper } from "./components/inputs/FieldWrapper.vue";
7	export { default as StringInput } from "./components/inputs/StringInput.vue";
8	export { default as NumberInput } from "./components/inputs/NumberInput.vue";
9	export { default as SelectInput } from "./components/inputs/SelectInput.vue";
10	export { default as TextInput } from "./components/inputs/TextInput.vue";
11	export { default as ListSection } from "./components/inputs/ListSection.vue";
12	export type { Option } from "./components/inputs/SelectInput.vue";
13
14	// Visual
15	export { default as Fire3D } from "./components/visual/Fire3D.vue";
16	export { default as ProductConfigurator } from "./components/visual/ProductConfigurator.vue";
17	export { CommandProcessor } from "./configurator/events/CommandProcessor";
18	export type { DockingRules } from "./configurator/events/DefaultCommandProcessor";
19	export type { UIReaction, MenuEntry } from "./configurator/types";
20
21	// Building blocks
22	export { default as ProductBrowserCard } from "./components/building-blocks/ProductBrowser.vue";
23	export type { ProductBrowserTheme, CatalogNode } from "./components/building-blocks/ProductBrowser.vue";
24	export { default as ProductBrowserPanel } from "./components/building-blocks/ProductBrowserPanel.vue";
25	export { default as ProductSettingsPanel } from "./components/building-blocks/ProductSettingsPanel.vue";
26	export { default as ModalWindow } from "./components/building-blocks/ModalWindow.vue";
27	export { default as ToastNotification } from "./components/building-blocks/ToastNotification.vue";
28	export { default as AppDialog } from "./components/building-blocks/AppDialog.vue";
29	export { default as ConfirmDialog } from "./components/building-blocks/ConfirmDialog.vue";
30	export { default as ModalBackdrop } from "./components/building-blocks/ModalBackdrop.vue";
31	export { default as EntityCard } from "./components/building-blocks/EntityCard.vue";
32	export { default as CardList } from "./components/building-blocks/CardList.vue";
33	export { default as ValidationErrorIcon } from "./components/inputs/ValidationErrorIcon.vue";
34
35	// Base widgets (non-BLAST, themeable)
36	export { default as BaseButton } from "./components/base/BaseButton.vue";
37	export { default as IconButton } from "./components/base/IconButton.vue";
38	export { default as BaseInput } from "./components/base/BaseInput.vue";
39	export { default as BaseSelect } from "./components/base/BaseSelect.vue";
40	export { default as BaseTextarea } from "./components/base/BaseTextarea.vue";
41
42	// Theming
43	export { provideTheme, useTheme, themeToVars } from "./composables/useTheme";
44	export type { AppTheme } from "./composables/useTheme";
45
46	// Composables
47	export { useModal, createModal } from "./composables/useModal";
48	export type { ModalOptions } from "./composables/useModal";
49	export { provideDiagnosticFormatter, useDiagnosticFormatter } from "./composables/useDiagnosticFormatter";
50	export type { DiagnosticFormatter, DiagnosticFormatContext } from "./composables/useDiagnosticFormatter";
51	export { useCardListManager } from "./composables/useCardListManager";
52	export type { CardListManagerOptions } from "./composables/useCardListManager";
53

import "./css/theme.css";
import "./css/base.css";
import "./css/product-browser.css";

// Inputs
export { default as FieldWrapper } from "./components/inputs/FieldWrapper.vue";
export { default as StringInput } from "./components/inputs/StringInput.vue";
export { default as NumberInput } from "./components/inputs/NumberInput.vue";
export { default as SelectInput } from "./components/inputs/SelectInput.vue";
export { default as TextInput } from "./components/inputs/TextInput.vue";
export { default as ListSection } from "./components/inputs/ListSection.vue";
export type { Option } from "./components/inputs/SelectInput.vue";

// Visual
export { default as Fire3D } from "./components/visual/Fire3D.vue";
export { default as ProductConfigurator } from "./components/visual/ProductConfigurator.vue";
export { CommandProcessor } from "./configurator/events/CommandProcessor";
export type { DockingRules } from "./configurator/events/DefaultCommandProcessor";
export type { UIReaction, MenuEntry } from "./configurator/types";

// Building blocks
export { default as ProductBrowserCard } from "./components/building-blocks/ProductBrowser.vue";
export type { ProductBrowserTheme, CatalogNode } from "./components/building-blocks/ProductBrowser.vue";
export { default as ProductBrowserPanel } from "./components/building-blocks/ProductBrowserPanel.vue";
export { default as ProductSettingsPanel } from "./components/building-blocks/ProductSettingsPanel.vue";
export { default as ModalWindow } from "./components/building-blocks/ModalWindow.vue";
export { default as ToastNotification } from "./components/building-blocks/ToastNotification.vue";
export { default as AppDialog } from "./components/building-blocks/AppDialog.vue";
export { default as ConfirmDialog } from "./components/building-blocks/ConfirmDialog.vue";
export { default as ModalBackdrop } from "./components/building-blocks/ModalBackdrop.vue";
export { default as EntityCard } from "./components/building-blocks/EntityCard.vue";
export { default as CardList } from "./components/building-blocks/CardList.vue";
export { default as ValidationErrorIcon } from "./components/inputs/ValidationErrorIcon.vue";

// Base widgets (non-BLAST, themeable)
export { default as BaseButton } from "./components/base/BaseButton.vue";
export { default as IconButton } from "./components/base/IconButton.vue";
export { default as BaseInput } from "./components/base/BaseInput.vue";
export { default as BaseSelect } from "./components/base/BaseSelect.vue";
export { default as BaseTextarea } from "./components/base/BaseTextarea.vue";

// Theming
export { provideTheme, useTheme, themeToVars } from "./composables/useTheme";
export type { AppTheme } from "./composables/useTheme";

// Composables
export { useModal, createModal } from "./composables/useModal";
export type { ModalOptions } from "./composables/useModal";
export { provideDiagnosticFormatter, useDiagnosticFormatter } from "./composables/useDiagnosticFormatter";
export type { DiagnosticFormatter, DiagnosticFormatContext } from "./composables/useDiagnosticFormatter";
export { useCardListManager } from "./composables/useCardListManager";
export type { CardListManagerOptions } from "./composables/useCardListManager";


1
41
42	<template>
43

44

45	            <slot name="catalog" :select="onProductSelected" />
46

47	        <Fire3D v-if="config" ref="fire" class="product-configurator__fire" :config="config" :processor="processor" />
48

49	</template>
50
51
67

<template>

            <slot name="catalog" :select="onProductSelected" />

        <Fire3D v-if="config" ref="fire" class="product-configurator__fire" :config="config" :processor="processor" />

</template>


1	import type { Meta, StoryObj } from "@storybook/vue3";
2	import { ref, onMounted } from "vue";
3	import Fire3D from "./Fire3D.vue";
4	import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
5
6	const config = await fetch("/fire/environment.config.json").then((r) => r.json());
7
8	const meta: Meta<typeof Fire3D> = {
9	    title: "Widgets/Visual",
10	    component: Fire3D,
11	    parameters: { layout: "fullscreen" },
12	};
13
14	export default meta;
15
16	type Story = StoryObj<typeof Fire3D>;
17
18	export const Default: Story = {
19	    render: () => ({
20	        components: { Fire3D },
21	        setup() {
22	            const processor = new DefaultCommandProcessor();
23	            const fire3d = ref<InstanceType<typeof Fire3D> | null>(null);
24
25	            onMounted(async () => {
26	                const scene = await fetch("/products/schutzzaun_basic/SBS1044PLA.json").then((r) => r.json());
27	                await fire3d.value?.setScene(scene);
28	            });
29
30	            return { config, fire3d, processor };
31	        },
32	        template: `<div style="height:100vh"><Fire3D ref="fire3d" :processor="processor" :config="config" /></div>`,
33	    }),
34	};
35
36	export const MultipleZaun: Story = {
37	    render: () => ({
38	        components: { Fire3D },
39	        setup() {
40	            const processor = new DefaultCommandProcessor();
41	            const fire3d = ref<InstanceType<typeof Fire3D> | null>(null);
42	            let zaunScene: any = null;
43
44	            const EMPTY = { _resourceClass: "sceneGraph_entity", handles: [] };
45
46	            onMounted(async () => {
47	                zaunScene = await fetch("/products/schutzzaun_basic/SBS1044PLA.json").then((r) => r.json());
48	                fire3d.value?.setBaseline(EMPTY);
49	            });
50
51	            const addZaun = async () => {
52	                if (!fire3d.value || !zaunScene) return;
53	                await fire3d.value.dispatch({ eventTypeName: "add-product", handles: zaunScene.handles });
54	            };
55
56	            return { config, fire3d, processor, addZaun };
57	        },
58	        template: `
59	            <div style="height:100vh">
60	                <Fire3D ref="fire3d" :processor="processor" :config="config">
61	                    <template #bottom>
62	                        <button @click="addZaun" style="padding: 0.4rem 0.8rem; cursor: pointer;">
63	                            + Zaun hinzufügen
64	                        </button>
65	                    </template>
66	                </Fire3D>
67	            </div>
68	        `,
69	    }),
70	};
71

import type { Meta, StoryObj } from "@storybook/vue3";
import { ref, onMounted } from "vue";
import Fire3D from "./Fire3D.vue";
import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";

const config = await fetch("/fire/environment.config.json").then((r) => r.json());

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

export default meta;

type Story = StoryObj<typeof Fire3D>;

export const Default: Story = {
    render: () => ({
        components: { Fire3D },
        setup() {
            const processor = new DefaultCommandProcessor();
            const fire3d = ref<InstanceType<typeof Fire3D> | null>(null);

            onMounted(async () => {
                const scene = await fetch("/products/schutzzaun_basic/SBS1044PLA.json").then((r) => r.json());
                await fire3d.value?.setScene(scene);
            });

            return { config, fire3d, processor };
        },
        template: `<div style="height:100vh"><Fire3D ref="fire3d" :processor="processor" :config="config" /></div>`,
    }),
};

export const MultipleZaun: Story = {
    render: () => ({
        components: { Fire3D },
        setup() {
            const processor = new DefaultCommandProcessor();
            const fire3d = ref<InstanceType<typeof Fire3D> | null>(null);
            let zaunScene: any = null;

            const EMPTY = { _resourceClass: "sceneGraph_entity", handles: [] };

            onMounted(async () => {
                zaunScene = await fetch("/products/schutzzaun_basic/SBS1044PLA.json").then((r) => r.json());
                fire3d.value?.setBaseline(EMPTY);
            });

            const addZaun = async () => {
                if (!fire3d.value || !zaunScene) return;
                await fire3d.value.dispatch({ eventTypeName: "add-product", handles: zaunScene.handles });
            };

            return { config, fire3d, processor, addZaun };
        },
        template: `
            <div style="height:100vh">
                <Fire3D ref="fire3d" :processor="processor" :config="config">
                    <template #bottom>
                        <button @click="addZaun" style="padding: 0.4rem 0.8rem; cursor: pointer;">
                            + Zaun hinzufügen
                        </button>
                    </template>
                </Fire3D>
            </div>
        `,
    }),
};


1	import type { Meta, StoryObj } from "@storybook/vue3";
2	import { onMounted, ref } from "vue";
3	import ProductConfigurator from "./ProductConfigurator.vue";
4	import ProductBrowser from "../building-blocks/ProductBrowser.vue";
5	import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";
6
7	const meta: Meta<typeof ProductConfigurator> = {
8	    title: "Widgets/Visual",
9	    component: ProductConfigurator,
10	    parameters: { layout: "fullscreen" },
11	};
12
13	export default meta;
14
15	type Story = StoryObj<typeof ProductConfigurator>;
16
17	export const Configurator: Story = {
18	    render: () => ({
19	        components: { ProductConfigurator, ProductBrowser },
20	        setup() {
21	            const catalog = ref<unknown>(null);
22	            // Docking-Regeln kommen normalerweise vom cpq-Client (geko); hier aus einer statischen JSON
23	            // geladen und per Prop übergeben. Klassen-Keys entsprechen den `class-…` Tags an den Bauteilen.
24	            const dockingRules = ref<DockingRules | null>(null);
25	            onMounted(async () => {
26	                const [cat, rules] = await Promise.all([
27	                    fetch("/products/catalog.json").then((r) => r.json()),
28	                    fetch("/products/docking-rules.json").then((r) => r.json()),
29	                ]);
30	                catalog.value = cat;
31	                dockingRules.value = rules;
32	            });
33	            return { catalog, dockingRules };
34	        },
35	        template: `
36	            <div style="height:100vh">
37	                <ProductConfigurator v-if="dockingRules" :docking-rules="dockingRules">
38	                    <template #catalog="{ select }">
39	                        <ProductBrowser
40	                            v-if="catalog"
41	                            :data="catalog"
42	                            image-base-path="/products/"
43	                            @child-clicked="select"
44	                        />
45	                    </template>
46	                </ProductConfigurator>
47	            </div>
48	        `,
49	    }),
50	};
51

import type { Meta, StoryObj } from "@storybook/vue3";
import { onMounted, ref } from "vue";
import ProductConfigurator from "./ProductConfigurator.vue";
import ProductBrowser from "../building-blocks/ProductBrowser.vue";
import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";

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

export default meta;

type Story = StoryObj<typeof ProductConfigurator>;

export const Configurator: Story = {
    render: () => ({
        components: { ProductConfigurator, ProductBrowser },
        setup() {
            const catalog = ref<unknown>(null);
            // Docking-Regeln kommen normalerweise vom cpq-Client (geko); hier aus einer statischen JSON
            // geladen und per Prop übergeben. Klassen-Keys entsprechen den `class-…` Tags an den Bauteilen.
            const dockingRules = ref<DockingRules | null>(null);
            onMounted(async () => {
                const [cat, rules] = await Promise.all([
                    fetch("/products/catalog.json").then((r) => r.json()),
                    fetch("/products/docking-rules.json").then((r) => r.json()),
                ]);
                catalog.value = cat;
                dockingRules.value = rules;
            });
            return { catalog, dockingRules };
        },
        template: `
            <div style="height:100vh">
                <ProductConfigurator v-if="dockingRules" :docking-rules="dockingRules">
                    <template #catalog="{ select }">
                        <ProductBrowser
                            v-if="catalog"
                            :data="catalog"
                            image-base-path="/products/"
                            @child-clicked="select"
                        />
                    </template>
                </ProductConfigurator>
            </div>
        `,
    }),
};


Das weiß ich nicht das passt - fäürchte ich - nicht so ganz Dann las uns das gleiuch imAnschluss nach denm erstwen Umbau diskttieren baust mal los?


Jetzt die öffentlichen Signaturen des `GroupProcessor`, die Modul und Kontext aufrufen.


Bash

cd /Users/marcus.hinz/projects/adesso-cpq && grep -nE '^\s+(public\s+)?(async\s+)?[a-zA-Z_]+\s*\([^)]*\)\s*(:|\{)|export (type|const|enum|interface) |^export class' packages/frontend/src/configurator/model/GroupProcessor.ts | head -120

List GroupProcessor public signatures


28:export const ROLE = { scene: "scene", assembly: "assembly", part: "part", geometry: "geometry" } as const;
29:export type NodeRole = (typeof ROLE)[keyof typeof ROLE];
42:    for (const handle of src.handles ?? []) {
44:        for (const comp of handle.components ?? []) {
45:            if (comp._resourceClass === "component_transform" && comp.key) {
54:    for (const handle of src.handles ?? []) {
55:        for (const comp of handle.components ?? []) {
57:            if (comp.parentKey) {
70:export interface Edge {
89:export interface GroupCreateEvent {
100:export interface GroupMergeEvent {
112:export interface GroupTransformEvent {
120:export interface GroupDetachEvent {
131:export interface GroupRegroupEvent {
140:export type GroupEvent =
147:export class GroupProcessor {
170:    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
184:    groupIdOfElement(rootHandleId: string): string | null {
188:    groupByKey(key: string): string | null {
193:    groupKeyOf(groupId: string): string | null {
197:    transformOf(groupId: string): Transform | null {
203:    occupiedPmis(): { element: string; pmi: string }[] {
206:            for (const e of g.edges) {
223:    blockedPmis(): { element: string; pmi: string }[] {
238:                if (r !== x) { r = find(r); parent.set(x, r); }
243:            for (const e of g.edges) {
270:                for (const m of members) {
272:                    for (const a of ["0", "90", "270"]) {
290:    groupCount(): number {
294:    roleOfId(handleId: string): NodeRole | null {
301:    sceneOfElement(elementRoot: string): { _resourceClass: string; handles: Handle[] } | null {
305:        for (const h of clone) {
315:    classOfGroup(groupId: string): string | null {
326:    elementsOfClass(klass: string): string[] {
330:            for (const member of g.members) {
342:    buildDetachEvent(elementId: string): GroupDetachEvent | null {
372:    buildRegroupEvent(selectedElementIds: string[]): { event: GroupRegroupEvent | null; moveGroupIds: string[] } {
374:        for (const id of selectedElementIds) {
385:        for (const [gid, sel] of byGroup) {
391:            if (sel.length === g.members.length && selComponents.length === 1) {
402:            for (const comp of selComponents) {
407:            for (const comp of restComponents) {
443:    membersOfKey(groupKey: string): string[] {
450:    nearestAncestorOfRole(handleId: string, role: NodeRole): string | null {
452:        while (current) {
462:    elementRootOf(handleId: string): string | null {
469:    areConnected(elementIds: string[]): boolean {
481:    selectableAncestors(handleId: string): string[] {
484:        while (cur) {
495:    replay(baseScene: Scene, events: GroupEvent[]): Scene {
505:        switch (event.type) {
553:                for (const ng of event.newGroups) {
568:                for (const ng of event.newGroups) {
589:            for (const member of g.members) {
630:        for (const e of g.edges) {
639:        while (queue.length) {
676:        for (const e of edges) {
682:        for (const m of members) {
687:            while (queue.length) {
738:        for (const comp of handle?.components ?? []) {
739:            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
751:        for (const comp of handle?.components ?? []) {
752:            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
763:    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
772:        for (const t of handle?.tags ?? []) {
782:        for (const h of handles) {
787:        for (const h of handles) {


1	import { describe, it, expect } from "vitest";
2	import { DefaultCommandProcessor, type DockingRules } from "./DefaultCommandProcessor";
3	import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";
4
5	const product = () => structuredClone(productData);
6
7	// The fixture is a schutzzaun_basic-tuer; let doors dock onto each other's open ends so placement has
8	// concrete drop targets (left entry → previous door's right end, and vice versa).
9	const DOCKING_RULES: DockingRules = {
10	    "schutzzaun_basic-tuer": {
11	        "schutzzaun_basic-tuer": {
12	            CS_EIN_L: ["CS_OUT_R_0"],
13	            CS_EIN_R: ["CS_OUT_L_0"],
14	        },
15	    },
16	};
17	const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
18
19	const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
20	const selOf = (r: any[]) => (r.find((x: any) => x.cmd === "set_selection") as any)?.args[0] as string[] | undefined;
21	const cmd = (r: any[], name: string) => r.find((x: any) => x.cmd === name) as any;
22	const tagged = (scene: any, role: string): string[] =>
23	    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);
24	const groupNodeOf = (scene: any, part: string) =>
25	    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
26	        .parentKey;
27
28	const click = (id: string, action: "set" | "add") => ({ eventTypeName: "selection-click", target: { id }, selectionAction: action });
29	const placement = (subject: string, o: { drag?: string; dragPmi?: string; drop?: string; dropPmi?: string; pos: number[] }) => ({
30	    eventTypeName: "interaction-mode",
31	    modeName: "placement",
32	    lifecycleState: "update",
33	    subject: { entity: { id: subject } },
34	    position: { values: o.pos },
35	    rotation: { values: [1, 0, 0, 0] },
36	    dragPointSelector: o.drag ? `handle#${o.drag} pmi#${o.dragPmi}` : undefined,
37	    dropPointSelector: o.drop ? `handle#${o.drop} pmi#${o.dropPmi}` : undefined,
38	});
39	const moveEnd = (key: string, pos: number[]) => ({
40	    eventTypeName: "interaction-mode",
41	    modeName: "movement",
42	    lifecycleState: "update",
43	    subjects: [{ entity: { id: key } }],
44	    positions: [{ values: pos }],
45	    delta: { values: pos },
46	});
47
48	// Three products merged into a chain a-b-c via the processor's own event flow.
49	function chain() {
50	    const proc = new DefaultCommandProcessor(DOCKING_RULES);
51	    proc.setInitialScene(base());
52	    let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
53	    scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
54	    scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
55	    const [a, b, c] = tagged(scene, "part");
56	    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, b), { drag: b, dragPmi: "CS_EIN_L", drop: a, dropPmi: "CS_OUT_R_0", pos: [0, 0, 0] })));
57	    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_EIN_L", drop: b, dropPmi: "CS_OUT_R_0", pos: [0, 0, 0] })));
58	    return { proc, scene, a, b, c };
59	}
60
61	describe("selection — multi-select", () => {
62	    it("set / add / toggle builds the selection set", () => {
63	        const { proc, a, b, c } = chain();
64	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]);
65	        expect(selOf(proc.handleEvent(click(b, "add")))).toEqual([`#${a}`, `#${b}`]);
66	        expect(selOf(proc.handleEvent(click(c, "add")))).toEqual([`#${a}`, `#${b}`, `#${c}`]);
67	        expect(selOf(proc.handleEvent(click(b, "add")))).toEqual([`#${a}`, `#${c}`]); // toggle b off
68	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]); // plain click resets
69	    });
70	});
71
72	describe("selection — escalating click", () => {
73	    it("element by default, re-clicking the same spot escalates to the group and clamps", () => {
74	        const { proc, scene, a } = chain();
75	        const group = groupNodeOf(scene, a);
76	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]);
77	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${group}`]);
78	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${group}`]); // clamped
79	    });
80	});
81
82	describe("detach — re-place a single element", () => {
83	    it("detaches the picked part and starts placement on a top-level group node", () => {
84	        const { proc, b } = chain();
85	        const react = proc.handleEvent({ eventTypeName: "detach-element", elementId: b });
86	        const scene = sceneOf(react);
87	        const subject = cmd(react, "start_placement_mode").args[0].subject as string;
88	        // Detach takes the element plus everything to its right: b leaves with c, {a} stays → 2 groups.
89	        expect(tagged(scene, "assembly").length).toBe(2);
90	        expect(tagged(scene, "assembly").map((id: string) => `#${id}`)).toContain(subject);
91	        // undo brings the chain back
92	        const back = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
93	        expect(tagged(back, "assembly").length).toBe(1);
94	    });
95	});
96
97	describe("placement — id-scoped open-end whitelist", () => {
98	    it("offers the same-named end of the open sibling but not of the occupied one", () => {
99	        // chain a-b-c: a/CS_OUT_R_0 docks b, b/CS_OUT_R_0 docks c. Detaching c frees b/CS_OUT_R_0 while
100	        // a/CS_OUT_R_0 stays occupied by b — same point name on two doors, distinguished by id.
101	        const { proc, a, b, c } = chain();
102	        const react = proc.handleEvent({ eventTypeName: "detach-element", elementId: c });
103	        const dragPoints = cmd(react, "start_placement_mode").args[0].dragPoints as Array<{
104	            selector: string;
105	            dropPoints: string[];
106	        }>;
107	        const drops = dragPoints.flatMap((d) => d.dropPoints);
108	        expect(drops).toContain(`#${b} .name-CS_OUT_R_0`); // freed by detaching c → offered
109	        expect(drops).not.toContain(`#${a} .name-CS_OUT_R_0`); // still occupied by b → excluded
110	        expect(drops).toContain(`#${a} .name-CS_OUT_L_0`); // a's left end is open → offered
111	    });
112	});
113
114	describe("actions — move strictly on the selection", () => {
115	    it("a partial selection regroups and moves as ONE undo step", () => {
116	        const { proc, b, c } = chain();
117	        proc.handleEvent(click(b, "set"));
118	        proc.handleEvent(click(c, "add"));
119	        const prep = proc.handleEvent({ eventTypeName: "prepare-action", mode: "move" });
120	        const prepScene = sceneOf(prep);
121	        const sel = selOf(prep)!;
122	        expect(tagged(prepScene, "assembly").length).toBe(2); // {a} and {b,c}
123	        expect(sel).toHaveLength(1);
124	        const movedScene = sceneOf(proc.handleEvent(moveEnd(sel[0].slice(1), [9, 0, 0])));
125	        expect(tagged(movedScene, "assembly").length).toBe(2);
126	        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" })); // single undo
127	        expect(tagged(undone, "assembly").length).toBe(1); // regroup + move reverted together
128	    });
129
130	    it("a whole-group selection moves without regrouping", () => {
131	        const { proc, a } = chain();
132	        proc.handleEvent(click(a, "set"));
133	        proc.handleEvent(click(a, "set")); // escalate to the group
134	        const prep = proc.handleEvent({ eventTypeName: "prepare-action", mode: "move" });
135	        expect(tagged(sceneOf(prep), "assembly").length).toBe(1); // no regroup
136	        const sel = selOf(prep)!;
137	        sceneOf(proc.handleEvent(moveEnd(sel[0].slice(1), [9, 0, 0])));
138	        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
139	        expect(tagged(undone, "assembly").length).toBe(1);
140	    });
141	});
142

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

const product = () => structuredClone(productData);

// The fixture is a schutzzaun_basic-tuer; let doors dock onto each other's open ends so placement has
// concrete drop targets (left entry → previous door's right end, and vice versa).
const DOCKING_RULES: DockingRules = {
    "schutzzaun_basic-tuer": {
        "schutzzaun_basic-tuer": {
            CS_EIN_L: ["CS_OUT_R_0"],
            CS_EIN_R: ["CS_OUT_L_0"],
        },
    },
};
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });

const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const selOf = (r: any[]) => (r.find((x: any) => x.cmd === "set_selection") as any)?.args[0] as string[] | undefined;
const cmd = (r: any[], name: string) => r.find((x: any) => x.cmd === name) as any;
const tagged = (scene: any, role: string): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;

const click = (id: string, action: "set" | "add") => ({ eventTypeName: "selection-click", target: { id }, selectionAction: action });
const placement = (subject: string, o: { drag?: string; dragPmi?: string; drop?: string; dropPmi?: string; pos: number[] }) => ({
    eventTypeName: "interaction-mode",
    modeName: "placement",
    lifecycleState: "update",
    subject: { entity: { id: subject } },
    position: { values: o.pos },
    rotation: { values: [1, 0, 0, 0] },
    dragPointSelector: o.drag ? `handle#${o.drag} pmi#${o.dragPmi}` : undefined,
    dropPointSelector: o.drop ? `handle#${o.drop} pmi#${o.dropPmi}` : undefined,
});
const moveEnd = (key: string, pos: number[]) => ({
    eventTypeName: "interaction-mode",
    modeName: "movement",
    lifecycleState: "update",
    subjects: [{ entity: { id: key } }],
    positions: [{ values: pos }],
    delta: { values: pos },
});

// Three products merged into a chain a-b-c via the processor's own event flow.
function chain() {
    const proc = new DefaultCommandProcessor(DOCKING_RULES);
    proc.setInitialScene(base());
    let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    const [a, b, c] = tagged(scene, "part");
    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, b), { drag: b, dragPmi: "CS_EIN_L", drop: a, dropPmi: "CS_OUT_R_0", pos: [0, 0, 0] })));
    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_EIN_L", drop: b, dropPmi: "CS_OUT_R_0", pos: [0, 0, 0] })));
    return { proc, scene, a, b, c };
}

describe("selection — multi-select", () => {
    it("set / add / toggle builds the selection set", () => {
        const { proc, a, b, c } = chain();
        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]);
        expect(selOf(proc.handleEvent(click(b, "add")))).toEqual([`#${a}`, `#${b}`]);
        expect(selOf(proc.handleEvent(click(c, "add")))).toEqual([`#${a}`, `#${b}`, `#${c}`]);
        expect(selOf(proc.handleEvent(click(b, "add")))).toEqual([`#${a}`, `#${c}`]); // toggle b off
        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]); // plain click resets
    });
});

describe("selection — escalating click", () => {
    it("element by default, re-clicking the same spot escalates to the group and clamps", () => {
        const { proc, scene, a } = chain();
        const group = groupNodeOf(scene, a);
        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]);
        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${group}`]);
        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${group}`]); // clamped
    });
});

describe("detach — re-place a single element", () => {
    it("detaches the picked part and starts placement on a top-level group node", () => {
        const { proc, b } = chain();
        const react = proc.handleEvent({ eventTypeName: "detach-element", elementId: b });
        const scene = sceneOf(react);
        const subject = cmd(react, "start_placement_mode").args[0].subject as string;
        // Detach takes the element plus everything to its right: b leaves with c, {a} stays → 2 groups.
        expect(tagged(scene, "assembly").length).toBe(2);
        expect(tagged(scene, "assembly").map((id: string) => `#${id}`)).toContain(subject);
        // undo brings the chain back
        const back = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(tagged(back, "assembly").length).toBe(1);
    });
});

describe("placement — id-scoped open-end whitelist", () => {
    it("offers the same-named end of the open sibling but not of the occupied one", () => {
        // chain a-b-c: a/CS_OUT_R_0 docks b, b/CS_OUT_R_0 docks c. Detaching c frees b/CS_OUT_R_0 while
        // a/CS_OUT_R_0 stays occupied by b — same point name on two doors, distinguished by id.
        const { proc, a, b, c } = chain();
        const react = proc.handleEvent({ eventTypeName: "detach-element", elementId: c });
        const dragPoints = cmd(react, "start_placement_mode").args[0].dragPoints as Array<{
            selector: string;
            dropPoints: string[];
        }>;
        const drops = dragPoints.flatMap((d) => d.dropPoints);
        expect(drops).toContain(`#${b} .name-CS_OUT_R_0`); // freed by detaching c → offered
        expect(drops).not.toContain(`#${a} .name-CS_OUT_R_0`); // still occupied by b → excluded
        expect(drops).toContain(`#${a} .name-CS_OUT_L_0`); // a's left end is open → offered
    });
});

describe("actions — move strictly on the selection", () => {
    it("a partial selection regroups and moves as ONE undo step", () => {
        const { proc, b, c } = chain();
        proc.handleEvent(click(b, "set"));
        proc.handleEvent(click(c, "add"));
        const prep = proc.handleEvent({ eventTypeName: "prepare-action", mode: "move" });
        const prepScene = sceneOf(prep);
        const sel = selOf(prep)!;
        expect(tagged(prepScene, "assembly").length).toBe(2); // {a} and {b,c}
        expect(sel).toHaveLength(1);
        const movedScene = sceneOf(proc.handleEvent(moveEnd(sel[0].slice(1), [9, 0, 0])));
        expect(tagged(movedScene, "assembly").length).toBe(2);
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" })); // single undo
        expect(tagged(undone, "assembly").length).toBe(1); // regroup + move reverted together
    });

    it("a whole-group selection moves without regrouping", () => {
        const { proc, a } = chain();
        proc.handleEvent(click(a, "set"));
        proc.handleEvent(click(a, "set")); // escalate to the group
        const prep = proc.handleEvent({ eventTypeName: "prepare-action", mode: "move" });
        expect(tagged(sceneOf(prep), "assembly").length).toBe(1); // no regroup
        const sel = selOf(prep)!;
        sceneOf(proc.handleEvent(moveEnd(sel[0].slice(1), [9, 0, 0])));
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(tagged(undone, "assembly").length).toBe(1);
    });
});


Struktur ist klar. Ich lege die drei Dateien an und ziehe die Verdrahtung nach.

**1. Interface + Kontext**


Write

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.
export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
}


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

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.
export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
}


**2. GEKO-Modul mit der ausgelagerten Logik**


import type { UIReaction, MenuEntry } from "../types";
import type { GroupEvent } from "../model/GroupProcessor";
import { IDENTITY_QUAT } from "../model/transformMath";
import { extractPlacementData } from "../events/eventData";
import type { CommandContext, EventHooks, PlacementModule, PrepareResult } from "../events/PlacementModule";

// Client-supplied placement rules, keyed by the moving part's docking class:
//   { movingClass: { targetClass: { dragPointName: dropPointName[] } } }
// Classes match the `class-…` tags on part root handles (e.g. "schutzzaun_allround-steher").
export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;

// The geko-specific placement/docking behaviour, lifted out of the domain-agnostic core: three-point
// snap orientation (dockRotation), docking-rules drag/drop wiring, and the marker "Anbau". It records
// merge/create events through the context and reads the shared group model — no second processor.
export class GekoPlacementModule implements PlacementModule {
    // dockingRules come from the cpq client (geko) — cpq itself is domain-agnostic.
    constructor(private readonly dockingRules: DockingRules = {}) {}

    hooks(): Record<string, EventHooks> {
        return {
            // A marker click docks a copy (prepare, vetoes the plain selection); every selection gets
            // its PMI markers back on the resulting scene (refine).
            "selection-click": {
                prepare: (event, ctx) => this.interceptMarkerClick(event, ctx),
                refine: (_event, ctx, reactions) => this.decorateSelection(reactions, ctx),
            },
            // Escalation re-renders the selection → markers again.
            "escalate-selection": {
                refine: (_event, ctx, reactions) => this.decorateSelection(reactions, ctx),
            },
            // The context menu gains "Platzieren" / "Element neu platzieren" for dockable subjects.
            "context-click": {
                refine: (event, ctx, reactions) => this.appendContextEntries(event, ctx, reactions),
            },
            // Placement end (snap → merge) rides on the interaction-mode event; move/rotate stay core.
            "interaction-mode": {
                prepare: (event, ctx) => this.interceptPlacement(event, ctx),
            },
            // Detach-for-placement and the keyboard build prototype have no core default.
            "detach-element": {
                replace: (event, ctx) => this.detachElement(event, ctx),
            },
            "keyboard-build": {
                replace: (event, ctx) => this.keyboardBuild(event, ctx),
            },
        };
    }

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

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

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

    // ---- Selection markers (refine) ----------------------------------------------------------------

    // Add the PMI markers to the setScene reaction's scene. No setScene → nothing to decorate.
    private decorateSelection(reactions: UIReaction[], ctx: CommandContext): UIReaction[] {
        const idx = reactions.findIndex((r) => r.type === "sendCommand" && r.cmd === "setScene");
        if (idx < 0) return reactions;
        const scene = (reactions[idx] as Extract<UIReaction, { type: "sendCommand" }>).args[0];
        const decorated = this.withSelectionMarkers(scene, ctx);
        const out = reactions.slice();
        out[idx] = { type: "sendCommand", cmd: "setScene", args: [decorated] };
        return out;
    }

    // Drop a small green sphere at every open select-PMI of the selected element(s). The marker hangs
    // under the element root's transform (PMI positions are element-local), rendered via an inline
    // parametric sphere + basic material, with a box collider so it's clickable. No selection → scene
    // unchanged.
    private withSelectionMarkers(scene: any, ctx: CommandContext): any {
        const roots = new Set(ctx.selectionElementRoots());
        if (!roots.size) return scene;
        const occupied = new Set(ctx.groups.blockedPmis().map((o) => `${o.element} ${o.pmi}`));
        const markers: any[] = [];
        for (const h of scene.handles ?? []) {
            if (!roots.has(h.id)) continue;
            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
            if (!tr?.key) continue;
            for (const c of h.components ?? []) {
                if (c._resourceClass !== "component_pmi") continue;
                // Only the select PMIs (CS_SELECT_<side>_<angle>) get a marker, not the base points.
                if (!String(c.id).startsWith("CS_SELECT")) continue;
                // Show only where a copy can dock: the matching CS_OUT slot must be open per the post
                // rule (blockedPmis already folds in occupancy and shared-post directions).
                const anchorPmi = String(c.id).replace("CS_SELECT_", "CS_OUT_");
                if (occupied.has(`${h.id} ${anchorPmi}`)) continue;
                const pos = c.properties?.position?.value ?? [0, 0, 0];
                markers.push({
                    id: `sel-marker-${h.id}-${c.id}`,
                    tags: ["name-select-marker"],
                    components: [
                        { _resourceClass: "component_transform", parentKey: tr.key, translation: pos, rotation: [0, 0, 0], scale: [1, 1, 1] },
                        // A real mesh sphere via the built-in parametric factory (key "sphere-3d", param
                        // `diameter`) — the same component_renderer path the config's arrow gizmo uses, so it
                        // renders in this build and yields proper localBounds (no getBounds crash on select).
                        {
                            _resourceClass: "component_renderer",
                            castShadow: false,
                            receiveShadow: false,
                            geometry: { _resourceClass: "geometry_parametric", parameters: { factoryName: "sphere-3d", diameter: 120 } },
                            material: { _resourceClass: "material_basic", diffuse: [0, 1, 0], depthTest: true },
                        },
                        // Box collider (shapeType 4) around the PMI — this @ad/fire build registers only
                        // box/capsule/mesh shape descriptors (no sphere), so shapeType 2 has no descriptor and
                        // crashes selection-bounds. triggersSelection defaults true → the raycast hits it and
                        // reports the marker as the click target. The visible marker stays the round mesh above.
                        { _resourceClass: "component_collider", type: 2, shapeType: 4, center: [0, 0, 0], dimension: [80, 80, 80] },
                    ],
                });
            }
        }
        return { ...scene, handles: [...(scene.handles ?? []), ...markers] };
    }

    // ---- Placement end (snap → merge) --------------------------------------------------------------

    private interceptPlacement(event: any, ctx: CommandContext): PrepareResult {
        if (event.modeName !== "placement" || event.lifecycleState !== "update") return null;
        return { stopDefault: true, reactions: this.placementEnd(event, ctx) };
    }

    private placementEnd(event: any, ctx: CommandContext): UIReaction[] {
        const data = extractPlacementData(event);
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        ctx.replay();
        // The subject is always a top-level group node (an element to re-place is detached into its own
        // group first — see detachElement — so the engine never gets a nested subject).
        const movingGroupId = ctx.resolveGroupId(data.handleId);
        if (!movingGroupId) return [stop];
        const placement = this.placementEvent(data, movingGroupId, ctx);
        if (placement) ctx.record(placement);
        const scene = ctx.replay();
        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
    }

    // The merge (on snap) or move (free) event for a moving group.
    private placementEvent(data: any, movingGroupId: string, ctx: CommandContext): GroupEvent | null {
        if (data.dropHandleId) {
            const anchorRoot = ctx.groups.elementRootOf(data.dropHandleId);
            const joinRoot = data.dragHandleId ? ctx.groups.elementRootOf(data.dragHandleId) : null;
            const targetGroupId = anchorRoot ? ctx.groups.groupIdOfElement(anchorRoot) : null;
            if (anchorRoot && joinRoot && targetGroupId && targetGroupId !== movingGroupId && data.dropPmiId && data.dragPmiId) {
                // The snapped points fix the join: the dragged point is placed onto the drop point.
                // Direction (any axis) follows from the point positions; the relative rotation (e.g. a
                // 90° branch onto a post) follows from the points' frame orientations.
                return {
                    type: "group-merge",
                    timestamp: Date.now(),
                    targetGroupId,
                    movingGroupId,
                    anchorRootId: anchorRoot,
                    joinRootId: joinRoot,
                    anchorPmiId: data.dropPmiId,
                    joinPmiId: data.dragPmiId,
                    rot: ctx.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),
                };
            }
            return null;
        }
        // No snap: free reposition of the group to the drop position.
        return {
            type: "group-move",
            timestamp: Date.now(),
            groupId: movingGroupId,
            position: data.position,
            rotation: ctx.groups.transformOf(movingGroupId)?.rot ?? IDENTITY_QUAT,
        };
    }

    // ---- Detach for re-placement -------------------------------------------------------------------

    // Pull a part out of its group into its own top-level group, then start placement on that group's
    // node (a valid top-level subject). The freed neighbour points are open again post-detach.
    private detachElement(event: any, ctx: CommandContext): UIReaction[] {
        ctx.replay();
        const detach = ctx.groups.buildDetachEvent(event.elementId);
        if (!detach) return [];
        ctx.record(detach);
        const scene = ctx.replay();

        const groupId = detach.newGroups.find((ng) => ng.members.includes(event.elementId))?.groupId;
        const groupKey = groupId ? ctx.groups.groupKeyOf(groupId) : null;
        const setScene: UIReaction = { type: "sendCommand", cmd: "setScene", args: [scene] };
        if (!groupKey) return [setScene];

        const subject = "#" + groupKey;
        const dragPoints = this.placementDragPoints(subject, ctx);
        const reactions: UIReaction[] = [
            setScene,
            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
        ];
        // No docking rule for this class → no drag points. fire's placement mode dereferences
        // dragPoints[0], so skip start_placement_mode entirely instead of handing it an empty array.
        if (dragPoints.length > 0) {
            reactions.push({
                type: "sendCommand",
                cmd: "start_placement_mode",
                args: [{ subject, allowAllDragPoints: true, dragPoints }],
            });
        }
        return reactions;
    }

    // ---- Keyboard build prototype ------------------------------------------------------------------

    // PROTOTYPE — keyboard build shortcut ("+" held + arrow), fed from Fire3D. Works off the last active
    // selection. right/left dock a COPY of the active part onto its open CS_OUT_R_0 / CS_OUT_L_0 end
    // (only if that end is still free); up/down nudge the active group along Y.
    private keyboardBuild(event: any, ctx: CommandContext): UIReaction[] {
        ctx.replay();
        const selection = ctx.getSelection();
        const active = selection[selection.length - 1]?.pickedId;
        const activeRoot = active ? ctx.groups.elementRootOf(active) : null;
        const groupId = activeRoot ? ctx.groups.groupIdOfElement(activeRoot) : null;
        if (!active || !activeRoot || !groupId) return [];

        if (event.direction === "up" || event.direction === "down") {
            const t = ctx.groups.transformOf(groupId);
            if (!t) return [];
            const step = event.direction === "up" ? 100 : -100;
            ctx.record({
                type: "group-move",
                timestamp: Date.now(),
                groupId,
                position: [t.pos[0], t.pos[1] + step, t.pos[2]],
                rotation: t.rot,
            } as GroupEvent);
            return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
        }

        // right / left: dock a copy of the active part onto its open end
        const anchorPmi = event.direction === "right" ? "CS_OUT_R_0" : "CS_OUT_L_0";
        const joinPmi = event.direction === "right" ? "CS_EIN_L" : "CS_EIN_R";
        if (ctx.groups.occupiedPmis().some((o) => o.element === activeRoot && o.pmi === anchorPmi)) {
            return []; // no room at the active part's chosen end
        }
        const scene = ctx.groups.sceneOfElement(activeRoot);
        if (!scene) return [];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        ctx.record({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: groupId,
            movingGroupId: createEvent.groupId,
            anchorRootId: activeRoot,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: anchorPmi,
            joinPmiId: joinPmi,
            rot: IDENTITY_QUAT,
        } as GroupEvent);
        // Chain: move the active selection onto the fresh copy so the next Shift+arrow docks onto it.
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }

    // ---- Context menu entries (refine) -------------------------------------------------------------

    // Append "Platzieren" (if the subject has open drag points) and "Element neu platzieren" (if a
    // single element is picked) to the core context menu. No contextMenu reaction → nothing to add.
    private appendContextEntries(event: any, ctx: CommandContext, reactions: UIReaction[]): UIReaction[] {
        const menuIdx = reactions.findIndex((r) => r.type === "contextMenu");
        if (menuIdx < 0) return reactions;
        const pickedId: unknown = event.pointed?.[0]?.entity?.id;
        if (typeof pickedId !== "string") return reactions;
        const groupSelector = ctx.groupSelectorOf(pickedId);
        if (!groupSelector) return reactions;

        const elementRoot = ctx.groups.elementRootOf(pickedId);
        const dragPoints = this.placementDragPoints(groupSelector, ctx);
        const extra: MenuEntry[] = [];
        if (dragPoints.length > 0) {
            extra.push({
                label: "Platzieren",
                reactions: [
                    { type: "sendCommand", cmd: "start_placement_mode", args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }] },
                ],
            });
        }
        if (elementRoot) {
            extra.push({
                label: "Element neu platzieren",
                // Detach runs via a dispatched event (records the split), then it starts placement on
                // the freed element's own top-level group node.
                reactions: [{ type: "dispatchEvent", event: { eventTypeName: "detach-element", elementId: elementRoot } }],
            });
        }
        if (!extra.length) return reactions;
        const menu = reactions[menuIdx] as Extract<UIReaction, { type: "contextMenu" }>;
        const out = reactions.slice();
        out[menuIdx] = { type: "contextMenu", entries: [...menu.entries, ...extra] };
        return out;
    }

    // ---- Docking rules wiring ----------------------------------------------------------------------

    // Placement drag/drop wiring from the client-supplied docking rules (root-level points only). The
    // moving part's class selects its rule block; per target class, each drag point (CS_EIN_*) maps to
    // the drop points (CS_OUT_*) it may snap onto. The class only *finds* the candidate elements in the
    // model — the emitted drop selectors then key on the concrete element id (`#<id> .name-<dp>`), a
    // whitelist of the currently OPEN points. This keys on the (universal) id instead of excluding
    // occupied points by id, so a shared/edge case can only under-offer, never wrongly block a sibling.
    // The subject's own members and every occupied point are left out. No rule → no drag points → free.
    private placementDragPoints(subject: string, ctx: CommandContext): Array<{ selector: string; dropPoints: string[] }> {
        const klass = this.classOf(subject, ctx);
        const rules = klass ? this.dockingRules[klass] : undefined;
        if (!rules) return [];

        const occupied = new Set(ctx.groups.blockedPmis().map((o) => `${o.element} ${o.pmi}`));
        const ownKey = subject.startsWith("#") ? subject.slice(1) : subject;
        const ownMembers = new Set(ctx.groups.membersOfKey(ownKey));

        // Per drag point on the subject, collect the concrete open drop points across every target class.
        const byDrag = new Map<string, string[]>();
        for (const [targetClass, dragMap] of Object.entries(rules)) {
            const targets = ctx.groups.elementsOfClass(targetClass).filter((el) => !ownMembers.has(el));
            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
                const open = byDrag.get(dragPoint) ?? [];
                for (const el of targets) {
                    for (const dp of dropPoints) {
                        if (!occupied.has(`${el} ${dp}`)) open.push(`#${el} .name-${dp}`);
                    }
                }
                byDrag.set(dragPoint, open);
            }
        }
        return [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}`,
            dropPoints,
        }));
    }

    // Docking class of a "#<groupKey>" subject, from the element root's `class-…` tag. Null when the
    // group carries no class — then there are no rules and the part places freely without snapping.
    private classOf(subject: string, ctx: CommandContext): string | null {
        const key = subject.startsWith("#") ? subject.slice(1) : subject;
        const groupId = ctx.groups.groupByKey(key);
        return groupId ? ctx.groups.classOfGroup(groupId) : null;
    }
}