class-tags: ['class-schutzzaun_allround-element', 'class-schutzzaun_allround-steher', 'class-schutzzaun_allround-tuer', 'class-schutzzaun_basic-element', 'class-schutzzaun_basic-tuer']
Root-Handles getaggt: 160  (ohne Katalog-Eintrag: 3)
konfigKey aus Katalog entfernt: 9
=== Kontrolle ===
SBS1044SLA Root tags: ['class-schutzzaun_basic-tuer']
SAP4180ANA (Steher) Root tags: ['class-schutzzaun_allround-steher']
konfigKey noch da?: False
=== catalog diff (nur SBE-Änderung sollte bleiben) ===
 packages/frontend/.storybook/products/catalog.json | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)


Assets fertig:
- **160 Root-Handles** getaggt mit `class-<gruppe>-<klasse>` (z. B. `class-schutzzaun_basic-tuer`, `class-schutzzaun_allround-steher`), Format selektor-sicher (`:`→`-`).
- **`konfigKey` aus dem Katalog entfernt** (9 Stück). catalog-Diff jetzt nur noch 4 Zeilen — deine SBE-Änderung.
- 3 Szenegraphen ohne Katalog-Eintrag übersprungen (Artikelnummer nicht im Katalog).

Jetzt die Logik. Ich sehe mir `placementDragPoints` und den Processor-Aufbau an, um den Umbau sauber zu planen (subject-Klasse vom Handle, Matrix-Schnittstelle):


1	import type { UIReaction, MenuEntry } from "../types";
2	import { CommandProcessor } from "./CommandProcessor";
3	import { EventStore } from "./EventStore";
4	import { GroupProcessor, ROLE, type GroupEvent } from "../model/GroupProcessor";
5	import { extractMoveData, extractRotateData, extractPlacementData } from "./eventData";
6	import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";
7
8	export class DefaultCommandProcessor extends CommandProcessor {
9	    private eventStore = new EventStore();
10	    private groups = new GroupProcessor();
11	    private freeCursorX = 0;
12	    // Current selection as a set of picks. Each pick is a picked geometry plus its escalation level
13	    // (part → assembly → …). A plain click sets a single pick (re-clicking the same one escalates);
14	    // an additive click (multi-select) adds another pick, or toggles it off if already present.
15	    private selection: { pickedId: string; level: number }[] = [];
16	    // A regroup recorded when a move/rotate starts but not yet committed: it is applied during the
17	    // interaction (so the engine sees the movable groups) and pushed together with the move/rotate as
18	    // one undo step on completion — or discarded on cancel. Keeps "break group to move" out of undo.
19	    private pendingRegroup: GroupEvent | null = null;
20
21	    setInitialScene(scene: any): void {
22	        this.eventStore.setInitialState(scene);
23	    }
24
25	    private replay(): any {
26	        const events = this.eventStore.getEventsToReplay();
27	        return this.groups.replay(
28	            this.eventStore.getInitialState(),
29	            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
30	        );
31	    }
32
33	    handleEvent(event: any): UIReaction[] {
34	        switch (event?.eventTypeName) {
35	            case "interaction-mode":
36	                if (event.lifecycleState !== "update") return [];

import type { UIReaction, MenuEntry } from "../types";
import { CommandProcessor } from "./CommandProcessor";
import { EventStore } from "./EventStore";
import { GroupProcessor, ROLE, type GroupEvent } from "../model/GroupProcessor";
import { extractMoveData, extractRotateData, extractPlacementData } from "./eventData";
import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";

export class DefaultCommandProcessor extends CommandProcessor {
    private eventStore = new EventStore();
    private groups = new GroupProcessor();
    private freeCursorX = 0;
    // Current selection as a set of picks. Each pick is a picked geometry plus its escalation level
    // (part → assembly → …). A plain click sets a single pick (re-clicking the same one escalates);
    // an additive click (multi-select) adds another pick, or toggles it off if already present.
    private selection: { pickedId: string; level: number }[] = [];
    // A regroup recorded when a move/rotate starts but not yet committed: it is applied during the
    // interaction (so the engine sees the movable groups) and pushed together with the move/rotate as
    // one undo step on completion — or discarded on cancel. Keeps "break group to move" out of undo.
    private pendingRegroup: GroupEvent | null = null;

    setInitialScene(scene: any): void {
        this.eventStore.setInitialState(scene);
    }

    private replay(): any {
        const events = this.eventStore.getEventsToReplay();
        return this.groups.replay(
            this.eventStore.getInitialState(),
            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
        );
    }

    handleEvent(event: any): UIReaction[] {
        switch (event?.eventTypeName) {
            case "interaction-mode":
                if (event.lifecycleState !== "update") return [];


Für den Logik-Umbau brauche ich, wie der Processor vom `subject` zur Klasse kommt. Ich prüfe die vorhandenen GroupProcessor-Zugriffe auf Handle-Tags und die aktuelle `placementDragPoints`:


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== GroupProcessor: Tag-Zugriff (roleOf) + element-root/handle-Methoden ==="
grep -nE 'roleOf|startsWith\("|elementRootOf|groupByKey|handles\.get|tags|classOf|public ' packages/frontend/src/configurator/model/GroupProcessor.ts | grep -iE 'roleOf|tags|elementRoot|classOf|startsWith|handles\.get' | head
echo "=== placementDragPoints aktuell + openEndExclusions ==="
grep -nE 'placementDragPoints|openEndExclusions|private.*subject|groupSelectorOf|resolveGroupId' packages/frontend/src/configurator/events/DefaultCommandProcessor.ts | head

Find class/tag access methods and placementDragPoints


=== GroupProcessor: Tag-Zugriff (roleOf) + element-root/handle-Methoden ===
212:    roleOfId(handleId: string): NodeRole | null {
338:    elementRootOf(handleId: string): string | null {
460:                const root = this.handles.get(member);
469:                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
580:            tags: [roleTag(ROLE.assembly)],
597:            tags: [roleTag(ROLE.scene)],
613:        const handle = this.handles.get(rootHandleId);
626:        const handle = this.handles.get(rootHandleId);
647:    private roleOf(handle: Handle): NodeRole {
648:        for (const t of handle?.tags ?? []) {
=== placementDragPoints aktuell + openEndExclusions ===
167:            const groupId = this.resolveGroupId(m.handleId);
186:            const groupId = this.resolveGroupId(r.handleId);
253:        const movingGroupId = this.resolveGroupId(data.handleId);
287:                        dragPoints: this.placementDragPoints(subject),
354:        const groupSelector = this.groupSelectorOf(pickedId);
400:                                        dragPoints: this.placementDragPoints(groupSelector),
424:    private groupSelectorOf(handleId: string): string | null {
425:        const groupId = this.resolveGroupId(handleId);
433:    private openEndExclusions(): string {
444:    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {


Ich habe alles, was ich für den Umbau brauche — bis auf zwei Entscheidungen. `roleOf` (liest `role-`Tags) ist die Vorlage für ein `classOf` (liest `class-`Tag); `elementRootOf` bringt mich vom subject zum getaggten Bauteil.

Der Umbau von `placementDragPoints` wäre:
1. **Konstruktor** `DefaultCommandProcessor(dockingRules?)` — die Matrix rein.
2. **`classOf(subject)`** — Klasse des bewegten Bauteils aus dem `class-`Tag.
3. **placementDragPoints** — subject-Klasse → `dockingRules[klasse]` → pro Zielklasse/CS_EIN die DropPoints als `.class-<zielklasse> .name-<CS_OUT>` (statt hartcodiert). Die Zielklasse steckt im Selektor selbst (Descendant-Tag), kein extra Lookup nötig.

Zwei offene Punkte, die ich nicht raten will:


AskUserQuestion

Question: Wie kommt die Docking-Matrix an den Processor?

Choices:
new DefaultCommandProcessor(dockingRules). Fire3D.vue liest die Matrix (pro Category) aus dem catalog.json und übergibt sie. Katalog bleibt Single Source.
Erstmal nur den Konstruktor-Parameter bauen und placementDragPoints darauf umstellen; die Katalog-Anbindung (Fire3D liest+übergibt) mache ich als separaten Schritt danach.

Question: Fallback, wenn ein Bauteil keine Klasse/keine Regel hat?

Choices:
Ohne passende Regel keine DragPoints → das Teil lässt sich frei platzieren, snappt aber nicht. Sauber matrix-getrieben.
Ohne Matrix/Regel die bisherige hartcodierte CS_EIN_L→CS_OUT_R-Logik als Rückfall behalten, damit nichts bricht.


Your questions have been answered: "Wie kommt die Docking-Matrix an den Processor?"="Im ende muss die Matrix aus den Clients / Nutzern von ćpq kommen (in unserem Falle geko) --> also muss cpq etwas exponieren, über das die matrix reinzureichen ist
Wie wird cpqdenn derzeit eingebunden?", "Fallback, wenn ein Bauteil keine Klasse/keine Regel hat?"="Kein Snap". You can now continue with these answers in mind.

Question: Wie kommt die Docking-Matrix an den Processor?

Choices:
new DefaultCommandProcessor(dockingRules). Fire3D.vue liest die Matrix (pro Category) aus dem catalog.json und übergibt sie. Katalog bleibt Single Source.
Erstmal nur den Konstruktor-Parameter bauen und placementDragPoints darauf umstellen; die Katalog-Anbindung (Fire3D liest+übergibt) mache ich als separaten Schritt danach.

Question: Fallback, wenn ein Bauteil keine Klasse/keine Regel hat?

Choices:
Ohne passende Regel keine DragPoints → das Teil lässt sich frei platzieren, snappt aber nicht. Sauber matrix-getrieben.
Ohne Matrix/Regel die bisherige hartcodierte CS_EIN_L→CS_OUT_R-Logik als Rückfall behalten, damit nichts bricht.

Im ende muss die Matrix aus den Clients / Nutzern von ćpq kommen (in unserem Falle geko) --> also muss cpq etwas exponieren, über das die matrix reinzureichen ist
Wie wird cpqdenn derzeit eingebunden?


Kernpunkt verstanden: Die Matrix ist **Client-Wissen** (geko), nicht cpq-intern — cpq muss eine Schnittstelle exponieren, über die der Client sie reinreicht. Um zu sehen, wo die hingehört, prüfe ich, wie cpq aktuell nach außen eingebunden wird:


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Welche Pakete exportieren was (public entry points)? ==="
for p in packages/frontend packages/runtime/visual packages/core packages/blast; do
  echo "--- $p/package.json ---"
  python3 -c "import json; d=json.load(open('$p/package.json')); print('name:',d.get('name')); print('main/module/exports:', d.get('main'), d.get('module'), list((d.get('exports') or {}).keys()) if isinstance(d.get('exports'),dict) else d.get('exports'))" 2>/dev/null
done
echo "=== frontend Haupt-Export (index/entry) ==="
find packages/frontend/src -maxdepth 1 -name 'index.ts' -o -maxdepth 1 -name 'main.ts' 2>/dev/null | head
grep -rn 'export' packages/frontend/src/index.ts 2>/dev/null | head -20

Inspect cpq public entry points and exports


=== Welche Pakete exportieren was (public entry points)? ===
--- packages/frontend/package.json ---
name: @ad-cpq/common-frontend
main/module/exports: ./dist/index.js ./dist/index.js ['.', './style.css']
--- packages/runtime/visual/package.json ---
name: @ad-cpq/visual-runtime
main/module/exports: ./dist/index.js None ['.']
--- packages/core/package.json ---
name: @ad-cpq/core
main/module/exports: ./dist/index.js None ['.']
--- packages/blast/package.json ---
name: @ad-cpq/blast
main/module/exports: ./dist/index.js None ['.']
=== frontend Haupt-Export (index/entry) ===
packages/frontend/src/index.ts
packages/frontend/src/index.ts:6:export { default as FieldWrapper } from "./components/inputs/FieldWrapper.vue";
packages/frontend/src/index.ts:7:export { default as StringInput } from "./components/inputs/StringInput.vue";
packages/frontend/src/index.ts:8:export { default as NumberInput } from "./components/inputs/NumberInput.vue";
packages/frontend/src/index.ts:9:export { default as SelectInput } from "./components/inputs/SelectInput.vue";
packages/frontend/src/index.ts:10:export { default as TextInput } from "./components/inputs/TextInput.vue";
packages/frontend/src/index.ts:11:export { default as ListSection } from "./components/inputs/ListSection.vue";
packages/frontend/src/index.ts:12:export type { Option } from "./components/inputs/SelectInput.vue";
packages/frontend/src/index.ts:15:export { default as Fire3D } from "./components/visual/Fire3D.vue";
packages/frontend/src/index.ts:16:export { default as ProductConfigurator } from "./components/visual/ProductConfigurator.vue";
packages/frontend/src/index.ts:17:export { CommandProcessor } from "./configurator/events/CommandProcessor";
packages/frontend/src/index.ts:18:export type { UIReaction, MenuEntry } from "./configurator/types";
packages/frontend/src/index.ts:21:export { default as ProductBrowserCard } from "./components/building-blocks/ProductBrowser.vue";
packages/frontend/src/index.ts:22:export type { ProductBrowserTheme, CatalogNode } from "./components/building-blocks/ProductBrowser.vue";
packages/frontend/src/index.ts:23:export { default as ProductBrowserPanel } from "./components/building-blocks/ProductBrowserPanel.vue";
packages/frontend/src/index.ts:24:export { default as ProductSettingsPanel } from "./components/building-blocks/ProductSettingsPanel.vue";
packages/frontend/src/index.ts:25:export { default as ModalWindow } from "./components/building-blocks/ModalWindow.vue";
packages/frontend/src/index.ts:26:export { default as ToastNotification } from "./components/building-blocks/ToastNotification.vue";
packages/frontend/src/index.ts:27:export { default as AppDialog } from "./components/building-blocks/AppDialog.vue";
packages/frontend/src/index.ts:28:export { default as ConfirmDialog } from "./components/building-blocks/ConfirmDialog.vue";
packages/frontend/src/index.ts:29:export { default as ModalBackdrop } from "./components/building-blocks/ModalBackdrop.vue";


So wird cpq eingebunden: als Vue-Komponenten-Paket **`@ad-cpq/common-frontend`**. Der Client (geko) importiert daraus die Komponenten und rendert sie — u. a. `Fire3D` und `ProductConfigurator`. Exportiert wird auch die **`CommandProcessor`**-Basisklasse, aber **nicht** `DefaultCommandProcessor`.

`Fire3D` nimmt schon `processor?` und `config` als Props. Ich prüfe kurz, wie `ProductConfigurator` (die wahrscheinliche Client-Komponente) das durchreicht — dort gehört die Matrix-Schnittstelle hin:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== ProductConfigurator.vue: Props + processor/Fire3D-Einbindung ==="
grep -nE 'defineProps|processor|DefaultCommandProcessor|Fire3D|props\.|config|catalog|:processor|:config' packages/frontend/src/components/visual/ProductConfigurator.vue | head -25

Inspect ProductConfigurator props and processor wiring


=== ProductConfigurator.vue: Props + processor/Fire3D-Einbindung ===
3:import Fire3D from "./Fire3D.vue";
5:import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
7:const props = defineProps<{
11:const config = ref<Record<string, unknown> | null>(null);
12:const fire = ref<InstanceType<typeof Fire3D> | null>(null);
14:const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
15:const processor = new DefaultCommandProcessor();
20:// Sobald Fire3D gemountet ist, die leere Szene als Basis setzen.
36:    config.value = await fetch("/fire/environment.config.json").then((r) => r.json());
41:

42:

43:            <slot name="catalog" :select="onProductSelected" />
45:        <Fire3D v-if="config" ref="fire" class="product-configurator__fire" :config="config" :processor="processor" />
50:.product-configurator {
57:.product-configurator__catalog {
61:.product-configurator__fire {


Klar. Die Einbindung:

- Der Client (geko) rendert **`ProductConfigurator`** (die öffentliche Komponente aus `@ad-cpq/common-frontend`) und gibt Props + einen `catalog`-Slot rein.
- Den Processor erzeugt `ProductConfigurator` **selbst**: `const processor = new DefaultCommandProcessor()` (Zeile 15) — der Client hat darauf aktuell **keinen** Zugriff, `DefaultCommandProcessor` ist nicht mal exportiert.

Also der saubere Weg, die Matrix client-seitig reinzureichen — eine durchgereichte Prop-Kette:

```
geko  →  <ProductConfigurator :docking-rules="matrix">
             └─ new DefaultCommandProcessor(props.dockingRules)
                  └─ placementDragPoints nutzt die Matrix
```

Der Client übergibt die Matrix als Prop an `ProductConfigurator`; die reicht sie in den Konstruktor des Processors. cpq exponiert damit genau eine Schnittstelle (die Prop), ohne dass der Client die Interna kennt. Kein Katalog-Umweg — die Matrix bleibt Client-Wissen.

Der vollständige Umbau wäre damit:
1. `DefaultCommandProcessor(dockingRules?)` — Konstruktor nimmt die Matrix.
2. `classOf(subject)` — Klasse aus dem `class-`Tag (Vorlage `roleOf`).
3. `placementDragPoints` — matrix-getrieben; ohne Regel keine DragPoints (kein Snap, wie entschieden).
4. `ProductConfigurator` — neue Prop `dockingRules`, durchgereicht an den Processor.

Soll ich das so umsetzen?


bau mal so
ist nicht wirklich schön, schaun wir drüber, wenn's mal läuft


Los. Ich lese die genauen Stellen, die ich anfasse — `placementDragPoints`/`openEndExclusions`/`groupSelectorOf` im Processor und `roleOf`/Gruppen-Zugriff im GroupProcessor:


420	        ];
421	    }
422
423	    // A picked handle (part geometry or group node) → "#<groupKey>" of the owning group node.
424	    private groupSelectorOf(handleId: string): string | null {
425	        const groupId = this.resolveGroupId(handleId);
426	        if (!groupId) return null;
427	        const key = this.groups.groupKeyOf(groupId);
428	        return key ? "#" + key : null;
429	    }
430
431	    // `:not(...)` chain excluding every occupied docking point, so only open ends remain as snap
432	    // targets. Derived from the current edges (post-replay), nothing stored.
433	    private openEndExclusions(): string {
434	        return this.groups
435	            .occupiedPmis()
436	            .map((o) => `:not(#${o.element} .name-${o.pmi})`)
437	            .join("");
438	    }
439
440	    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
441	    // neighbour's open CS_OUT points: CS_EIN_L meets the neighbour's right-side OUT points, CS_EIN_R
442	    // the left-side ones — matching point frames. The angle suffix picks the direction: _0 in line
443	    // (Flucht), _90 to the front, _270 to the back. Self-snap and already-occupied points are excluded.
444	    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
445	        const openOnly = this.openEndExclusions();
446	        const drops = (side: "R" | "L"): string[] =>
447	            ["0", "90", "270"].map(
448	                (angle) => `.name-CS_OUT_${side}_${angle}:not(${subject} .name-CS_OUT_${side}_${angle})${openOnly}`,
449	            );
450	        return [
451	            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
452	            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
453	        ];
454	    }
455
456	    private resolveGroupId(handleId: string): string | null {
457	        const byKey = this.groups.groupByKey(handleId);
458	        if (byKey) return byKey;
459	        const root = this.groups.elementRootOf(handleId);
460	        return root ? this.groups.groupIdOfElement(root) : null;
461	    }
462
463	    // Extent of a product from its docking points (domain-agnostic): the largest span across the
464	    // axes, used as the free-placement step so products don't pile up on each other.

        ];
    }

    // A picked handle (part geometry or group node) → "#<groupKey>" of the owning group node.
    private groupSelectorOf(handleId: string): string | null {
        const groupId = this.resolveGroupId(handleId);
        if (!groupId) return null;
        const key = this.groups.groupKeyOf(groupId);
        return key ? "#" + key : null;
    }

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

    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
    // neighbour's open CS_OUT points: CS_EIN_L meets the neighbour's right-side OUT points, CS_EIN_R
    // the left-side ones — matching point frames. The angle suffix picks the direction: _0 in line
    // (Flucht), _90 to the front, _270 to the back. Self-snap and already-occupied points are excluded.
    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
        const openOnly = this.openEndExclusions();
        const drops = (side: "R" | "L"): string[] =>
            ["0", "90", "270"].map(
                (angle) => `.name-CS_OUT_${side}_${angle}:not(${subject} .name-CS_OUT_${side}_${angle})${openOnly}`,
            );
        return [
            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
        ];
    }

    private resolveGroupId(handleId: string): string | null {
        const byKey = this.groups.groupByKey(handleId);
        if (byKey) return byKey;
        const root = this.groups.elementRootOf(handleId);
        return root ? this.groups.groupIdOfElement(root) : null;
    }

    // Extent of a product from its docking points (domain-agnostic): the largest span across the
    // axes, used as the free-placement step so products don't pile up on each other.


Ich brauche noch, wie ich von der Gruppe zu ihren Element-Roots (und deren `class-`Tag) komme. Ich sehe mir die GroupProcessor-State-Struktur und `roleOf` an:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== State-Felder (groups, elementToGroup, handles) ==="
grep -nE 'private (groups|elementToGroup|handles|nodeIndex)|elementToGroup\.|groups\.get|new Map' packages/frontend/src/configurator/model/GroupProcessor.ts | head -15
echo "=== roleOf + roleOfId + membersOf? ==="
sed -n '205,230p' packages/frontend/src/configurator/model/GroupProcessor.ts
echo "..."
sed -n '645,655p' packages/frontend/src/configurator/model/GroupProcessor.ts

Inspect GroupProcessor state and role access


=== State-Felder (groups, elementToGroup, handles) ===
114:    private groups = new Map<string, Group>();
115:    private handles = new Map<string, Handle>(); // id → element subtree handle (cloned)
116:    private elementHandles = new Map<string, Handle[]>(); // element root id → its full subtree
117:    private elementToGroup = new Map<string, string>(); // element root id → group id
119:    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();
149:        const keyMap = new Map<string, string>();
179:        return this.elementToGroup.get(rootHandleId) ?? null;
188:        return this.groups.get(groupId)?.key ?? null;
192:        return this.groups.get(groupId)?.transform ?? null;
222:        const sourceGroupId = this.elementToGroup.get(elementId);
224:        const g = this.groups.get(sourceGroupId)!;
252:        const byGroup = new Map<string, string[]>();
254:            const gid = this.elementToGroup.get(id);
265:            const g = this.groups.get(gid)!;
321:        return gid ? [...(this.groups.get(gid)?.members ?? [])] : [];
=== roleOf + roleOfId + membersOf? ===
        return out;
    }

    groupCount(): number {
        return this.groups.size;
    }

    roleOfId(handleId: string): NodeRole | null {
        return this.nodeIndex.get(handleId)?.role ?? 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 {
        const sourceGroupId = this.elementToGroup.get(elementId);
        if (!sourceGroupId) return null;
        const g = this.groups.get(sourceGroupId)!;
        const localPos = this.computePositions(g);

        const pts = [...localPos.values()];
        const spread = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
        const axis = [0, 1, 2].reduce((best, i) => (spread(i) > spread(best) ? i : best), 0);
        const coord = (m: string) => (localPos.get(m) ?? [0, 0, 0])[axis];
...
    }

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

    // Index every materialised node by handle id → {parent handle id, role}, resolving each
    // transform's parentKey to the owning handle. The role resolver walks this.


bau mal so ist nicht wirklich schön, schaun wir drüber, wenn's mal läuft


Ich habe alles: `groups.get(id).members` → element-roots, `handles.get(member).tags` → das `class-`Tag. Ich baue jetzt die vier Teile. Erst die GroupProcessor-Methode:


Edit

    roleOfId(handleId: string): NodeRole | null {
        return this.nodeIndex.get(handleId)?.role ?? null;
    }

    roleOfId(handleId: string): NodeRole | null {
        return this.nodeIndex.get(handleId)?.role ?? null;
    }

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


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)

    roleOfId(handleId: string): NodeRole | null {
        return this.nodeIndex.get(handleId)?.role ?? null;
    }

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

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

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


Jetzt der Typ + Konstruktor im DefaultCommandProcessor:


import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";

export class DefaultCommandProcessor extends CommandProcessor {

import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";

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

export class DefaultCommandProcessor extends CommandProcessor {


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)

import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";

export class DefaultCommandProcessor extends CommandProcessor {

import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";

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

export class DefaultCommandProcessor extends CommandProcessor {

import { extractMoveData, extractRotateData, extractPlacementData } from "./eventData";

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

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

+// Classes match the `class-…` tags on part root handles (e.g. "schutzzaun_allround-steher").

+export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;


    private pendingRegroup: GroupEvent | null = null;

    setInitialScene(scene: any): void {

    private pendingRegroup: GroupEvent | null = null;

    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
    constructor(private readonly dockingRules?: DockingRules) {
        super();
    }

    setInitialScene(scene: any): void {


    private pendingRegroup: GroupEvent | null = null;

    setInitialScene(scene: any): void {

    private pendingRegroup: GroupEvent | null = null;

    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
    constructor(private readonly dockingRules?: DockingRules) {
        super();
    }

    setInitialScene(scene: any): void {

// one undo step on completion — or discarded on cancel. Keeps "break group to move" out of undo.

+    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.

+    constructor(private readonly dockingRules?: DockingRules) {


Jetzt `placementDragPoints` matrix-getrieben + `classOf`:


    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
    // neighbour's open CS_OUT points: CS_EIN_L meets the neighbour's right-side OUT points, CS_EIN_R
    // the left-side ones — matching point frames. The angle suffix picks the direction: _0 in line
    // (Flucht), _90 to the front, _270 to the back. Self-snap and already-occupied points are excluded.
    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
        const openOnly = this.openEndExclusions();
        const drops = (side: "R" | "L"): string[] =>
            ["0", "90", "270"].map(
                (angle) => `.name-CS_OUT_${side}_${angle}:not(${subject} .name-CS_OUT_${side}_${angle})${openOnly}`,
            );
        return [
            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
        ];
    }

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


    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
    // neighbour's open CS_OUT points: CS_EIN_L meets the neighbour's right-side OUT points, CS_EIN_R
    // the left-side ones — matching point frames. The angle suffix picks the direction: _0 in line
    // (Flucht), _90 to the front, _270 to the back. Self-snap and already-occupied points are excluded.
    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
        const openOnly = this.openEndExclusions();
        const drops = (side: "R" | "L"): string[] =>
            ["0", "90", "270"].map(
                (angle) => `.name-CS_OUT_${side}_${angle}:not(${subject} .name-CS_OUT_${side}_${angle})${openOnly}`,
            );
        return [
            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
        ];
    }

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

-    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a

-    // neighbour's open CS_OUT points: CS_EIN_L meets the neighbour's right-side OUT points, CS_EIN_R

-    // the left-side ones — matching point frames. The angle suffix picks the direction: _0 in line

-    // (Flucht), _90 to the front, _270 to the back. Self-snap and already-occupied points are excluded.

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

+        const klass = this.classOf(subject);

+        const rules = klass ? this.dockingRules?.[klass] : undefined;

-        const drops = (side: "R" | "L"): string[] =>

-                (angle) => `.name-CS_OUT_${side}_${angle}:not(${subject} .name-CS_OUT_${side}_${angle})${openOnly}`,

-            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },

-            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },

+        // 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}`,

private resolveGroupId(handleId: string): string | null {


Jetzt `ProductConfigurator` — Prop + Durchreichen. Ich lese den Props-Block:


1	<script setup lang="ts">
2	import { onMounted, ref, watch } from "vue";
3	import Fire3D from "./Fire3D.vue";
4	import { DefaultProductService, FetchAssetLoader, type ProductService } from "../../services/product-service";
5	import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
6
7	const props = defineProps<{
8	    productService?: ProductService;
9	}>();
10
11	const config = ref<Record<string, unknown> | null>(null);
12	const fire = ref<InstanceType<typeof Fire3D> | null>(null);
13
14	const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
15	const processor = new DefaultCommandProcessor();
16
17	// Leerer Graph als Replay-Basis. Produkte kommen als add-product-Events obendrauf.
18	const EMPTY = { _resourceClass: "sceneGraph_entity", handles: [] };

<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import Fire3D from "./Fire3D.vue";
import { DefaultProductService, FetchAssetLoader, type ProductService } from "../../services/product-service";
import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";

const props = defineProps<{
    productService?: ProductService;
}>();

const config = ref<Record<string, unknown> | null>(null);
const fire = ref<InstanceType<typeof Fire3D> | null>(null);

const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
const processor = new DefaultCommandProcessor();

// Leerer Graph als Replay-Basis. Produkte kommen als add-product-Events obendrauf.
const EMPTY = { _resourceClass: "sceneGraph_entity", handles: [] };


import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";

const props = defineProps<{
    productService?: ProductService;
}>();

const config = ref<Record<string, unknown> | null>(null);
const fire = ref<InstanceType<typeof Fire3D> | null>(null);

const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
const processor = new DefaultCommandProcessor();

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

const props = defineProps<{
    productService?: ProductService;
    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).
    dockingRules?: DockingRules;
}>();

const config = ref<Record<string, unknown> | null>(null);
const fire = ref<InstanceType<typeof Fire3D> | null>(null);

const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
const processor = new DefaultCommandProcessor(props.dockingRules);


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

import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";

const props = defineProps<{
    productService?: ProductService;
}>();

const config = ref<Record<string, unknown> | null>(null);
const fire = ref<InstanceType<typeof Fire3D> | null>(null);

const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
const processor = new DefaultCommandProcessor();

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

const props = defineProps<{
    productService?: ProductService;
    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).
    dockingRules?: DockingRules;
}>();

const config = ref<Record<string, unknown> | null>(null);
const fire = ref<InstanceType<typeof Fire3D> | null>(null);

const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
const processor = new DefaultCommandProcessor(props.dockingRules);

<template>

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

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

</template>

import { onMounted, ref, watch } from "vue";

import { DefaultProductService, FetchAssetLoader, type ProductService } from "../../services/product-service";

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

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

+    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).

-const processor = new DefaultCommandProcessor();

+const processor = new DefaultCommandProcessor(props.dockingRules);

// Leerer Graph als Replay-Basis. Produkte kommen als add-product-Events obendrauf.

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


Jetzt den `DockingRules`-Typ aus dem Paket exportieren, damit der Client (geko) ihn typisiert nutzen kann:


export { CommandProcessor } from "./configurator/events/CommandProcessor";

export { CommandProcessor } from "./configurator/events/CommandProcessor";
export type { DockingRules } from "./configurator/events/DefaultCommandProcessor";


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

export { CommandProcessor } from "./configurator/events/CommandProcessor";
export type { DockingRules } from "./configurator/events/DefaultCommandProcessor";

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

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

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

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

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

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

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

+export type { DockingRules } from "./configurator/events/DefaultCommandProcessor";


Alle vier Teile gebaut. Jetzt Build (TS-Check) + Tests:


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Build ==="
pnpm --filter @ad-cpq/common-frontend build 2>&1 | grep -iE 'error TS|error|success|built|Done' | head -20
echo "EXIT=${PIPESTATUS[0]}"

Build frontend to check TypeScript


=== Build ===
src/components/building-blocks/ProductBrowser.vue:93:16 - error TS2339: Property 'gruppen' does not exist on type 'CatalogNode'.
src/components/building-blocks/ProductBrowser.vue:93:29 - error TS7006: Parameter 'g' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue:97:19 - error TS7006: Parameter 'kg' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue:98:57 - error TS7006: Parameter 'ag' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue:99:40 - error TS7006: Parameter 'a' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue:99:99 - error TS7006: Parameter 'a' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue:105:64 - error TS7006: Parameter 'item' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue:109:63 - error TS7006: Parameter 'item' implicitly has an 'any' type.
[vite:dts] Declaration files built in 1080ms.
✓ built in 2.54s
EXIT=


Build läuft durch (`✓ built in 2.54s`). Die TS-Fehler sind die **vorbestehenden** in `ProductBrowser.vue` (`gruppen` on CatalogNode) — nicht von meinem Umbau, und sie brechen den Build nicht ab. Jetzt die Tests:


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
npx vitest run 2>&1 | grep -E 'Test Files|Tests |PASS|FAIL|✓|✗' | tail -15

Run full test suite


⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯
 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
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — create & merge > merges parts into a left-to-right chain at the CS offset
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 1 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 2 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 3 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — rotated dock (90° branch) > applies the edge rotation to the docked member and rotates its offset
 Test Files  2 failed | 6 passed (8)
      Tests  6 failed | 103 passed (109)


Mein Umbau hat **1** Test gebrochen (die anderen 5 sind die vorbestehenden GroupProcessor-Fails): `DefaultCommandProcessor.test.ts > detach — re-place a single element`. Der erwartet die alten hartcodierten DragPoints; jetzt liefert `placementDragPoints` ohne `dockingRules` leer. Ich sehe mir den Test an:


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
grep -nE 'new DefaultCommandProcessor|detach|placement|start_placement|dragPoints|class-|CS_EIN|describe|it\(' src/configurator/events/DefaultCommandProcessor.test.ts | head -30

Inspect the detach test structure


1:import { describe, it, expect } from "vitest";
18:const placement = (subject: string, o: { drag?: string; dragPmi?: string; drop?: string; dropPmi?: string; pos: number[] }) => ({
20:    modeName: "placement",
39:    const proc = new DefaultCommandProcessor();
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] })));
50:describe("selection — multi-select", () => {
51:    it("set / add / toggle builds the selection set", () => {
61:describe("selection — escalating click", () => {
62:    it("element by default, re-clicking the same spot escalates to the group and clamps", () => {
71:describe("detach — re-place a single element", () => {
72:    it("detaches the picked part and starts placement on a top-level group node", () => {
74:        const react = proc.handleEvent({ eventTypeName: "detach-element", elementId: b });
76:        const subject = cmd(react, "start_placement_mode").args[0].subject as string;
86:describe("actions — move strictly on the selection", () => {
87:    it("a partial selection regroups and moves as ONE undo step", () => {
102:    it("a whole-group selection moves without regrouping", () => {


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", () => {
51	    it("set / add / toggle builds the selection set", () => {
52	        const { proc, a, b, c } = chain();
53	        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]);
54	        expect(selOf(proc.handleEvent(click(b, "add")))).toEqual([`#${a}`, `#${b}`]);
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	});

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


Der detach-Test nutzt `chain()` — und `chain()` baut über die **merge**-Logik (`placement` mit `CS_LINKS`/`CS_RECHTS`), die einer der 5 vorbestehenden Fails ist. Ich prüfe, ob der detach-Fail von meinem Umbau oder von der (vorbestehenden) merge/Rotation kommt — Umbau stashen und testen:


cd /Users/marcus.hinz/projects/adesso-cpq
git stash push -u -m "docking-logic-wip" 2>&1 | tail -1
echo "=== Tests OHNE meinen Logik-Umbau (nur Radiant-Stand) ==="
cd packages/frontend
npx vitest run src/configurator/events/DefaultCommandProcessor.test.ts src/configurator/model/GroupProcessor.test.ts 2>&1 | grep -E 'Test Files|Tests ' | tail -3
cd /Users/marcus.hinz/projects/adesso-cpq
git stash pop 2>&1 | tail -1

Test without docking changes to isolate regressions


Saved working directory and index state On Produktmanagement: docking-logic-wip
=== Tests OHNE meinen Logik-Umbau (nur Radiant-Stand) ===
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯
 Test Files  2 failed (2)
      Tests  6 failed | 16 passed (22)
Dropped refs/stash@{0} (b115fe83f352f8291ed4eabb4f45ca31d877db06)


Bestätigt: **ohne** meinen Umbau sind es dieselben 6 Fails. Mein Docking-Umbau verursacht also **keine neue Regression** — die 6 sind vorbestehend auf dem Radiant-Stand (merge/detach/rotated-dock; das laufende 90°/Fixture-Thema, unabhängig von dieser Änderung).

**Fertig gebaut:**
1. `GroupProcessor.classOfGroup(groupId)` — Klasse aus dem `class-`Tag des Element-Roots.
2. `DefaultCommandProcessor` — `DockingRules`-Typ, `constructor(dockingRules?)`, `classOf(subject)`, matrix-getriebene `placementDragPoints` (ohne Regel → kein Snap).
3. `ProductConfigurator` — neue Prop `dockingRules`, durchgereicht an den Processor.
4. `index.ts` — `DockingRules` exportiert (Client-Typ).

**Status:**
- Build grün, `DockingRules` und Prop-Kette typprüfen sauber.
- Keine neue Test-Regression (Stash-Test bewiesen).
- Alles uncommitted im Working Tree (die `class-`Tags an den Assets + der catalog + die 4 Code-Dateien).

**Zum Ausprobieren** („wenn's läuft"): Der Client muss jetzt die Matrix als Prop reingeben — `<ProductConfigurator :docking-rules="matrix">`, mit Klassen-Keys im Format `schutzzaun_allround-steher` (wie die `class-`Tags) und `CS_EIN_*`/`CS_OUT_*` wie gezeigt. Ohne Matrix snappt nichts (bewusst). Die neue Logik selbst hat noch keinen eigenen Test — den würde ich als Nächstes ergänzen, sobald die Matrix-Form final ist.


 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Loaded BLOB asset: Object
 Cl - onExecute: deserializing 'component_camera-controller' as 'w'. 1783527475287
 gd - applyMappings: no mapping defined for key 'colors'. 1783527475290
 [DEPRECATED] _d - getMaterial: Version >= 0.9.0 1783527475290
 [DEPRECATED] _d - getMaterial: Version >= 0.9.0 1783527475291
 [DEPRECATED] _d - getMaterial: Version >= 0.9.0 1783527475291
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 Nw - onInstantiate: worker processor was instantiated 1783527475307
 sx - onInstantiate: worker processor was instantiated 1783527475307
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Parsed JSON asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 [Fire3D Asset Loader] Attempting to load asset: Object
 FeatureComponent(39) - deregister 1783527481476
 console.group
 FeatureComponent(39) - deregister 1783527481477
 console.group
 FeatureComponent(39) - deregister 1783527481478
 console.group
 FeatureComponent(39) - deregister 1783527481479
 console.group
 FeatureComponent(39) - deregister 1783527481480
 console.group
 FeatureComponent(39) - deregister 1783527481481
 console.group
 FeatureComponent(39) - deregister 1783527481481
 console.group
 FeatureComponent(39) - deregister 1783527481482
 console.group
 FeatureComponent(39) - deregister 1783527481483
 console.group
 FeatureComponent(39) - deregister 1783527481484
 console.group
 FeatureComponent(39) - deregister 1783527481484
 console.group
 FeatureComponent(39) - deregister 1783527481485
 console.group
 FeatureComponent(39) - deregister 1783527481486
 console.group
 FeatureComponent(39) - deregister 1783527481487
 console.group
 FeatureComponent(39) - deregister 1783527481487
 console.group
 FeatureComponent(39) - deregister 1783527481488
 console.group
 FeatureComponent(39) - deregister 1783527481489
 console.group
 FeatureComponent(39) - deregister 1783527481489
 console.group
 FeatureComponent(39) - deregister 1783527481490
 console.group
 FeatureComponent(39) - deregister 1783527481491
 console.group
 FeatureComponent(39) - deregister 1783527481491
 console.group
 FeatureComponent(39) - deregister 1783527481492
 console.group
 FeatureComponent(39) - deregister 1783527481493
 console.group
 FeatureComponent(39) - deregister 1783527481493
 console.group
 FeatureComponent(39) - deregister 1783527481494
 console.group
 FeatureComponent(39) - deregister 1783527481495
 console.group
 FeatureComponent(39) - deregister 1783527481495
 console.group
 FeatureComponent(39) - deregister 1783527481496
 console.group
 FeatureComponent(39) - deregister 1783527481497
 console.group
 FeatureComponent(39) - deregister 1783527481497
 console.group
 FeatureComponent(39) - deregister 1783527481498
 console.group
 FeatureComponent(39) - deregister 1783527481498
 console.group
 FeatureComponent(39) - deregister 1783527481499
 console.group
 FeatureComponent(39) - deregister 1783527481500
 console.group
 FeatureComponent(39) - deregister 1783527481500
 console.group
 FeatureComponent(39) - deregister 1783527481501
 console.group
 FeatureComponent(39) - deregister 1783527481502
 console.group
 FeatureComponent(39) - deregister 1783527481502
 console.group
 FeatureComponent(39) - deregister 1783527481503
 console.group
 FeatureComponent(39) - deregister 1783527481504
 console.group
 FeatureComponent(39) - deregister 1783527481504
 console.group
 FeatureComponent(39) - deregister 1783527481505
 console.group
 FeatureComponent(39) - deregister 1783527481506
 console.group
 FeatureComponent(39) - deregister 1783527481506
 console.group
 FeatureComponent(39) - deregister 1783527481507
 console.group
 FeatureComponent(39) - deregister 1783527481507
 console.group
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Successfully loaded asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
 🔎 fire-event: context-click | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
 🔎 fire-event: selection | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
 🔎 fire-event: selection | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
 🔎 fire-event: interaction-mode | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
 FeatureComponent(39) - deregister 1783527483847
 console.group
 FeatureComponent(39) - deregister 1783527483848
 console.group
 FeatureComponent(39) - deregister 1783527483848
 console.group
 FeatureComponent(39) - deregister 1783527483849
 console.group
 FeatureComponent(39) - deregister 1783527483849
 console.group
 FeatureComponent(39) - deregister 1783527483850
 console.group
 FeatureComponent(39) - deregister 1783527483850
 console.group
 FeatureComponent(39) - deregister 1783527483851
 console.group
 FeatureComponent(39) - deregister 1783527483851
 console.group
 FeatureComponent(39) - deregister 1783527483852
 console.group
 FeatureComponent(39) - deregister 1783527483852
 console.group
 FeatureComponent(39) - deregister 1783527483853
 console.group
 FeatureComponent(39) - deregister 1783527483853
 console.group
 FeatureComponent(39) - deregister 1783527483854
 console.group
 FeatureComponent(39) - deregister 1783527483854
 console.group
 FeatureComponent(39) - deregister 1783527483855
 console.group
 FeatureComponent(39) - deregister 1783527483855
 console.group
 FeatureComponent(39) - deregister 1783527483855
 console.group
 FeatureComponent(39) - deregister 1783527483856
 console.group
 FeatureComponent(39) - deregister 1783527483856
 console.group
 FeatureComponent(39) - deregister 1783527483857
 console.group
 FeatureComponent(39) - deregister 1783527483857
 console.group
 FeatureComponent(39) - deregister 1783527483858
 console.group
 FeatureComponent(39) - deregister 1783527483858
 console.group
 FeatureComponent(39) - deregister 1783527483859
 console.group
 FeatureComponent(39) - deregister 1783527483859
 console.group
 FeatureComponent(39) - deregister 1783527483859
 console.group
 FeatureComponent(39) - deregister 1783527483860
 console.group
 FeatureComponent(39) - deregister 1783527483860
 console.group
 FeatureComponent(39) - deregister 1783527483861
 console.group
 FeatureComponent(39) - deregister 1783527483861
 console.group
 FeatureComponent(39) - deregister 1783527483862
 console.group
 FeatureComponent(39) - deregister 1783527483862
 console.group
 FeatureComponent(39) - deregister 1783527483863
 console.group
 FeatureComponent(39) - deregister 1783527483863
 console.group
 FeatureComponent(39) - deregister 1783527483863
 console.group
 FeatureComponent(39) - deregister 1783527483864
 console.group
 FeatureComponent(39) - deregister 1783527483864
 console.group
 FeatureComponent(39) - deregister 1783527483865
 console.group
 FeatureComponent(39) - deregister 1783527483865
 console.group
 FeatureComponent(39) - deregister 1783527483866
 console.group
 FeatureComponent(39) - deregister 1783527483866
 console.group
 FeatureComponent(39) - deregister 1783527483867
 console.group
 FeatureComponent(39) - deregister 1783527483868
 console.group
 FeatureComponent(39) - deregister 1783527483868
 console.group
 FeatureComponent(39) - deregister 1783527483868
 console.group
 🔎 fire-event: interaction-mode | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
 FeatureComponent(39) - deregister 1783527485778
 console.group
 FeatureComponent(39) - deregister 1783527485779
 console.group
 FeatureComponent(39) - deregister 1783527485780
 console.group
 FeatureComponent(39) - deregister 1783527485780
 console.group
 FeatureComponent(39) - deregister 1783527485781
 console.group
 FeatureComponent(39) - deregister 1783527485781
 console.group
 FeatureComponent(39) - deregister 1783527485782
 console.group
 FeatureComponent(39) - deregister 1783527485782
 console.group
 FeatureComponent(39) - deregister 1783527485783
 console.group
 FeatureComponent(39) - deregister 1783527485783
 console.group
 FeatureComponent(39) - deregister 1783527485783
 console.group
 FeatureComponent(39) - deregister 1783527485784
 console.group
 FeatureComponent(39) - deregister 1783527485784
 console.group
 FeatureComponent(39) - deregister 1783527485785
 console.group
 FeatureComponent(39) - deregister 1783527485785
 console.group
 FeatureComponent(39) - deregister 1783527485786
 console.group
 FeatureComponent(39) - deregister 1783527485786
 console.group
 FeatureComponent(39) - deregister 1783527485787
 console.group
 FeatureComponent(39) - deregister 1783527485787
 console.group
 FeatureComponent(39) - deregister 1783527485788
 console.group
 FeatureComponent(39) - deregister 1783527485788
 console.group
 FeatureComponent(39) - deregister 1783527485789
 console.group
 FeatureComponent(39) - deregister 1783527485789
 console.group
 FeatureComponent(39) - deregister 1783527485790
 console.group
 FeatureComponent(39) - deregister 1783527485790
 console.group
 FeatureComponent(39) - deregister 1783527485790
 console.group
 FeatureComponent(39) - deregister 1783527485791
 console.group
 FeatureComponent(39) - deregister 1783527485791
 console.group
 FeatureComponent(39) - deregister 1783527485792
 console.group
 FeatureComponent(39) - deregister 1783527485792
 console.group
 FeatureComponent(39) - deregister 1783527485793
 console.group
 FeatureComponent(39) - deregister 1783527485794
 console.group
 FeatureComponent(39) - deregister 1783527485794
 console.group
 FeatureComponent(39) - deregister 1783527485795
 console.group
 FeatureComponent(39) - deregister 1783527485795
 console.group
 FeatureComponent(39) - deregister 1783527485795
 console.group
 FeatureComponent(39) - deregister 1783527485796
 console.group
 FeatureComponent(39) - deregister 1783527485796
 console.group
 FeatureComponent(39) - deregister 1783527485797
 console.group
 FeatureComponent(39) - deregister 1783527485797
 console.group
 FeatureComponent(39) - deregister 1783527485798
 console.group
 FeatureComponent(39) - deregister 1783527485798
 console.group
 FeatureComponent(39) - deregister 1783527485799
 console.group
 FeatureComponent(39) - deregister 1783527485799
 console.group
 FeatureComponent(39) - deregister 1783527485800
 console.group
 FeatureComponent(39) - deregister 1783527485800
 console.group
 🔎 fire-event: interaction-mode | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
 FeatureComponent(39) - deregister 1783527487095
 console.group
 FeatureComponent(39) - deregister 1783527487096
 console.group
 FeatureComponent(39) - deregister 1783527487096
 console.group
 FeatureComponent(39) - deregister 1783527487097
 console.group
 FeatureComponent(39) - deregister 1783527487097
 console.group
 FeatureComponent(39) - deregister 1783527487098
 console.group
 FeatureComponent(39) - deregister 1783527487098
 console.group
 FeatureComponent(39) - deregister 1783527487099
 console.group
 FeatureComponent(39) - deregister 1783527487099
 console.group
 FeatureComponent(39) - deregister 1783527487099
 console.group
 FeatureComponent(39) - deregister 1783527487100
 console.group
 FeatureComponent(39) - deregister 1783527487100
 console.group
 FeatureComponent(39) - deregister 1783527487101
 console.group
 FeatureComponent(39) - deregister 1783527487101
 console.group
 FeatureComponent(39) - deregister 1783527487102
 console.group
 FeatureComponent(39) - deregister 1783527487102
 console.group
 FeatureComponent(39) - deregister 1783527487103
 console.group
 FeatureComponent(39) - deregister 1783527487103
 console.group
 FeatureComponent(39) - deregister 1783527487104
 console.group
 FeatureComponent(39) - deregister 1783527487104
 console.group
 FeatureComponent(39) - deregister 1783527487104
 console.group
 FeatureComponent(39) - deregister 1783527487105
 console.group
 FeatureComponent(39) - deregister 1783527487105
 console.group
 FeatureComponent(39) - deregister 1783527487106
 console.group
 FeatureComponent(39) - deregister 1783527487106
 console.group
 FeatureComponent(39) - deregister 1783527487107
 console.group
 FeatureComponent(39) - deregister 1783527487107
 console.group
 FeatureComponent(39) - deregister 1783527487108
 console.group
 FeatureComponent(39) - deregister 1783527487108
 console.group
 FeatureComponent(39) - deregister 1783527487109
 console.group
 FeatureComponent(39) - deregister 1783527487109
 console.group
 FeatureComponent(39) - deregister 1783527487110
 console.group
 FeatureComponent(39) - deregister 1783527487110
 console.group
 FeatureComponent(39) - deregister 1783527487110
 console.group
 FeatureComponent(39) - deregister 1783527487111
 console.group
 FeatureComponent(39) - deregister 1783527487111
 console.group
 FeatureComponent(39) - deregister 1783527487112
 console.group
 FeatureComponent(39) - deregister 1783527487112
 console.group
 FeatureComponent(39) - deregister 1783527487113
 console.group
 FeatureComponent(39) - deregister 1783527487113
 console.group
 FeatureComponent(39) - deregister 1783527487114
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487114
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487115
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487115
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487116
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487116
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487117
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487118
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487118
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487119
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487119
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487120
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487120
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487121
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487121
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487122
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487122
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487122
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487123
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487123
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487124
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487124
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487125
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487125
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487126
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487126
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487127
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487127
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487128
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487128
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487129
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487129
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487130
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487131
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487131
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487132
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487132
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487133
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487133
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487133
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487134
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487134
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487135
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487135
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487136
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487136
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487137
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487137
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487137
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487138
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487138
@ad_fire.js?v=339f371e:37846 console.group
@ad_fire.js?v=339f371e:37819 FeatureComponent(39) - deregister 1783527487139
@ad_fire.js?v=339f371e:37846 console.group
Fire3D.vue:175 🔎 fire-event: context-click | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: selection | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: interaction-mode | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
@ad_fire.js?v=339f371e:30201 Uncaught TypeError: Cannot read properties of undefined (reading 'transform')
    at get defaultCursorTransform (@ad_fire.js?v=339f371e:30201:84)
    at get cursorTransform (@ad_fire.js?v=339f371e:30204:51)
    at M.processInput (@ad_fire.js?v=339f371e:30147:70)
    at y.processInput (@ad_fire.js?v=339f371e:29805:33)
    at S.processInput (@ad_fire.js?v=339f371e:30227:36)
    at Object.execute (@ad_fire.js?v=339f371e:30224:449)
    at Wr.executeCallbacks (@ad_fire.js?v=339f371e:17433:39)
    at Wr.processPhase (@ad_fire.js?v=339f371e:17429:105)
    at Wr.frame (@ad_fire.js?v=339f371e:17426:41)
    at Wr.frameAutomatic (@ad_fire.js?v=339f371e:17415:12)


Der Crash ist die direkte Folge von „kein Snap ohne Regel" — und ein Robustheitsloch in fire:

```
Cannot read properties of undefined (reading 'transform')
  at get defaultCursorTransform (dragPoints[0].transform)
```

In der Story (`Fire3d.stories`) gibt es **keine `dockingRules`** — der Processor wird dort mit `new DefaultCommandProcessor()` ohne Matrix erzeugt. Also liefert `placementDragPoints` `[]` (wie entschieden: kein Snap ohne Regel), und fires `placementMode` greift ungeprüft auf `dragPoints[0].transform` zu → Crash. fire verträgt **keine leeren dragPoints**.

Zwei Dinge sind auseinanderzuhalten:

1. **Test-Umgebung:** Die Story reicht keine Matrix rein, also greift die neue Logik nie und du siehst nur den Crash. Zum echten Testen braucht der Placement-Weg eine `dockingRules`-Matrix — entweder in `Fire3d.stories` (`new DefaultCommandProcessor(matrix)`) oder über `ProductConfigurator :docking-rules`.

2. **Robustheit:** Auch mit Matrix kann ein Bauteil ohne passende Regel auftreten → wieder leere dragPoints → wieder Crash. „Kein Snap" darf fire nicht mit `[]` füttern.

Für Punkt 2 ist die Frage, was „kein Snap" konkret heißt:


AskUserQuestion

Question: Was soll passieren, wenn ein Bauteil keine Docking-Regel hat (leere dragPoints)?

Choices:
start_placement_mode gar nicht senden, wenn keine dragPoints. Das Teil bleibt liegen, lässt sich (vorerst) nicht ziehen — aber fire crasht nicht. Einfachster Guard.
Einen Anker-DragPoint (z.B. .name-CS_EIN) ohne DropPoints senden. Das Teil folgt dem Cursor und lässt sich frei ablegen, snappt aber nirgends.

Question: Wie beim Testen die Matrix reingeben?

Choices:
Ich lege eine Test-Matrix (für schutzzaun_basic-tuer bzw. die geladenen Klassen) an und übergebe sie in der Story an new DefaultCommandProcessor(matrix).
Keine Story-Änderung; du testest über ProductConfigurator mit :docking-rules. Ich zeige nur das Matrix-Format.


Error: The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:
The user wants to clarify these questions.
    This means they may have additional information, context or questions for you.
    Take their response into account and then reformulate the questions if appropriate.
    Start by asking them what they would like to clarify.

    Questions asked:
- "Was soll passieren, wenn ein Bauteil keine Docking-Regel hat (leere dragPoints)?"
  Answer: Kein Placement-Mode
- "Wie beim Testen die Matrix reingeben?"
  (No answer provided)


[Fire3D Asset Loader] Attempting to load asset: Object  [Fire3D Asset Loader] Successfully loaded asset: Object  [Fire3D Asset Loader] Loaded BLOB asset: Object  Cl - onExecute: deserializing 'compone…


Verstanden — Guard: kein `start_placement_mode` bei leeren dragPoints (setze ich um).

Zur zweiten Frage (wie die Matrix beim Testen reinkommt): Was möchtest du klären?


Derr kommt doch normal über den Configurator rein, oder?


Ja, genau — im echten Weg kommt die Matrix über den **ProductConfigurator** rein: die `:docking-rules`-Prop, die ich gebaut habe (`ProductConfigurator` → `new DefaultCommandProcessor(props.dockingRules)`). Der Client (geko) übergibt sie dort.

Die `Fire3d.stories`, in der du gerade den Crash siehst, ist ein **isolierter Fire3D-Test** — die erzeugt den Processor direkt mit `new DefaultCommandProcessor()`, **ohne** Matrix, und umgeht damit den Configurator komplett. Deshalb leere dragPoints → Crash. Das ist nicht der Produktionsweg.

Also:
- **Snap testen** → über `ProductConfigurator` mit `:docking-rules="matrix"` (bzw. eine ProductConfigurator-Story, die die Prop setzt).
- Der **Guard** (kein `start_placement_mode` bei leeren dragPoints) sorgt dafür, dass es auch ohne Matrix — wie in der Fire3d.stories — nicht mehr crasht, dann eben ohne Snap.

Ich baue den Guard ein. Zum Testen des tatsächlichen Snappings brauchst du den Configurator-Weg mit Matrix. Soll ich den Guard umsetzen — und dir eine ProductConfigurator-Story mit einer Beispiel-Matrix anlegen, damit du den Snap direkt im Storybook siehst?


NO WAY
Übergib die Matrix im Konstruktor


Genau
Guard, um den Crash zu vermeiden'
Im Storybook --> Der Configurtaor hat dort doch eine Produktuaswhal
--> kann die nicht dei Matrix halten, ganz normal


Guten Punkt — der `ProductConfigurator` hat im Storybook den `catalog`-Slot (die Produktauswahl). Der ganz normale Weg: der Client hält Katalog **und** Matrix und gibt beide an den Configurator. Also lege ich die Matrix in die `ProductConfigurator`-Story und reiche sie als `:docking-rules` rein.

Ich baue erst den Guard. Dazu lese ich die beiden `start_placement_mode`-Stellen und die Story:


266	        if (placement) this.eventStore.push(placement);
267
268	        const scene = this.replay();
269	        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
270	    }
271
272	    // Pull a part out of its group into its own top-level group, then start placement on that group's
273	    // node (a valid top-level subject). The freed neighbour points are open again post-detach.
274	    private handleDetachElement(event: any): UIReaction[] {
275	        this.replay();
276	        const detach = this.groups.buildDetachEvent(event.elementId);
277	        if (!detach) return [];
278	        this.eventStore.push(detach);
279	        const scene = this.replay();
280
281	        const groupId = detach.newGroups.find((ng) => ng.members.includes(event.elementId))?.groupId;
282	        const groupKey = groupId ? this.groups.groupKeyOf(groupId) : null;
283	        const setScene: UIReaction = { type: "sendCommand", cmd: "setScene", args: [scene] };
284	        if (!groupKey) return [setScene];
285
286	        const subject = "#" + groupKey;
287	        return [
288	            setScene,
289	            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
290	            {
291	                type: "sendCommand",
292	                cmd: "start_placement_mode",
293	                args: [
294	                    {
295	                        subject,

        if (placement) this.eventStore.push(placement);

        const scene = this.replay();
        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
    }

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

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

        const subject = "#" + groupKey;
        return [
            setScene,
            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
            {
                type: "sendCommand",
                cmd: "start_placement_mode",
                args: [
                    {
                        subject,


296	                        allowAllDragPoints: true,
297	                        dragPoints: this.placementDragPoints(subject),
298	                    },
299	                ],
300	            },
301	        ];
302	    }
303
304	    // The merge (on snap) or move (free) event for a moving group.
305	    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {

                        allowAllDragPoints: true,
                        dragPoints: this.placementDragPoints(subject),
                    },
                ],
            },
        ];
    }

    // The merge (on snap) or move (free) event for a moving group.
    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {


Genau  Guard, um den Crash zu vermeiden' Im Storybook --> Der Configurtaor hat dort doch eine Produktuaswhal --> kann die nicht dei Matrix halten, ganz normal


Jetzt die zweite Stelle in `getContextForSelection`:


376	        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
377	        const canEscalate = this.selection.length === 1 && level < chain.length - 1;
378
379	        return [
380	            { type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },
381	            {
382	                type: "contextMenu",
383	                entries: [
384	                    ...(canEscalate
385	                        ? ([
386	                              {
387	                                  label: "Übergeordnete Gruppe wählen",
388	                                  reactions: [{ type: "dispatchEvent", event: { eventTypeName: "escalate-selection", pickedId } }],
389	                              },
390	                          ] as MenuEntry[])
391	                        : []),
392	                    {
393	                        label: "Move",
394	                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "move" } }],
395	                    },
396	                    {
397	                        label: "Rotate",
398	                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "rotate" } }],
399	                    },
400	                    {
401	                        label: "Platzieren",
402	                        reactions: [
403	                            {
404	                                type: "sendCommand",
405	                                cmd: "start_placement_mode",
406	                                args: [
407	                                    {
408	                                        subject: groupSelector,
409	                                        allowAllDragPoints: true,
410	                                        dragPoints: this.placementDragPoints(groupSelector),
411	                                    },
412	                                ],
413	                            },
414	                        ],
415	                    },
416	                    ...(elementRoot
417	                        ? ([
418	                              {
419	                                  label: "Element neu platzieren",
420	                                  // Detach runs in the processor (records the split), then it starts

        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
        const canEscalate = this.selection.length === 1 && level < chain.length - 1;

        return [
            { type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },
            {
                type: "contextMenu",
                entries: [
                    ...(canEscalate
                        ? ([
                              {
                                  label: "Übergeordnete Gruppe wählen",
                                  reactions: [{ type: "dispatchEvent", event: { eventTypeName: "escalate-selection", pickedId } }],
                              },
                          ] as MenuEntry[])
                        : []),
                    {
                        label: "Move",
                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "move" } }],
                    },
                    {
                        label: "Rotate",
                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "rotate" } }],
                    },
                    {
                        label: "Platzieren",
                        reactions: [
                            {
                                type: "sendCommand",
                                cmd: "start_placement_mode",
                                args: [
                                    {
                                        subject: groupSelector,
                                        allowAllDragPoints: true,
                                        dragPoints: this.placementDragPoints(groupSelector),
                                    },
                                ],
                            },
                        ],
                    },
                    ...(elementRoot
                        ? ([
                              {
                                  label: "Element neu platzieren",
                                  // Detach runs in the processor (records the split), then it starts


Guard 1 — `handleDetachElement`:


Edit

        const subject = "#" + groupKey;
        return [
            setScene,
            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
            {
                type: "sendCommand",
                cmd: "start_placement_mode",
                args: [
                    {
                        subject,
                        allowAllDragPoints: true,
                        dragPoints: this.placementDragPoints(subject),
                    },
                ],
            },
        ];
    }

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