Das stimm doch gar niocht {     "_resourceClass": "sceneGraph_entity",     "handles": [         {             "id": "X-80e46675-63eb-4f7a-80e8-3517e1e0193d",             "properties": { "custom-select…


Rollenförderer Visualisierung und Fehler beheben


Genau eine Stelle: am **Root-Handle** (das erste Handle, `id: "X-80e46675-…"`), im `tags`-Array **ganz am Ende** des Objekts — direkt nach dem großen `components`-Block:

```json
            "tags": ["class-schutzzaun_basic-tuer"]
```

Das ist die komplette „Klassen"-Information dieses Bauteils: Gruppe `schutzzaun_basic`, Klasse `tuer`. Daran hängt `classOfGroup` die Docking-Regeln.

Die eigentlichen **Ein-/Aus-Punkte** sind keine Klassen, sondern die `component_pmi`-Einträge am selben Root-Handle:
- **Einbau/Eingang**: `CS_EIN_L`, `CS_EIN_R` (`tags: ["name-CS_EIN_L"]` usw.)
- **Ausgang**: `CS_OUT_R_0`, `CS_OUT_L_0`, `CS_OUT_R_90`, `CS_OUT_L_90`, `CS_OUT_R_270`, … (`tags: ["name-CS_OUT_R_0"]` usw.)

Alle anderen Handles (Steherprofile, Abdeckkappen, Scharniere …) tragen nur `part-…`/`pmi-…`/`name-generic`-Tags — **keine** `class-…`-Angabe. Die Docking-Klasse gibt es also nur einmal, am Root.

Kurz: die Klasse zum Ein-/Ausbau steht als `class-schutzzaun_basic-tuer`-**Tag** am Root-Handle — nicht als eigenes Feld, sondern im `tags`-Array.


jetzt weiß ich, was, was mich stört
nimm den Fall zweier Türen #4711, #4812
#4711 ist CS_OUT_L_0 belegt
#4812 ist CS_OUT_L_0 NICHT belegt

Wie schließen wir aus, dass CS_OUT_L_0 bei #4812 belegt werden, kann bei #4711 nicht


Ausgeschlossen wird nicht über den PMI-**Namen**, sondern über die Kombination aus **eindeutiger Element-Root-Id + PMI-Name**. Das ist der Punkt, an dem #4711 und #4812 auseinandergehalten werden.

## Der Mechanismus (`DefaultCommandProcessor.ts`)

`occupiedPmis()` (GroupProcessor) liefert Paare `{ element, pmi }` — `element` = die **Root-Id des konkreten Bauteils** (also `#4711` bzw. `#4812`), `pmi` = der Name (`CS_OUT_L_0`). Für dein Beispiel steht nur drin: `{ element: "4711", pmi: "CS_OUT_L_0" }`.

`openEndExclusions()` (Zeile 497–502) baut daraus id-skopierte Ausschlüsse:
```js
.map((o) => `:not(#${o.element} .name-${o.pmi})`)   // → :not(#4711 .name-CS_OUT_L_0)
```

Der Drop-Selektor in `placementDragPoints` (Zeile 527) für `CS_OUT_L_0` wird damit:
```
.class-…-tuer .name-CS_OUT_L_0:not(#<subject> .name-CS_OUT_L_0):not(#4711 .name-CS_OUT_L_0)
```

- **#4711**: sein `CS_OUT_L_0` liegt unter `#4711` → vom `:not(#4711 …)` gekillt → **kein Snap-Ziel**.
- **#4812**: sein `CS_OUT_L_0` liegt unter `#4812`, nicht unter `#4711` → **bleibt Treffer** → snapbar.

Das `#4711` im `:not` benennt genau **eine** Instanz. Deshalb blockiert es #4711 und lässt #4812 offen.

## Zweite, harte Sperre (Model)

Selbst wenn die UI-Selektion daneben läge, greift der Guard im `group-merge`-Replay (Zeile 429–435):
```js
if (busy(anchorRootId, anchorPmiId) || busy(joinRootId, joinPmiId)) break;
```
`busy` prüft dieselbe `(element, pmi)`-Paarung — ein Merge auf `(4711, CS_OUT_L_0)` wird verworfen, `(4812, CS_OUT_L_0)` durchgelassen.

## Woran es hängt (der wunde Punkt)

Die Unterscheidung funktioniert nur, weil
1. **jede Instanz eine eindeutige Root-Id** hat (`instantiate()` in `product-service.ts` setzt den `i<n>-`-Präfix auf Handle-id/keys), und
2. `.name-CS_OUT_L_0` als **Descendant** von `#4711` selektierbar ist (Space-Kombinator in `#4711 .name-…`).

Wenn zwei Türen dieselbe Root-Id trügen, würde `:not(#id .name-CS_OUT_L_0)` beide gleichzeitig sperren — dann könnte #4812 fälschlich nicht mehr belegt werden. Genau da ist die id-Eindeutigkeit die einzige Absicherung. Wenn dich das stört, ist das der Hebel.


Wenn wir die Loagik umdrehen und die ID nehmen, um nur doe gültigen Elemente zu selejktieren geht?


Ja, das geht — und ist im Ergebnis sogar robuster, mit einer wichtigen Einschränkung.

## Was sich ändert
Statt Blacklist (alle `.name-<dp>` nehmen, belegte per `:not(#id …)` abziehen) baust du eine **Whitelist**: du zählst die tatsächlich **offenen** Andockpunkte auf und emittierst pro offenem Punkt einen positiven Selektor `#<elementId> .name-<pmi>`, ver-odert.

Die Daten dafür liegen im Model komplett vor:
- alle Element-Roots je Gruppe (`groups`/`members`),
- deren Docking-PMIs (aus den Handles),
- die Belegung (`occupiedPmis`),
- welche PMI-Namen überhaupt zählen (aus den `DockingRules`).

Also: `offene Punkte = (Regel-erlaubte dp der Zielklasse) × (Elemente der Zielklasse) − belegte`.

## Warum das besser ist
Der Fehlermodus dreht sich um:
- **Blacklist heute**: geht was schief, wird ein *gültiges* Ziel fälschlich gesperrt (zu streng).
- **Whitelist**: geht was schief, wird höchstens ein *ungültiges* Ziel fälschlich angeboten — und das fängt der harte Model-Guard im `group-merge`-Replay (`busy`-Check) ohnehin ab. Ein zu großzügiges UI ist damit unkritisch, ein zu strenges nervt den Nutzer.

## Die Einschränkung
Das Umdrehen behebt die **ID-Abhängigkeit nicht**. Auch der positive Selektor `#4812 .name-CS_OUT_L_0` braucht eine eindeutige Root-Id — sonst matcht er beide Türen. Du verschiebst nur die Konsequenz von „sperrt zu viel" zu „bietet zu viel an". Solange `instantiate()` eindeutige Ids vergibt, passt beides.

## Wenn du die id-/Selektor-Fragilität ganz loswerden willst
Dann ist der eigentliche Hebel nicht Blacklist↔Whitelist, sondern: den **offenen Punkten selbst ein eindeutiges, selektierbares Tag** geben (z. B. instanz-präfigiert beim Instanziieren), dann selektierst du direkt darüber statt über `#id`-Descendant-Matching. Das ist aber ein größerer Umbau als das reine Umdrehen.

Länge ist kein Argument für eine Seite: Whitelist skaliert mit offenen Punkten, Blacklist mit belegten.


Ist ja genau derr Punkt: Auf der ID abzuheben ist universell / klassen müssen da sein
kannst Du das mal umstellen?


Ich stelle `placementDragPoints` auf eine ID-basierte Whitelist um: das Model findet über die Klasse alle Elemente, der emittierte Selektor hebt aber auf die **ID** ab (`#<id> .name-<dp>`), und es kommen nur **offene** Punkte rein. Zuerst die exakten aktuellen Stellen lesen.


228	        return { _resourceClass: "sceneGraph_entity", handles: clone };
229	    }
230
231	    // Docking class of a group, read from the `class-…` tag of its element root(s). Null if none of
232	    // the members carry a class tag. Used to look the group up in the client-supplied docking rules.
233	    classOfGroup(groupId: string): string | null {
234	        for (const member of this.groups.get(groupId)?.members ?? []) {
235	            for (const t of this.handles.get(member)?.tags ?? []) {
236	                if (typeof t === "string" && t.startsWith("class-")) return t.slice("class-".length);
237	            }
238	        }
239	        return null;
240	    }
241
242	    // Record-time: split the group's chain at `elementId`. The element plus everything in reading
243	    // direction (to its "right") becomes one new group — the movable one; everything to the left
244	    // stays as another group. "Right" is derived geometrically: the chain's dominant axis (largest
245	    // spread of member positions), increasing coordinate = reading direction. No part-name knowledge.
246	    // Both groups keep their members' world positions (transform shifted to each component origin).
247	    buildDetachEvent(elementId: string): GroupDetachEvent | null {

        return { _resourceClass: "sceneGraph_entity", handles: clone };
    }

    // Docking class of a group, read from the `class-…` tag of its element root(s). Null if none of
    // the members carry a class tag. Used to look the group up in the client-supplied docking rules.
    classOfGroup(groupId: string): string | null {
        for (const member of this.groups.get(groupId)?.members ?? []) {
            for (const t of this.handles.get(member)?.tags ?? []) {
                if (typeof t === "string" && t.startsWith("class-")) return t.slice("class-".length);
            }
        }
        return null;
    }

    // Record-time: split the group's chain at `elementId`. The element plus everything in reading
    // direction (to its "right") becomes one new group — the movable one; everything to the left
    // stays as another group. "Right" is derived geometrically: the chain's dominant axis (largest
    // spread of member positions), increasing coordinate = reading direction. No part-name knowledge.
    // Both groups keep their members' world positions (transform shifted to each component origin).
    buildDetachEvent(elementId: string): GroupDetachEvent | null {


Ist ja genau derr Punkt: Auf der ID abzuheben ist universell / klassen müssen da sein kannst Du das mal umstellen?


494
495	    // `:not(...)` chain excluding every occupied docking point, so only open ends remain as snap
496	    // targets. Derived from the current edges (post-replay), nothing stored.
497	    private openEndExclusions(): string {
498	        return this.groups
499	            .occupiedPmis()
500	            .map((o) => `:not(#${o.element} .name-${o.pmi})`)
501	            .join("");
502	    }
503
504	    // Docking class of a "#<groupKey>" subject, from the element root's `class-…` tag. Null when the
505	    // group carries no class — then there are no rules and the part places freely without snapping.
506	    private classOf(subject: string): string | null {
507	        const key = subject.startsWith("#") ? subject.slice(1) : subject;
508	        const groupId = this.groups.groupByKey(key);
509	        return groupId ? this.groups.classOfGroup(groupId) : null;
510	    }
511
512	    // Placement drag/drop wiring from the client-supplied docking rules (root-level points only).
513	    // The moving part's class selects its rule block; per target class, each drag point (CS_EIN_*)
514	    // maps to the drop points (CS_OUT_*) it may snap onto. Drop points are scoped to the target
515	    // class via a descendant `.class-<target>` selector; self-snap and occupied ends are excluded.
516	    // No rule for the moving class → no drag points → the part places freely without snapping.
517	    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
518	        const klass = this.classOf(subject);
519	        const rules = klass ? this.dockingRules?.[klass] : undefined;
520	        if (!rules) return [];
521	        const openOnly = this.openEndExclusions();
522	        // Gather, per drag point, the drop-point selectors across every target class.
523	        const byDrag = new Map<string, string[]>();
524	        for (const [targetClass, dragMap] of Object.entries(rules)) {
525	            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
526	                const selectors = dropPoints.map(
527	                    (dp) => `.class-${targetClass} .name-${dp}:not(${subject} .name-${dp})${openOnly}`,
528	                );
529	                byDrag.set(dragPoint, [...(byDrag.get(dragPoint) ?? []), ...selectors]);
530	            }
531	        }
532	        return [...byDrag].map(([dragPoint, dropPoints]) => ({
533	            selector: `.name-${dragPoint}${openOnly}`,
534	            dropPoints,
535	        }));
536	    }
537

    // `:not(...)` chain excluding every occupied docking point, so only open ends remain as snap
    // targets. Derived from the current edges (post-replay), nothing stored.
    private openEndExclusions(): string {
        return this.groups
            .occupiedPmis()
            .map((o) => `:not(#${o.element} .name-${o.pmi})`)
            .join("");
    }

    // 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): string | null {
        const key = subject.startsWith("#") ? subject.slice(1) : subject;
        const groupId = this.groups.groupByKey(key);
        return groupId ? this.groups.classOfGroup(groupId) : null;
    }

    // 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. Drop points are scoped to the target
    // class via a descendant `.class-<target>` selector; self-snap and occupied ends are excluded.
    // No rule for the moving class → no drag points → the part places freely without snapping.
    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
        const klass = this.classOf(subject);
        const rules = klass ? this.dockingRules?.[klass] : undefined;
        if (!rules) return [];
        const openOnly = this.openEndExclusions();
        // Gather, per drag point, the drop-point selectors across every target class.
        const byDrag = new Map<string, string[]>();
        for (const [targetClass, dragMap] of Object.entries(rules)) {
            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
                const selectors = dropPoints.map(
                    (dp) => `.class-${targetClass} .name-${dp}:not(${subject} .name-${dp})${openOnly}`,
                );
                byDrag.set(dragPoint, [...(byDrag.get(dragPoint) ?? []), ...selectors]);
            }
        }
        return [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}${openOnly}`,
            dropPoints,
        }));
    }


**1. GroupProcessor: `elementsOfClass` ergänzen.**


Edit

    classOfGroup(groupId: string): string | null {
        for (const member of this.groups.get(groupId)?.members ?? []) {
            for (const t of this.handles.get(member)?.tags ?? []) {
                if (typeof t === "string" && t.startsWith("class-")) return t.slice("class-".length);
            }
        }
        return null;
    }

    classOfGroup(groupId: string): string | null {
        for (const member of this.groups.get(groupId)?.members ?? []) {
            for (const t of this.handles.get(member)?.tags ?? []) {
                if (typeof t === "string" && t.startsWith("class-")) return t.slice("class-".length);
            }
        }
        return null;
    }

    // Every element root in the scene whose docking class equals `klass` (its `class-…` tag). Lets the
    // placement build an id-keyed whitelist of concrete snap targets per target class.
    elementsOfClass(klass: string): string[] {
        const tag = `class-${klass}`;
        const out: string[] = [];
        for (const g of this.groups.values()) {
            for (const member of g.members) {
                if ((this.handles.get(member)?.tags ?? []).some((t) => t === tag)) out.push(member);
            }
        }
        return out;
    }


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

    classOfGroup(groupId: string): string | null {
        for (const member of this.groups.get(groupId)?.members ?? []) {
            for (const t of this.handles.get(member)?.tags ?? []) {
                if (typeof t === "string" && t.startsWith("class-")) return t.slice("class-".length);
            }
        }
        return null;
    }

    // Every element root in the scene whose docking class equals `klass` (its `class-…` tag). Lets the
    // placement build an id-keyed whitelist of concrete snap targets per target class.
    elementsOfClass(klass: string): string[] {
        const tag = `class-${klass}`;
        const out: string[] = [];
        for (const g of this.groups.values()) {
            for (const member of g.members) {
                if ((this.handles.get(member)?.tags ?? []).some((t) => t === tag)) out.push(member);
            }
        }
        return out;
    }

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

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

+    elementsOfClass(klass: string): string[] {

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

+            for (const member of g.members) {

+                if ((this.handles.get(member)?.tags ?? []).some((t) => t === tag)) out.push(member);

// Record-time: split the group's chain at `elementId`. The element plus everything in reading

// direction (to its "right") becomes one new group — the movable one; everything to the left

// stays as another group. "Right" is derived geometrically: the chain's dominant axis (largest


**2. DefaultCommandProcessor: `openEndExclusions` entfernen, `placementDragPoints` auf ID-Whitelist umstellen.**


    // `:not(...)` chain excluding every occupied docking point, so only open ends remain as snap
    // targets. Derived from the current edges (post-replay), nothing stored.
    private openEndExclusions(): string {
        return this.groups
            .occupiedPmis()
            .map((o) => `:not(#${o.element} .name-${o.pmi})`)
            .join("");
    }

    // 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): string | null {
        const key = subject.startsWith("#") ? subject.slice(1) : subject;
        const groupId = this.groups.groupByKey(key);
        return groupId ? this.groups.classOfGroup(groupId) : null;
    }

    // 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. Drop points are scoped to the target
    // class via a descendant `.class-<target>` selector; self-snap and occupied ends are excluded.
    // No rule for the moving class → no drag points → the part places freely without snapping.
    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
        const klass = this.classOf(subject);
        const rules = klass ? this.dockingRules?.[klass] : undefined;
        if (!rules) return [];
        const openOnly = this.openEndExclusions();
        // Gather, per drag point, the drop-point selectors across every target class.
        const byDrag = new Map<string, string[]>();
        for (const [targetClass, dragMap] of Object.entries(rules)) {
            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
                const selectors = dropPoints.map(
                    (dp) => `.class-${targetClass} .name-${dp}:not(${subject} .name-${dp})${openOnly}`,
                );
                byDrag.set(dragPoint, [...(byDrag.get(dragPoint) ?? []), ...selectors]);
            }
        }
        return [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}${openOnly}`,
            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): string | null {
        const key = subject.startsWith("#") ? subject.slice(1) : subject;
        const groupId = this.groups.groupByKey(key);
        return groupId ? this.groups.classOfGroup(groupId) : null;
    }

    // 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): Array<{ selector: string; dropPoints: string[] }> {
        const klass = this.classOf(subject);
        const rules = klass ? this.dockingRules?.[klass] : undefined;
        if (!rules) return [];

        const occupied = new Set(this.groups.occupiedPmis().map((o) => `${o.element}${o.pmi}`));
        const ownKey = subject.startsWith("#") ? subject.slice(1) : subject;
        const ownMembers = new Set(this.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 = this.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,
        }));
    }


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

    // `:not(...)` chain excluding every occupied docking point, so only open ends remain as snap
    // targets. Derived from the current edges (post-replay), nothing stored.
    private openEndExclusions(): string {
        return this.groups
            .occupiedPmis()
            .map((o) => `:not(#${o.element} .name-${o.pmi})`)
            .join("");
    }

    // 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): string | null {
        const key = subject.startsWith("#") ? subject.slice(1) : subject;
        const groupId = this.groups.groupByKey(key);
        return groupId ? this.groups.classOfGroup(groupId) : null;
    }

    // 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. Drop points are scoped to the target
    // class via a descendant `.class-<target>` selector; self-snap and occupied ends are excluded.
    // No rule for the moving class → no drag points → the part places freely without snapping.
    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
        const klass = this.classOf(subject);
        const rules = klass ? this.dockingRules?.[klass] : undefined;
        if (!rules) return [];
        const openOnly = this.openEndExclusions();
        // Gather, per drag point, the drop-point selectors across every target class.
        const byDrag = new Map<string, string[]>();
        for (const [targetClass, dragMap] of Object.entries(rules)) {
            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
                const selectors = dropPoints.map(
                    (dp) => `.class-${targetClass} .name-${dp}:not(${subject} .name-${dp})${openOnly}`,
                );
                byDrag.set(dragPoint, [...(byDrag.get(dragPoint) ?? []), ...selectors]);
            }
        }
        return [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}${openOnly}`,
            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): string | null {
        const key = subject.startsWith("#") ? subject.slice(1) : subject;
        const groupId = this.groups.groupByKey(key);
        return groupId ? this.groups.classOfGroup(groupId) : null;
    }

    // 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): Array<{ selector: string; dropPoints: string[] }> {
        const klass = this.classOf(subject);
        const rules = klass ? this.dockingRules?.[klass] : undefined;
        if (!rules) return [];

        const occupied = new Set(this.groups.occupiedPmis().map((o) => `${o.element}${o.pmi}`));
        const ownKey = subject.startsWith("#") ? subject.slice(1) : subject;
        const ownMembers = new Set(this.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 = this.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,
        }));
    }

-    // `:not(...)` chain excluding every occupied docking point, so only open ends remain as snap

-    // targets. Derived from the current edges (post-replay), nothing stored.

-    private openEndExclusions(): string {

-    // 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. Drop points are scoped to the target

-    // class via a descendant `.class-<target>` selector; self-snap and occupied ends are excluded.

-    // No rule for the moving class → no drag points → the part places freely without snapping.

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

-        const openOnly = this.openEndExclusions();

-        // Gather, per drag point, the drop-point selectors across every target class.

+        const occupied = new Set(this.groups.occupiedPmis().map((o) => `${o.element}${o.pmi}`));

+        const ownKey = subject.startsWith("#") ? subject.slice(1) : subject;

+        const ownMembers = new Set(this.groups.membersOfKey(ownKey));

+        // Per drag point on the subject, collect the concrete open drop points across every target class.

+            const targets = this.groups.elementsOfClass(targetClass).filter((el) => !ownMembers.has(el));

-                const selectors = dropPoints.map(

-                    (dp) => `.class-${targetClass} .name-${dp}:not(${subject} .name-${dp})${openOnly}`,

-                byDrag.set(dragPoint, [...(byDrag.get(dragPoint) ?? []), ...selectors]);

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

-            selector: `.name-${dragPoint}${openOnly}`,

+            selector: `.name-${dragPoint}`,


Jetzt Tests prüfen (evtl. testen sie das alte Selektor-Format) und Typecheck.


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Testet irgendwas placementDragPoints / openEndExclusions / die Selektoren? ==="
grep -rniE 'placementDragPoints|openEndExclusion|\.class-|:not\(#|start_placement_mode|dragPoints|dropPoints|name-CS_OUT' packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts 2>/dev/null | head -30

Tests zu placement drag points suchen


=== Testet irgendwas placementDragPoints / openEndExclusions / die Selektoren? ===
packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts:25:    dragPointSelector: o.drag ? `handle#${o.drag} pmi#${o.dragPmi}` : undefined,
packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts:26:    dropPointSelector: o.drop ? `handle#${o.drop} pmi#${o.dropPmi}` : undefined,
packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts:76:        const subject = cmd(react, "start_placement_mode").args[0].subject as string;


55	        expect(selOf(proc.handleEvent(click(c, "add")))).toEqual([`#${a}`, `#${b}`, `#${c}`]);
56	        expect(selOf(proc.handleEvent(click(b, "add")))).toEqual([`#${a}`, `#${c}`]); // toggle b off
57	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]); // plain click resets
58	    });
59	});
60
61	describe("selection — escalating click", () => {
62	    it("element by default, re-clicking the same spot escalates to the group and clamps", () => {
63	        const { proc, scene, a } = chain();
64	        const group = groupNodeOf(scene, a);
65	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]);
66	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${group}`]);
67	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${group}`]); // clamped
68	    });
69	});
70
71	describe("detach — re-place a single element", () => {
72	    it("detaches the picked part and starts placement on a top-level group node", () => {
73	        const { proc, b } = chain();
74	        const react = proc.handleEvent({ eventTypeName: "detach-element", elementId: b });
75	        const scene = sceneOf(react);
76	        const subject = cmd(react, "start_placement_mode").args[0].subject as string;
77	        // Detach takes the element plus everything to its right: b leaves with c, {a} stays → 2 groups.
78	        expect(tagged(scene, "assembly").length).toBe(2);
79	        expect(tagged(scene, "assembly").map((id: string) => `#${id}`)).toContain(subject);
80	        // undo brings the chain back
81	        const back = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
82	        expect(tagged(back, "assembly").length).toBe(1);
83	    });
84	});
85
86	describe("actions — move strictly on the selection", () => {
87	    it("a partial selection regroups and moves as ONE undo step", () => {
88	        const { proc, b, c } = chain();
89	        proc.handleEvent(click(b, "set"));
90	        proc.handleEvent(click(c, "add"));
91	        const prep = proc.handleEvent({ eventTypeName: "prepare-action", mode: "move" });
92	        const prepScene = sceneOf(prep);
93	        const sel = selOf(prep)!;
94	        expect(tagged(prepScene, "assembly").length).toBe(2); // {a} and {b,c}
95	        expect(sel).toHaveLength(1);
96	        const movedScene = sceneOf(proc.handleEvent(moveEnd(sel[0].slice(1), [9, 0, 0])));
97	        expect(tagged(movedScene, "assembly").length).toBe(2);
98	        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" })); // single undo
99	        expect(tagged(undone, "assembly").length).toBe(1); // regroup + move reverted together
100	    });
101
102	    it("a whole-group selection moves without regrouping", () => {
103	        const { proc, a } = chain();
104	        proc.handleEvent(click(a, "set"));
105	        proc.handleEvent(click(a, "set")); // escalate to the group
106	        const prep = proc.handleEvent({ eventTypeName: "prepare-action", mode: "move" });
107	        expect(tagged(sceneOf(prep), "assembly").length).toBe(1); // no regroup
108	        const sel = selOf(prep)!;
109	        sceneOf(proc.handleEvent(moveEnd(sel[0].slice(1), [9, 0, 0])));
110	        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
111	        expect(tagged(undone, "assembly").length).toBe(1);
112	    });
113	});
114

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


Tests und Typecheck laufen lassen.


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Tests: GroupProcessor + DefaultCommandProcessor ==="
pnpm --filter @ad-cpq/common-frontend exec vitest run GroupProcessor DefaultCommandProcessor 2>&1 | tail -18
echo
echo "=== Typecheck (nur neue/relevante Fehler) ==="
pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -E 'error TS' | grep -vE 'ProductBrowser.vue' | head

Tests und Typecheck nach Whitelist-Umbau


=== Tests: GroupProcessor + DefaultCommandProcessor ===
     74|         const react = proc.handleEvent({ eventTypeName: "detach-elemen…
     75|         const scene = sceneOf(react);
     76|         const subject = cmd(react, "start_placement_mode").args[0].sub…
       |                                                          ^
     77|         // Detach takes the element plus everything to its right: b le…
     78|         expect(tagged(scene, "assembly").length).toBe(2);

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed | 1 passed (2)
      Tests  1 failed | 23 passed (24)
   Start at  12:18:43
   Duration  681ms (transform 98ms, setup 0ms, import 123ms, tests 189ms, environment 823ms)

undefined
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend:
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command failed with exit code 1: vitest run GroupProcessor DefaultCommandProcessor

=== Typecheck (nur neue/relevante Fehler) ===
src/configurator/model/GroupProcessor.ts(249,66): error TS7006: Parameter 't' implicitly has an 'any' type.


Zwei Punkte: Typfehler in `elementsOfClass` (`t` any) und der Detach-Test schlägt fehl. Ich sehe mir das Test-Setup an (welche Regeln/Klasse), um die Ursache zu verstehen.


1	import { describe, it, expect } from "vitest";
2	import { DefaultCommandProcessor } from "./DefaultCommandProcessor";
3	import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";
4
5	const product = () => structuredClone(productData);
6	const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
7
8	const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
9	const selOf = (r: any[]) => (r.find((x: any) => x.cmd === "set_selection") as any)?.args[0] as string[] | undefined;
10	const cmd = (r: any[], name: string) => r.find((x: any) => x.cmd === name) as any;
11	const tagged = (scene: any, role: string): string[] =>
12	    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);
13	const groupNodeOf = (scene: any, part: string) =>
14	    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
15	        .parentKey;
16
17	const click = (id: string, action: "set" | "add") => ({ eventTypeName: "selection-click", target: { id }, selectionAction: action });
18	const placement = (subject: string, o: { drag?: string; dragPmi?: string; drop?: string; dropPmi?: string; pos: number[] }) => ({
19	    eventTypeName: "interaction-mode",
20	    modeName: "placement",
21	    lifecycleState: "update",
22	    subject: { entity: { id: subject } },
23	    position: { values: o.pos },
24	    rotation: { values: [1, 0, 0, 0] },
25	    dragPointSelector: o.drag ? `handle#${o.drag} pmi#${o.dragPmi}` : undefined,
26	    dropPointSelector: o.drop ? `handle#${o.drop} pmi#${o.dropPmi}` : undefined,
27	});
28	const moveEnd = (key: string, pos: number[]) => ({
29	    eventTypeName: "interaction-mode",
30	    modeName: "movement",
31	    lifecycleState: "update",
32	    subjects: [{ entity: { id: key } }],
33	    positions: [{ values: pos }],
34	    delta: { values: pos },
35	});
36
37	// Three products merged into a chain a-b-c via the processor's own event flow.
38	function chain() {
39	    const proc = new DefaultCommandProcessor();
40	    proc.setInitialScene(base());
41	    let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
42	    scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
43	    scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
44	    const [a, b, c] = tagged(scene, "part");
45	    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, b), { drag: b, dragPmi: "CS_LINKS", drop: a, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
46	    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_LINKS", drop: b, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
47	    return { proc, scene, a, b, c };
48	}
49
50	describe("selection — multi-select", () => {

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

const product = () => structuredClone(productData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });

const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const 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();
    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_LINKS", drop: a, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_LINKS", drop: b, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
    return { proc, scene, a, b, c };
}

describe("selection — multi-select", () => {


Der Test-Processor wird **ohne** Docking-Regeln erzeugt (`new DefaultCommandProcessor()`), also greift in beiden Fassungen der `if (!rules) return []`-Pfad. Ich fixe erst den Typfehler und prüfe dann, ob der Detach-Test schon vor meiner Änderung rot war.


Edit

if ((this.handles.get(member)?.tags ?? []).some((t) => t === tag)) out.push(member);

if ((this.handles.get(member)?.tags ?? []).some((t: unknown) => t === tag)) out.push(member);


-                if ((this.handles.get(member)?.tags ?? []).some((t) => t === tag)) out.push(member);

+                if ((this.handles.get(member)?.tags ?? []).some((t: unknown) => t === tag)) out.push(member);


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Detach-Test auf UNVERÄNDERTEM Stand (meine 2 Dateien gestasht) ==="
git stash push -- packages/frontend/src/configurator/events/DefaultCommandProcessor.ts packages/frontend/src/configurator/model/GroupProcessor.ts 2>&1 | tail -1
pnpm --filter @ad-cpq/common-frontend exec vitest run DefaultCommandProcessor 2>&1 | grep -E 'Tests|detaches the picked|✓|✗|×'
echo "--- Änderungen zurück ---"
git stash pop 2>&1 | tail -1

Detach-Test auf Originalstand prüfen


=== Detach-Test auf UNVERÄNDERTEM Stand (meine 2 Dateien gestasht) ===
Saved working directory and index state WIP on Produktmanagement: d448cdb ProductConfigurator-Story: Docking-Regeln aus JSON laden
     × detaches the picked part and starts placement on a top-level group node 14ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  src/configurator/events/DefaultCommandProcessor.test.ts > detach — re-place a single element > detaches the picked part and starts placement on a top-level group node
      Tests  1 failed | 4 passed (5)
--- Änderungen zurück ---
Dropped refs/stash@{0} (5d98c05791150e6f324ca7350a4f3774892220f3)


Der Detach-Test war **schon vor meiner Änderung rot** (Processor ohne Docking-Regeln + obsolete `CS_LINKS/CS_RECHTS`) — keine Regression. Da er genau den Placement-Pfad prüft, den ich gerade umgebaut habe, ziehe ich ihn auf die reale Logik (Regeln + echte PMI-Namen) und decke damit die Whitelist ab.


Edit

import { DefaultCommandProcessor } from "./DefaultCommandProcessor";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

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

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"],
        },
    },
};


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

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"],
        },
    },
};

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

const product = () => structuredClone(productData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });

const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const 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();
    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_LINKS", drop: a, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_LINKS", drop: b, dropPmi: "CS_RECHTS", 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("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);
    });
});

-import { DefaultCommandProcessor } from "./DefaultCommandProcessor";

+import { DefaultCommandProcessor, type DockingRules } from "./DefaultCommandProcessor";

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


function chain() {
    const proc = new DefaultCommandProcessor();
    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_LINKS", drop: a, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_LINKS", drop: b, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
    return { proc, scene, a, b, c };
}

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


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

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();
    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_LINKS", drop: a, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_LINKS", drop: b, dropPmi: "CS_RECHTS", 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("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);
    });
});

-    const proc = new DefaultCommandProcessor();

+    const proc = new DefaultCommandProcessor(DOCKING_RULES);

-    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, b), { drag: b, dragPmi: "CS_LINKS", drop: a, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));

-    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_LINKS", drop: b, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));

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


Und einen Test, der genau deinen #4711/#4812-Fall abbildet (gleicher PMI-Name, per ID unterschieden):


describe("actions — move strictly on the selection", () => {

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


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

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

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

+        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


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Tests ==="
pnpm --filter @ad-cpq/common-frontend exec vitest run GroupProcessor DefaultCommandProcessor 2>&1 | tail -8
echo
echo "=== Typecheck (nicht-ProductBrowser) ==="
pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -E 'error TS' | grep -vE 'ProductBrowser.vue' | head

Tests und Typecheck final


=== Tests ===
 RUN  v4.1.9 /Users/marcus.hinz/projects/adesso-cpq/packages/frontend

 Test Files  2 passed (2)
      Tests  25 passed (25)
   Start at  12:23:03
   Duration  595ms (transform 94ms, setup 0ms, import 119ms, tests 196ms, environment 659ms)

=== Typecheck (nicht-ProductBrowser) ===


Umgestellt. 25/25 Tests grün, Typecheck sauber (nur die vorbestehenden `ProductBrowser.vue`-Fehler).

## Was geändert wurde

**`DefaultCommandProcessor.placementDragPoints`** — jetzt ID-Whitelist statt Blacklist:
- Die Klasse dient nur noch dazu, im Model die Zielelemente zu **finden** (`GroupProcessor.elementsOfClass(targetClass)`).
- Der emittierte Drop-Selektor hebt auf die **konkrete ID** ab: `#<elementId> .name-<dp>` — und es kommen nur **offene** Punkte rein (belegte via `occupiedPmis` und die eigenen Subject-Member werden übersprungen).
- `openEndExclusions()` (die `:not(#id …)`-Kette) ist entfernt.

**`GroupProcessor.elementsOfClass(klass)`** — neu: alle Element-Roots der Szene mit `class-<klass>`-Tag.

## Warum das dein Punkt trifft
- Auf die ID abzuheben ist universell; die Klasse muss da sein, um die Elemente zu finden, steht aber nicht mehr im Selektor.
- Der Fehlermodus dreht sich um: schlimmstenfalls wird ein Ziel **nicht** angeboten — nie ein offenes Geschwister fälschlich gesperrt. Echte Doppelbelegung fängt weiterhin der harte `group-merge`-Guard im Model.

## Tests
- `DefaultCommandProcessor.test.ts`: `chain()` nutzt jetzt Docking-Regeln + echte PMI-Namen (`CS_EIN_L`/`CS_OUT_R_0`) statt `CS_LINKS`/`CS_RECHTS`. Damit ist der **Detach-Test grün**, der vorher (unabhängig von dieser Änderung) rot war.
- Neuer Test „id-scoped open-end whitelist" bildet genau deinen Fall ab: Kette a-b-c, `c` abgekoppelt → `#b .name-CS_OUT_R_0` wird angeboten (freigeworden), `#a .name-CS_OUT_R_0` nicht (weiter belegt) — gleicher PMI-Name, per ID unterschieden.

Nicht committet.


kannst du dann die klasse auf asset Ebene wiederventfernen?
8class-ture....) etc?


Kurz gegengeprüft, wo die `class-`-Tags noch gelesen werden, bevor ich etwas Kaputtes tue.


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Wer liest 'class-' / classOfGroup / elementsOfClass / classOf ? ==="
grep -rniE "startsWith\\(.class-|class-\\\$\\{|classOfGroup|elementsOfClass|\\bclassOf\\b" packages/frontend/src --include=*.ts --include=*.vue 2>/dev/null | grep -v '\.test\.' | grep -v node_modules

Konsumenten der class-Tags finden


=== Wer liest 'class-' / classOfGroup / elementsOfClass / classOf ? ===
(eval):3: no matches found: --include=*.ts


cd /Users/marcus.hinz/projects/adesso-cpq
grep -rniE "class-\{|startsWith\(.class-|classOfGroup|elementsOfClass|classOf\(" packages/frontend/src 2>/dev/null | grep -v '\.test\.' | grep -v node_modules

Konsumenten der class-Tags


packages/frontend/src/configurator/model/GroupProcessor.ts:233:    classOfGroup(groupId: string): string | null {
packages/frontend/src/configurator/model/GroupProcessor.ts:236:                if (typeof t === "string" && t.startsWith("class-")) return t.slice("class-".length);
packages/frontend/src/configurator/model/GroupProcessor.ts:244:    elementsOfClass(klass: string): string[] {


Nein — das würde das Docking brechen. Die `class-`-Tags sind auf Asset-Ebene weiterhin die **einzige Quelle** der Docking-Klasse und werden an zwei Stellen gelesen:

- **`GroupProcessor.classOfGroup`** (`:236`) — liest `class-…` am Bauteil, um die Klasse des **beweglichen** Teils zu bestimmen. Daraus wählt `placementDragPoints` den Regelblock. Ohne Tag → `classOf` = `null` → keine Regeln → keine Drag-Punkte → kein Snapping.
- **`GroupProcessor.elementsOfClass`** (`:244`) — findet über `class-…` alle **Ziel**-Elemente einer Klasse. Ohne Tag → leere Liste → keine Drop-Punkte.

Der ID-Umbau hat die Klasse nur aus dem **Selektor** genommen (`.class-tuer` ist raus, jetzt `#id`). Im **Model** bleibt die Klasse tragend — sie muss die Regeln keyen und die Kandidaten finden.

Wenn die Klasse wirklich vom Asset weg soll, braucht sie vorher eine andere Quelle — z. B. eine Zuordnung `Produkt-/Szenegraph-Pfad → Klasse` im Katalog oder in den Docking-Regeln. Solange die nicht existiert, entferne ich die `class-…`-Tags nicht, weil das Placement damit tot wäre.


kannst du dann die klasse auf asset Ebene wiederventfernen? 8class-ture....) etc?


die muss da raus
gleicxh mal reden


Verstanden — Klasse raus aus den Assets. Ich warte auf das Gespräch; zu klären ist dabei die Ersatzquelle für die Klasse (Katalog-Mapping vs. Regeln). Bis dahin fasse ich nichts an.


Könnn wir die AssetKlasse aus der Catalog JSON ermitteln? -> wir müsste uns eben merken, zu welch #ID welche klasse gehört


Ich schaue in `catalog.json`, ob die Klasse dort (oder etwas Äquivalentes) pro Artikel steht und wie sie mit der Szenegraph-ID zu korrelieren wäre.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products
echo "=== Struktur/Keys in catalog.json ==="
python3 -c "
import json
d=json.load(open('catalog.json'))
def keys(o,p='',depth=0):
    if depth>6: return
    if isinstance(o,dict):
        print('  '*depth+'{ '+', '.join(o.keys())+' }')
        for k,v in o.items():
            if isinstance(v,(dict,list)): keys(v,p+'/'+k,depth+1);
            if depth>=0 and not isinstance(v,(dict,list)): pass
    elif isinstance(o,list):
        print('  '*depth+f'[ {len(o)} x ]')
        if o: keys(o[0],p+'[0]',depth+1)
keys(d)
"

catalog.json Struktur inspizieren


=== Struktur/Keys in catalog.json ===
{ version, gruppen }
  [ 3 x ]
    { gruppe, kataloggruppen }
      [ 4 x ]
        { name, artikelgruppen }
          [ 2 x ]
            { name, fuellung, artikel }


[Request interrupted by user]


Nein, wir machen das anders
Wenn ich beim Priodukrservice ein Produkt erstellen lasse, ist die klasse doch bekannt?


Ja — und das ist der sauberere Weg als der Asset-Tag. Die Klasse ist beim Erzeugen bekannt (aus der Katalog-Auswahl), und die maßgebliche Element-Root-ID entsteht ohnehin genau dort: `buildCreateEvent` → `reidentify` vergibt `rootHandleId`. Genau an dem Punkt kann die Klasse mitgeschrieben werden.

## Sauberer, replay-fester Weg
Die Klasse muss **am `group-create`-Event** hängen, nicht in einer flüchtigen Map — sonst geht sie beim Replay verloren.

1. **Auswahl → Event**: `onProductSelected` bekommt die Klasse aus dem Katalog-Node und legt sie aufs `add-product`-Event.
2. **Processor**: `handleAddProduct` reicht sie an `buildCreateEvent(scene, position, klass)` weiter; die Klasse wird Feld im `GroupCreateEvent`.
3. **GroupProcessor.replay**: beim Abspielen des `group-create` füllt es eine Map `rootHandleId → klass` (aus dem Event-Log, also replay-stabil).
4. **`classOfGroup` / `elementsOfClass`**: lesen aus dieser Map statt aus dem `class-…`-Tag.
5. Dann fällt der **`class-…`-Tag am Asset** ersatzlos weg.

## Der eine offene Punkt
`onProductSelected` bekommt heute nur `{ scenegraph }`. Die Klasse muss also im **Katalog-Node** verfügbar sein (oder aus der Katalog-Position ableitbar) und mit durchgereicht werden. Das ist die einzige Stelle, die noch eine Quelle braucht — danach ist die ID→Klasse-Zuordnung komplett im Model und am Event, nicht mehr am Asset.

Wenn die Klasse im Katalog-Node steht, ist das ein kleiner, klar abgegrenzter Umbau. Sag Bescheid, wie die Klasse an der Auswahl ankommt, dann setze ich es um.


Nein, wir machen das anders Wenn ich beim Priodukrservice ein Produkt erstellen lasse, ist die klasse doch bekannt?


Die klasse hängt am katalo Node
Ich hätte den aber doch auf das Assett gesetzt
In eem schritt, in dem so osder so sie IDs neu gecshrieben werden


Genau so. Wichtig nur: es gibt **zwei** ID-Neuschreibungen — `instantiate()` im ProductService (`i<n>-`-Präfix) und danach `reidentify()` im Processor (`h-`/`X-…`). Die zweite überschreibt die erste, also ist **`reidentify`/`buildCreateEvent` der Schritt mit der maßgeblichen Element-Root-ID** — dort wird gebunden.

Und: nicht wieder als Tag, sondern als Wert **am `group-create`-Event**, damit es replay-fest ist.

## Fluss
1. `onProductSelected` liest `node.class` (hängt am Katalog-Node) und legt sie aufs `add-product`-Event.
2. `handleAddProduct` reicht sie an `buildCreateEvent(scene, position, klass)`.
3. `buildCreateEvent` schreibt `class` ins `GroupCreateEvent` — im selben Schritt, in dem `reidentify` die ID vergibt.
4. `GroupProcessor.replay` füllt beim `group-create` eine Map `rootHandleId → class`.
5. `classOfGroup` / `elementsOfClass` lesen aus dieser Map statt aus dem Tag.
6. Der Asset-Generierungsschritt lässt den `class-…`-Tag künftig weg.

## Konkrete Edits
- `GroupCreateEvent`-Typ: Feld `class?: string`.
- `GroupProcessor.buildCreateEvent(scene, position, klass?)` + Map + `classOfGroup`/`elementsOfClass` auf die Map umstellen.
- `DefaultCommandProcessor.handleAddProduct`: `event.class` durchreichen.
- `ProductConfigurator.onProductSelected`: `node.class` aufs Event.

Ein Punkt zum Abgleich: `buildCreateEvent` wird auch in `handleKeyboardBuild` (Kopie eines Teils via `sceneOfElement`) aufgerufen — da muss die Klasse des Quell-Elements aus der Map mitkopiert werden, sonst verliert die Kopie ihre Klasse.

Sag Go, dann setze ich es um.


es sollte nue rine Stelle gebenh, die IDs und Eugenscaften setzt, oder?


Ja. Aktuell sind es zwei — das ist die Redundanz. Ich prüfe kurz, ob `instantiate` außerhalb des Gruppen-Pfads noch gebraucht wird, bevor ich es als „weg damit" bestätige.


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Aufrufer von getProduct / instantiate / buildCreateEvent ==="
grep -rniE 'getProduct|\.instantiate\(|buildCreateEvent' packages/frontend/src 2>/dev/null | grep -v '\.test\.' | grep -v node_modules
echo
echo "=== Nutzt irgendein Pfad das getProduct-Ergebnis OHNE add-product/buildCreateEvent? ==="
grep -rniE 'getProduct|updateProduct' packages/frontend/src 2>/dev/null | grep -v node_modules | grep -v product-service.ts

getProduct/instantiate/buildCreateEvent-Nutzung prüfen


=== Aufrufer von getProduct / instantiate / buildCreateEvent ===
packages/frontend/src/components/visual/ProductConfigurator.vue:33:    const graph = await productService.getProduct(productType);
packages/frontend/src/configurator/model/GroupProcessor.ts:133:    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
packages/frontend/src/services/product-service.ts:64:    getProduct(productType: string): Promise<SceneGraph>;
packages/frontend/src/services/product-service.ts:75:    async getProduct(productType: string): Promise<SceneGraph> {
packages/frontend/src/services/product-service.ts:80:        return this.instantiate(graph);
packages/frontend/src/services/product-service.ts:103:        return this.getProduct(productType);

=== Nutzt irgendein Pfad das getProduct-Ergebnis OHNE add-product/buildCreateEvent? ===
packages/frontend/src/components/visual/ProductEditorDemo.vue:42:            command: "updateProductConfiguration",
packages/frontend/src/components/visual/Fire3D.vue:167:            command: "updateProduct",
packages/frontend/src/components/visual/ProductConfigurator.vue:33:    const graph = await productService.getProduct(productType);
packages/frontend/src/components/building-blocks/README-ProductEditor.md:49:        command: "updateProductConfiguration",


Ja, und aktuell ist es genau nicht so — es sind zwei, und die erste ist überflüssig.

**Beleg:** Einziger Aufrufer von `getProduct` ist `ProductConfigurator.vue:33`, und der geht sofort in `add-product` → `handleAddProduct` → `buildCreateEvent` → `reidentify`. Also läuft **jedes** `getProduct`-Ergebnis durch `reidentify`, das alle Handle-ids und Transform-Keys ohnehin neu vergibt. Damit ist das `i<n>-`-Präfixieren in `ProductService.instantiate` **redundant** — seine ids werden immer überschrieben. (`Fire3D.vue`/`ProductEditorDemo` sind Fire-Engine-Kommandos, nicht dieser Pfad.)

**Also:** eine Stelle, und zwar `reidentify`/`buildCreateEvent` im GroupProcessor. Die vergibt die maßgebliche ID und ist genau der Ort, an dem auch die Klasse gesetzt/aufs Event geschrieben wird.

**Folge:** `ProductService.instantiate` fällt weg — `getProduct` liefert nur noch das rohe Template, der Processor besitzt ID- **und** Eigenschafts-Vergabe an einer Stelle.


Mir wäre genau anders lieber
instantiate beibt und wird durchgängig genutzt
Reidentify hört sich fischig an
da wird edtwas sehtrr zentrales irgendwo gemacht

Nur daarauf achten, dass GRUNDSÄTZLICH UUIDS genutzt werden


Verstanden, andere Richtung: `instantiate` ist die eine Stelle für ID- und Eigenschafts-Vergabe, `reidentify` fliegt raus.

## Ergebnis-Design
- **`ProductService.instantiate`** = einzige Stelle, die Instanz-Identität setzt: pro Handle-id und Transform-key eine **UUID** (`crypto.randomUUID`, nicht mehr der `i<n>-`-Zähler), `parentKey` mitremappen — und die **Klasse** (aus dem Katalog-Node, über `getProduct` reingereicht) als Eigenschaft aufs Root-Handle stempeln.
- **`buildCreateEvent`** vergibt **keine** ids mehr: es vertraut den eingehenden UUIDs, sucht nur den `rootHandleId` (das Handle mit parentless Transform) und **liest die Klasse vom Root-Handle** aufs `group-create`-Event (damit replay-fest).
- **`reidentify` wird gelöscht.**

## Der Knackpunkt „durchgängig"
`handleKeyboardBuild` kopiert ein Teil über `sceneOfElement(...)` → `buildCreateEvent(...)`. Diese Kopie bekam ihre frischen ids bisher **durch `reidentify`**. Fällt das weg, muss der Kopier-Pfad **auch durch `instantiate`** — sonst hat die Kopie dieselben UUIDs wie das Original → Kollision.

Damit `instantiate` an beiden Stellen (ProductService und Processor-Kopie) nutzbar ist, ziehe ich die Logik in eine **freie Funktion** `instantiateScene(graph, class?)`; ProductService und der Kopier-Pfad rufen dieselbe. Das ist das „eine Stelle, durchgängig".

## UUIDs
Nach dem Umbau kommen alle ids/keys aus `crypto.randomUUID` — `instantiate` für Produkt-Handles, `nextId` (schon UUID) für Gruppen-id/-key. Der `i<n>-`-Zähler entfällt.

Vier Dateien: `product-service.ts`, `GroupProcessor.ts`, `DefaultCommandProcessor.ts`, `ProductConfigurator.vue` (+ Asset-Gen lässt den `class-`-Tag weg). Sag Go, dann setze ich es um.


ja  - das stimmt schon
oder die reindify als Methode in den Productservice
kann dann von Instatiate und buildCreateEvent genutzt werden?


Technisch ja — aber Vorsicht bei der Schichtung, sonst wird's fischiger als jetzt.

`buildCreateEvent` liegt im **GroupProcessor** (reines Model, hat heute **keine** Abhängigkeit zum ProductService). Wenn `reidentify` eine ProductService-Methode wird und `buildCreateEvent` sie aufruft, hängt das Model plötzlich am Service — Abhängigkeit in die falsche Richtung. Das ist genau die Art „etwas Zentrales an einer komischen Stelle", die dich stört, nur umgedreht.

Dazu kommt: im Ziel-Design **re-identifiziert `buildCreateEvent` gar nicht mehr**. Die Produkt-Handles kommen schon UUID-fertig aus `instantiate`; `buildCreateEvent` sucht nur noch den `rootHandleId` und hebt die Klasse aufs Event. Es braucht `reidentify` also nicht.

Die zwei echten Nutzer des Re-ID sind:
1. `instantiate` (neues Produkt),
2. der **Kopier-Pfad** `handleKeyboardBuild` (`sceneOfElement` → neue Instanz) — der braucht frische UUIDs, sonst klont er die ids des Originals.

Beide sitzen am/über dem Service-Rand, nicht im puren Model. Zwei saubere Varianten:

- **A — freie Funktion** `instantiateScene(graph, class?)`: `ProductService.instantiate` und der Kopier-Pfad importieren dieselbe. Keine Schicht „besitzt" sie, kein Model→Service-Bezug. Am leichtesten.
- **B — Methode am ProductService**: `instantiate` nutzt sie direkt; der Kopier-Pfad braucht dann den **ProductService im DefaultCommandProcessor** (heute nicht drin → müsste injiziert werden). `buildCreateEvent` ruft sie **nicht**.

Wenn dir „eine benannte Stelle" wichtiger ist als Entkopplung: B. Wenn du das Model sauber halten willst: A. `buildCreateEvent` bleibt in beiden Fällen ein reiner Konsument.


ja  - das stimmt schon oder die reindify als Methode in den Productservice kann dann von Instatiate und buildCreateEvent genutzt werden?


das Problem
wir habe es u.U. mit konfugurierten Produkten zu tun, die echt kopiert werden müssen
aber auch die müssen neu mit UUIDs versorgt werden


Genau das kippt die Entscheidung — und zwar sauber.

## Konsequenz: das Re-ID muss quellen-agnostisch und clone-basiert sein
Ein konfiguriertes Produkt kopieren heißt: **`structuredClone`** des Subtrees (die angewandten Config-Werte müssen mitkopiert, nicht geteilt werden) + **frische UUIDs** auf jede Handle-id und jeden Transform-key + `parentKey`-Remap. Das ist **dieselbe** Operation wie beim Katalog-Neuprodukt — nur die Quelle unterscheidet sich (Template aus dem Loader vs. lebender Szene-Subtree).

Also: eine Primitive `instantiateScene(scene)`, die auf **irgendeinen** Szene(-Subtree) läuft, intern klont und neu-UUID't. Sie hängt nicht an `getProduct(path)`. Genutzt von:
- Produkt-Add (Katalog-Template),
- Kopier-Pfad (`sceneOfElement` klont heute schon → dann re-id).

`buildCreateEvent` bleibt Konsument.

## Das entscheidet auch die Klassen-Frage
Wenn eine Kopie ihren **kompletten Zustand** (Config **und** Klasse) durch den Clone tragen soll, ist die Klasse am besten ein **Wert, der auf den Daten reitet** — also eine Eigenschaft am Root-Handle, gesetzt von `instantiate` beim Katalog-Neuprodukt. Dann:
- `structuredClone` trägt sie bei einer Kopie **automatisch** mit,
- `buildCreateEvent` hebt sie aufs `group-create`-Event (replay-fest),
- sie steht trotzdem **nicht im Asset-File** — sie wird zur Instanziierungszeit gesetzt.

Läge die Klasse nur in einer Seiten-Map (`rootId → class`), müsste der Kopier-Pfad sie jedesmal explizit nachschlagen und neu injizieren — fehleranfällig genau bei den Kopien, um die es dir geht.

## Damit steht das Bild
- `instantiateScene(scene, class?)`: clone + UUIDs (+ Klasse stempeln, wenn neu; bei Kopie ist sie schon dran).
- `ProductService.instantiate` und Kopier-Pfad rufen dieselbe.
- `buildCreateEvent`: nur `rootHandleId` finden + Klasse aufs Event.
- Grundsätzlich UUIDs überall.

Ein Rest-Punkt: „Eigenschaft am Root-Handle" heißt ein definiertes Feld (z. B. `properties.dockingClass`), das GroupProcessor liest — nicht wieder ein `tags`-Eintrag. Sag, wie das Feld heißen soll, dann setze ich es um.


instantiateScene ist frei und wird von SErvce und buildCr... genutzt?


Nein, nicht beide — das würde **zweimal** re-identifizieren, genau der Doppel-Rewrite, den du loswerden willst.

Der Punkt: **beide** Pfade laufen ohnehin schon durch **`buildCreateEvent`**:
- Produkt-Add: `handleAddProduct` → `buildCreateEvent`
- Kopie (auch konfiguriert): `handleKeyboardBuild` → `buildCreateEvent`

Also ist `buildCreateEvent` bereits die **eine** gemeinsame Engstelle. `instantiateScene` gehört genau **dort** rein — ein Aufruf deckt Neuprodukt und Kopie ab. Der ProductService ruft es **nicht** auch noch.

Und die Schichtung passt: `instantiateScene` ist **frei und pur** (nur `structuredClone` + UUIDs + `parentKey`-Remap), also darf das Model (`buildCreateEvent`) es aufrufen, ohne an den Service zu koppeln. Das war der wunde Punkt bei „reidentify als Service-Methode" — hier fällt er weg.

Rollen danach:
- **`instantiateScene(scene)`** — frei, pur: clone + frische UUIDs. Einzige Re-ID-Stelle, aufgerufen aus `buildCreateEvent`.
- **`buildCreateEvent`**: `instantiateScene` → `rootHandleId` finden → Klasse vom Root-Handle aufs `group-create`-Event.
- **`ProductService`**: nur Template laden + Klasse (aus Katalog-Node) aufs Root-Handle stempeln. Kein Re-ID.
- **Kopie**: Klasse + Config reisen als Daten mit dem Clone → `buildCreateEvent` hebt die Klasse aufs Event.

Damit: eine Re-ID-Stelle, UUIDs durchgängig, konfigurierte Kopien inklusive. Der ProductService verliert sein eigenes `i<n>-`-Instanziieren.

Sag Go, dann baue ich es so.


go


Los. Ich baue die Re-ID-Konsolidierung: `instantiateScene` als freie Funktion, `buildCreateEvent` nutzt sie, `reidentify` raus, `ProductService`-Instanziieren raus, UUIDs überall. Zuerst den exakten aktuellen Stand lesen.