Verstanden — keine `CS_LINKS`/`CS_RECHTS`-Injektion. Ich schaue mir die neue klassenbasierte Platzierungslogik an, bevor ich die Tests umbaue.


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== gestrige Commits (Platzierung/Klassen/Naming) ==="
git log --oneline -12
echo
echo "=== be58fd3: geänderte Dateien (Klassen-basierte Platzierung) ==="
git show --stat be58fd3 2>/dev/null | head -40

Gestrige Platzierungs-Commits ansehen


=== gestrige Commits (Platzierung/Klassen/Naming) ===
b05bebf Graph-Export-Button + Keyboard-Prototyp fürs Anbauen
744326c Docking: Doppelbelegung von Andockpunkten verhindern
be58fd3 Klassen-basierte Platzierung: Docking-Regeln pro Bauteilklasse
61e0548 PMI-Handling für fire: Nicht-Root-PMIs entfernt, Rotationen auf Radiant
129ffb9 Placement: Start-Punkte aus DragPoints entfernt, dragPointRadius korrigiert
debdb4e Neue Ordnerstruktur: Assets/Szenegraphen umstrukturiert
ea77fdc Neue Einbaupunkte: GroupProcessor/DefaultCommandProcessor + ProductConfigurator/ProductBrowser/Fire3D
297a73c update 1.06
cc927c5 @ad/fire auf 1.0.6
36ec21b eob
0633a88 git-sync: 2026-06-30 13:35
b961242 refactor

=== be58fd3: geänderte Dateien (Klassen-basierte Platzierung) ===
commit be58fd36e3a65d3208745ba061bee7686fe7987f
Author: marcus.hinz <marcus.hinz@adesso.de>
Date:   Wed Jul 8 18:38:12 2026 +0200

    Klassen-basierte Platzierung: Docking-Regeln pro Bauteilklasse

    - Szenegraphen: class-<gruppe>-<klasse>-Tag am Root-Handle jedes Bauteils
      (aus dem Katalog abgeleitet, selektor-sicher).
    - GroupProcessor.classOfGroup: Klasse aus dem class-Tag des Element-Roots.
    - DefaultCommandProcessor: DockingRules (Client-Prop), classOf, matrix-getriebene
      placementDragPoints (Drop-Points je Zielklasse via .class-…-Descendant-Selektor).
      Ohne Regel kein Placement-Mode (Guard gegen leere dragPoints, die fire crashen).
    - ProductConfigurator: docking-rules-Prop, durchgereicht an den Processor.
    - index.ts: DockingRules als Client-Typ exportiert.
    - ProductConfigurator.stories: Beispiel-Matrix (allround + basic).
    - catalog.json: konfigKey wieder entfernt (Klasse lebt am Bauteil, nicht doppelt).

 packages/frontend/.storybook/products/catalog.json |   4 +-
 .../products/schutzzaun_allround/SAD2083PLA.json   |   2 +-
 .../products/schutzzaun_allround/SAD2083PRA.json   |   2 +-
 .../products/schutzzaun_allround/SAD2083SLA.json   |   2 +-
 .../products/schutzzaun_allround/SAD2083SRA.json   |   2 +-
 .../products/schutzzaun_allround/SAE0131SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0174PNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0174SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0217PNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0217SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0260PNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0260SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0303PNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0303SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0346PNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0346SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0389PNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0389SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0432PNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0432SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0475PNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0475SNN.json   |   2 +-
 .../products/schutzzaun_allround/SAE0518PNN.json   |   2 +-


NEIN es gibt eine neue namens logik, die haben wir gestern implementiert schau dir die Platzierungslogik an --> Test auf diese Platzierungslogik umbauen ggf. ergänzen


Rollenförderer Visualisierung und Fehler beheben


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== DefaultCommandProcessor + DockingRules Dateien ==="
grep -rl 'DockingRules\|placementDragPoints\|classOf\|CS_OUT_\|CS_EIN' packages/frontend/src 2>/dev/null | grep -v node_modules | grep -v test
echo
echo "=== DefaultCommandProcessor.ts Struktur ==="
find packages/frontend/src -name 'DefaultCommandProcessor.ts' | head
wc -l packages/frontend/src/configurator/**/DefaultCommandProcessor.ts 2>/dev/null

Placement-Code-Dateien finden


=== DefaultCommandProcessor + DockingRules Dateien ===
packages/frontend/src/index.ts
packages/frontend/src/components/visual/ProductConfigurator.stories.ts
packages/frontend/src/components/visual/ProductConfigurator.vue
packages/frontend/src/configurator/model/transformMath.ts
packages/frontend/src/configurator/model/GroupProcessor.ts
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts

=== DefaultCommandProcessor.ts Struktur ===
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
     559 packages/frontend/src/configurator/events/DefaultCommandProcessor.ts


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	// Client-supplied placement rules, keyed by the moving part's docking class:
9	//   { movingClass: { targetClass: { dragPointName: dropPointName[] } } }
10	// Classes match the `class-…` tags on part root handles (e.g. "schutzzaun_allround-steher").
11	export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;
12
13	export class DefaultCommandProcessor extends CommandProcessor {
14	    private eventStore = new EventStore();
15	    private groups = new GroupProcessor();
16	    private freeCursorX = 0;
17	    // Current selection as a set of picks. Each pick is a picked geometry plus its escalation level
18	    // (part → assembly → …). A plain click sets a single pick (re-clicking the same one escalates);
19	    // an additive click (multi-select) adds another pick, or toggles it off if already present.
20	    private selection: { pickedId: string; level: number }[] = [];
21	    // A regroup recorded when a move/rotate starts but not yet committed: it is applied during the
22	    // interaction (so the engine sees the movable groups) and pushed together with the move/rotate as
23	    // one undo step on completion — or discarded on cancel. Keeps "break group to move" out of undo.
24	    private pendingRegroup: GroupEvent | null = null;
25
26	    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
27	    constructor(private readonly dockingRules?: DockingRules) {
28	        super();
29	    }
30
31	    setInitialScene(scene: any): void {
32	        this.eventStore.setInitialState(scene);
33	    }
34
35	    private replay(): any {
36	        const events = this.eventStore.getEventsToReplay();
37	        return this.groups.replay(
38	            this.eventStore.getInitialState(),
39	            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
40	        );
41	    }
42
43	    handleEvent(event: any): UIReaction[] {
44	        switch (event?.eventTypeName) {
45	            case "interaction-mode":
46	                if (event.lifecycleState !== "update") return [];
47	                return this.handleInteractionMode(event);
48	            case "add-product":
49	                return this.handleAddProduct(event);
50	            case "keyboard-build":
51	                return this.handleKeyboardBuild(event);
52	            case "detach-element":
53	                return this.handleDetachElement(event);
54	            case "escalate-selection":
55	                return this.handleEscalateSelection(event);
56	            case "prepare-action":
57	                return this.handlePrepareAction(event);
58	            case "undo":
59	                return this.handleUndo();
60	            case "redo":
61	                return this.handleRedo();
62	            case "esc":
63	                return this.handleEsc();
64	            case "context-click":
65	                return this.getContextForSelection(event);
66	            case "selection-click":
67	                return this.handleSelectionClickEvent(event);
68	            case "selection":
69	                return [];
70	            default:
71	                console.error("unknown event type ", event?.eventTypeName);
72	                return [];
73	        }
74	    }
75
76	    private handleEsc(): UIReaction[] {
77	        // Cancel a prepared-but-uncommitted regroup and resync the engine to the real model.
78	        if (this.pendingRegroup) {
79	            this.pendingRegroup = null;
80	            const scene = this.replay();
81	            return [
82	                { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
83	                { type: "sendCommand", cmd: "setScene", args: [scene] },
84	            ];
85	        }
86	        return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
87	    }
88
89	    private handleAddProduct(event: any): UIReaction[] {
90	        if (!event.handles?.length) return [];
91	        // A new product enters as a free group of one, offset along X by the running cursor so
92	        // products land adjacent and in view instead of overlapping.
93	        const position: Vec3 = [this.freeCursorX, 0, 0];
94	        this.freeCursorX += this.productExtent(event.handles);
95	        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
96	        this.eventStore.push(createEvent);
97	        const scene = this.replay();
98	        return [
99	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
100	            { type: "sendCommand", cmd: "setScene", args: [scene] },
101	        ];
102	    }
103
104	    // PROTOTYPE — keyboard build shortcut ("+" held + arrow), fed from Fire3D. Works off the last
105	    // active selection taken from the store. right/left dock a COPY of the active part onto its open
106	    // CS_OUT_R_0 / CS_OUT_L_0 end (only if that end is still free); up/down nudge the active group
107	    // along Y. Docking PMIs are hardcoded for now — this is a thinking prop, not the final wiring.
108	    private handleKeyboardBuild(event: any): UIReaction[] {
109	        this.replay();
110	        const active = this.selection[this.selection.length - 1]?.pickedId;
111	        const activeRoot = active ? this.groups.elementRootOf(active) : null;
112	        const groupId = activeRoot ? this.groups.groupIdOfElement(activeRoot) : null;
113	        if (!active || !activeRoot || !groupId) return [];
114
115	        if (event.direction === "up" || event.direction === "down") {
116	            const t = this.groups.transformOf(groupId);
117	            if (!t) return [];
118	            const step = event.direction === "up" ? 100 : -100;
119	            this.eventStore.push({
120	                type: "group-move",
121	                timestamp: Date.now(),
122	                groupId,
123	                position: [t.pos[0], t.pos[1] + step, t.pos[2]],
124	                rotation: t.rot,
125	            } as GroupEvent);
126	            return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
127	        }
128
129	        // right / left: dock a copy of the active part onto its open end
130	        const anchorPmi = event.direction === "right" ? "CS_OUT_R_0" : "CS_OUT_L_0";
131	        const joinPmi = event.direction === "right" ? "CS_EIN_L" : "CS_EIN_R";
132	        if (this.groups.occupiedPmis().some((o) => o.element === activeRoot && o.pmi === anchorPmi)) {
133	            return []; // no room at the active part's chosen end
134	        }
135	        const scene = this.groups.sceneOfElement(activeRoot);
136	        if (!scene) return [];
137	        const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);
138	        this.eventStore.push(createEvent);
139	        this.eventStore.push({
140	            type: "group-merge",
141	            timestamp: Date.now(),
142	            targetGroupId: groupId,
143	            movingGroupId: createEvent.groupId,
144	            anchorRootId: activeRoot,
145	            joinRootId: createEvent.rootHandleId,
146	            anchorPmiId: anchorPmi,
147	            joinPmiId: joinPmi,
148	            rot: IDENTITY_QUAT,
149	        } as GroupEvent);
150	        // Chain: move the active selection onto the fresh copy so the next Shift+arrow docks onto it.
151	        this.selection = [{ pickedId: createEvent.rootHandleId, level: 0 }];
152	        return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
153	    }
154	    // Default click selects the single element; re-clicking the same spot escalates up the role
155	    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
156	    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
157	    private handleSelectionClickEvent(event: any): UIReaction[] {
158	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
159	        const additive = event.selectionAction === "add";
160	        if (!event.target) {
161	            if (!additive) this.selection = [];
162	            return this.selectionReactions(stop);
163	        }
164	        this.replay();
165	        const pickedId = event.target.id;
166	        const chain = this.groups.selectableAncestors(pickedId);
167	        if (!chain.length) return [stop];
168
169	        if (additive) {
170	            const present = this.selection.some((p) => p.pickedId === pickedId);
171	            this.selection = present
172	                ? this.selection.filter((p) => p.pickedId !== pickedId)
173	                : [...this.selection, { pickedId, level: 0 }];
174	        } else {
175	            const sole = this.selection.length === 1 ? this.selection[0] : null;
176	            const level = sole && sole.pickedId === pickedId ? Math.min(sole.level + 1, chain.length - 1) : 0;
177	            this.selection = [{ pickedId, level }];
178	        }
179	        return this.selectionReactions(stop);
180	    }
181
182	    // Right-click escalation: only meaningful for a single selected pick; bump it one level.
183	    private handleEscalateSelection(event: any): UIReaction[] {
184	        this.replay();
185	        if (this.selection.length === 1 && this.selection[0].pickedId === event.pickedId) {
186	            const chain = this.groups.selectableAncestors(event.pickedId);
187	            this.selection[0].level = Math.min(this.selection[0].level + 1, chain.length - 1);
188	        }
189	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
190	        return this.selectionReactions(stop).slice(1); // no stop needed here, just the selection
191	    }
192
193	    // The node ids currently selected (each pick resolved to its escalated node).
194	    private selectedNodeIds(): string[] {
195	        return this.selection
196	            .map((p) => {
197	                const chain = this.groups.selectableAncestors(p.pickedId);
198	                return chain[Math.min(p.level, chain.length - 1)];
199	            })
200	            .filter((id): id is string => !!id);
201	    }
202
203	    private selectionReactions(stop: UIReaction): UIReaction[] {
204	        const selectors = this.selectedNodeIds().map((id) => "#" + id);
205	        return [stop, { type: "sendCommand", cmd: "set_selection", args: [selectors] }];
206	    }
207
208	    private handleInteractionMode(event: any): UIReaction[] {
209	        switch (event.modeName) {
210	            case "rotation":
211	                return this.handleRotateEnd(event);
212	            case "movement":
213	                return this.handleMoveEnd(event);
214	            case "placement":
215	                return this.handlePlacementEnd(event);
216	            default:
217	                console.error(`Unknown interaction mode: ${event.modeName}`);
218	                return [];
219	        }
220	    }
221
222	    // Move/rotate act on group nodes — the engine moves every member natively. All moved subjects are
223	    // recorded, together with the pending regroup (if any), as one undo step.
224	    private handleMoveEnd(event: any): UIReaction[] {
225	        const moves = extractMoveData(event);
226	        if (!moves.length) return [];
227	        this.replay();
228	        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
229	        for (const m of moves) {
230	            const groupId = this.resolveGroupId(m.handleId);
231	            if (!groupId) continue;
232	            group.push({
233	                type: "group-move",
234	                timestamp: Date.now(),
235	                groupId,
236	                position: m.position,
237	                rotation: this.groups.transformOf(groupId)?.rot ?? IDENTITY_QUAT,
238	            });
239	        }
240	        return this.commitInteraction(group);
241	    }
242
243	    private handleRotateEnd(event: any): UIReaction[] {
244	        const rotations = extractRotateData(event);
245	        if (!rotations.length) return [];
246	        this.replay();
247	        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
248	        for (const r of rotations) {
249	            const groupId = this.resolveGroupId(r.handleId);
250	            if (!groupId) continue;
251	            group.push({
252	                type: "group-rotate",
253	                timestamp: Date.now(),
254	                groupId,
255	                position: r.position,
256	                rotation: r.rotation,
257	            });
258	        }
259	        return this.commitInteraction(group);
260	    }
261
262	    // Push the interaction (regroup + per-subject transforms) as one undo step and clear the pending.
263	    private commitInteraction(group: GroupEvent[]): UIReaction[] {
264	        this.pendingRegroup = null;
265	        if (group.length) this.eventStore.pushGroup(group);
266	        const scene = this.replay();
267	        return [{ type: "sendCommand", cmd: "setScene", args: [scene] }];
268	    }
269
270	    // Start move/rotate strictly on the current selection: consolidate it into movable groups (break a
271	    // group only if a partial selection requires it), keep that regroup pending, select the resulting
272	    // group nodes and start the engine mode. Already-whole-group selections move without regrouping.
273	    private handlePrepareAction(event: any): UIReaction[] {
274	        this.pendingRegroup = null;
275	        this.replay();
276	        const elementRoots = this.selectionElementRoots();
277	        if (!elementRoots.length) return [];
278
279	        const { event: regroup, moveGroupIds } = this.groups.buildRegroupEvent(elementRoots);
280	        this.pendingRegroup = regroup;
281	        const scene = this.replay();
282
283	        const selectors = moveGroupIds
284	            .map((id) => this.groups.groupKeyOf(id))
285	            .filter((k): k is string => !!k)
286	            .map((k) => "#" + k);
287	        const startCmd = event.mode === "rotate" ? "start_rotate_mode" : "start_move_mode";
288	        return [
289	            { type: "sendCommand", cmd: "setScene", args: [scene] },
290	            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
291	            { type: "sendCommand", cmd: startCmd, args: [] },
292	        ];
293	    }
294
295	    // The element roots covered by the current selection: a selected part is itself, a selected group
296	    // node expands to all its members.
297	    private selectionElementRoots(): string[] {
298	        const roots = new Set<string>();
299	        for (const node of this.selectedNodeIds()) {
300	            if (this.groups.roleOfId(node) === ROLE.assembly) {
301	                for (const m of this.groups.membersOfKey(node)) roots.add(m);
302	            } else {
303	                roots.add(node);
304	            }
305	        }
306	        return [...roots];
307	    }
308
309	    private handlePlacementEnd(event: any): UIReaction[] {
310	        const data = extractPlacementData(event);
311	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
312	        this.replay();
313
314	        // The subject is always a top-level group node (an element to re-place is detached into its
315	        // own group first — see handleDetachElement — so the engine never gets a nested subject).
316	        const movingGroupId = this.resolveGroupId(data.handleId);
317	        if (!movingGroupId) return [stop];
318	        const placement = this.placementEvent(data, movingGroupId);
319	        if (placement) this.eventStore.push(placement);
320
321	        const scene = this.replay();
322	        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
323	    }
324
325	    // Pull a part out of its group into its own top-level group, then start placement on that group's
326	    // node (a valid top-level subject). The freed neighbour points are open again post-detach.
327	    private handleDetachElement(event: any): UIReaction[] {
328	        this.replay();
329	        const detach = this.groups.buildDetachEvent(event.elementId);
330	        if (!detach) return [];
331	        this.eventStore.push(detach);
332	        const scene = this.replay();
333
334	        const groupId = detach.newGroups.find((ng) => ng.members.includes(event.elementId))?.groupId;
335	        const groupKey = groupId ? this.groups.groupKeyOf(groupId) : null;
336	        const setScene: UIReaction = { type: "sendCommand", cmd: "setScene", args: [scene] };
337	        if (!groupKey) return [setScene];
338
339	        const subject = "#" + groupKey;
340	        const dragPoints = this.placementDragPoints(subject);
341	        const reactions: UIReaction[] = [
342	            setScene,
343	            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
344	        ];
345	        // No docking rule for this class → no drag points. fire's placement mode dereferences
346	        // dragPoints[0], so skip start_placement_mode entirely instead of handing it an empty array.
347	        if (dragPoints.length > 0) {
348	            reactions.push({
349	                type: "sendCommand",
350	                cmd: "start_placement_mode",
351	                args: [{ subject, allowAllDragPoints: true, dragPoints }],
352	            });
353	        }
354	        return reactions;
355	    }
356
357	    // The merge (on snap) or move (free) event for a moving group.
358	    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {
359	        if (data.dropHandleId) {
360	            const anchorRoot = this.groups.elementRootOf(data.dropHandleId);
361	            const joinRoot = data.dragHandleId ? this.groups.elementRootOf(data.dragHandleId) : null;
362	            const targetGroupId = anchorRoot ? this.groups.groupIdOfElement(anchorRoot) : null;
363	            if (anchorRoot && joinRoot && targetGroupId && targetGroupId !== movingGroupId && data.dropPmiId && data.dragPmiId) {
364	                // The snapped points fix the join: the dragged point is placed onto the drop point.
365	                // Direction (any axis) follows from the point positions; the relative rotation (e.g. a
366	                // 90° branch onto a post) follows from the points' frame orientations.
367	                return {
368	                    type: "group-merge",
369	                    timestamp: Date.now(),
370	                    targetGroupId,
371	                    movingGroupId,
372	                    anchorRootId: anchorRoot,
373	                    joinRootId: joinRoot,
374	                    anchorPmiId: data.dropPmiId,
375	                    joinPmiId: data.dragPmiId,
376	                    rot: this.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),
377	                };
378	            }
379	            return null;
380	        }
381	        // No snap: free reposition of the group to the drop position.
382	        return {
383	            type: "group-move",
384	            timestamp: Date.now(),
385	            groupId: movingGroupId,
386	            position: data.position,
387	            rotation: this.groups.transformOf(movingGroupId)?.rot ?? IDENTITY_QUAT,
388	        };
389	    }
390
391	    private handleUndo(): UIReaction[] {
392	        if (!this.eventStore.canUndo) return [];
393	        this.eventStore.undo();
394	        const scene = this.replay();
395	        return [
396	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
397	            { type: "sendCommand", cmd: "setScene", args: [scene] },
398	            { type: "notification", message: "Action reverted" },
399	        ];
400	    }
401
402	    private handleRedo(): UIReaction[] {
403	        if (!this.eventStore.canRedo) return [];
404	        this.eventStore.redo();
405	        const scene = this.replay();
406	        return [
407	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
408	            { type: "sendCommand", cmd: "setScene", args: [scene] },
409	            { type: "notification", message: "Action reapplied" },
410	        ];
411	    }
412
413	    private getContextForSelection(event: any): UIReaction[] {
414	        if (!event.pointed?.length) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
415	        this.replay();
416	        const pickedId = event.pointed[0].entity.id;
417	        const groupSelector = this.groupSelectorOf(pickedId);
418	        if (!groupSelector) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
419
420	        // The single picked element (for "re-place this element", as opposed to the whole group).
421	        const elementRoot = this.groups.elementRootOf(pickedId);
422
423	        // Right-click on something already in the selection keeps the whole set (so it doesn't drop a
424	        // multi-select or an escalation); a fresh target selects just that element. The escalate entry
425	        // only shows for a single-pick selection that can still go higher.
426	        const chain = this.groups.selectableAncestors(pickedId);
427	        if (!this.selection.some((p) => p.pickedId === pickedId)) this.selection = [{ pickedId, level: 0 }];
428	        const level = this.selection.find((p) => p.pickedId === pickedId)?.level ?? 0;
429	        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
430	        const canEscalate = this.selection.length === 1 && level < chain.length - 1;
431	        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode
432	        // dereferences dragPoints[0] and would crash on an empty array).
433	        const dragPoints = this.placementDragPoints(groupSelector);
434
435	        return [
436	            { type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },
437	            {
438	                type: "contextMenu",
439	                entries: [
440	                    ...(canEscalate
441	                        ? ([
442	                              {
443	                                  label: "Übergeordnete Gruppe wählen",
444	                                  reactions: [{ type: "dispatchEvent", event: { eventTypeName: "escalate-selection", pickedId } }],
445	                              },
446	                          ] as MenuEntry[])
447	                        : []),
448	                    {
449	                        label: "Move",
450	                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "move" } }],
451	                    },
452	                    {
453	                        label: "Rotate",
454	                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "rotate" } }],
455	                    },
456	                    ...(dragPoints.length > 0
457	                        ? ([
458	                              {
459	                                  label: "Platzieren",
460	                                  reactions: [
461	                                      {
462	                                          type: "sendCommand",
463	                                          cmd: "start_placement_mode",
464	                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],
465	                                      },
466	                                  ],
467	                              },
468	                          ] as MenuEntry[])
469	                        : []),
470	                    ...(elementRoot
471	                        ? ([
472	                              {
473	                                  label: "Element neu platzieren",
474	                                  // Detach runs in the processor (records the split), then it starts
475	                                  // placement on the freed element's own top-level group node.
476	                                  reactions: [
477	                                      { type: "dispatchEvent", event: { eventTypeName: "detach-element", elementId: elementRoot } },
478	                                  ],
479	                              },
480	                          ] as MenuEntry[])
481	                        : []),
482	                ],
483	            },
484	        ];
485	    }
486
487	    // A picked handle (part geometry or group node) → "#<groupKey>" of the owning group node.
488	    private groupSelectorOf(handleId: string): string | null {
489	        const groupId = this.resolveGroupId(handleId);
490	        if (!groupId) return null;
491	        const key = this.groups.groupKeyOf(groupId);
492	        return key ? "#" + key : null;
493	    }
494
495	    // `:not(...)` chain excluding every occupied docking point, so only open ends remain as snap
496	    // targets. Derived from the current edges (post-replay), nothing stored.
497	    private openEndExclusions(): string {
498	        return this.groups
499	            .occupiedPmis()
500	            .map((o) => `:not(#${o.element} .name-${o.pmi})`)
501	            .join("");
502	    }
503
504	    // Docking class of a "#<groupKey>" subject, from the element root's `class-…` tag. Null when the
505	    // group carries no class — then there are no rules and the part places freely without snapping.
506	    private classOf(subject: string): string | null {
507	        const key = subject.startsWith("#") ? subject.slice(1) : subject;
508	        const groupId = this.groups.groupByKey(key);
509	        return groupId ? this.groups.classOfGroup(groupId) : null;
510	    }
511
512	    // Placement drag/drop wiring from the client-supplied docking rules (root-level points only).
513	    // The moving part's class selects its rule block; per target class, each drag point (CS_EIN_*)
514	    // maps to the drop points (CS_OUT_*) it may snap onto. Drop points are scoped to the target
515	    // class via a descendant `.class-<target>` selector; self-snap and occupied ends are excluded.
516	    // No rule for the moving class → no drag points → the part places freely without snapping.
517	    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
518	        const klass = this.classOf(subject);
519	        const rules = klass ? this.dockingRules?.[klass] : undefined;
520	        if (!rules) return [];
521	        const openOnly = this.openEndExclusions();
522	        // Gather, per drag point, the drop-point selectors across every target class.
523	        const byDrag = new Map<string, string[]>();
524	        for (const [targetClass, dragMap] of Object.entries(rules)) {
525	            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
526	                const selectors = dropPoints.map(
527	                    (dp) => `.class-${targetClass} .name-${dp}:not(${subject} .name-${dp})${openOnly}`,
528	                );
529	                byDrag.set(dragPoint, [...(byDrag.get(dragPoint) ?? []), ...selectors]);
530	            }
531	        }
532	        return [...byDrag].map(([dragPoint, dropPoints]) => ({
533	            selector: `.name-${dragPoint}${openOnly}`,
534	            dropPoints,
535	        }));
536	    }
537
538	    private resolveGroupId(handleId: string): string | null {
539	        const byKey = this.groups.groupByKey(handleId);
540	        if (byKey) return byKey;
541	        const root = this.groups.elementRootOf(handleId);
542	        return root ? this.groups.groupIdOfElement(root) : null;
543	    }
544
545	    // Extent of a product from its docking points (domain-agnostic): the largest span across the
546	    // axes, used as the free-placement step so products don't pile up on each other.
547	    private productExtent(handles: any[]): number {
548	        const root = handles.find((h) =>
549	            h.components?.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),
550	        );
551	        const pts: number[][] = [];
552	        for (const c of root?.components ?? []) {
553	            if (c._resourceClass === "component_pmi") pts.push(c.properties.position.value);
554	        }
555	        if (!pts.length) return 0;
556	        const span = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
557	        return Math.max(span(0), span(1), span(2));
558	    }
559	}
560

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

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

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

    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 [];
                return this.handleInteractionMode(event);
            case "add-product":
                return this.handleAddProduct(event);
            case "keyboard-build":
                return this.handleKeyboardBuild(event);
            case "detach-element":
                return this.handleDetachElement(event);
            case "escalate-selection":
                return this.handleEscalateSelection(event);
            case "prepare-action":
                return this.handlePrepareAction(event);
            case "undo":
                return this.handleUndo();
            case "redo":
                return this.handleRedo();
            case "esc":
                return this.handleEsc();
            case "context-click":
                return this.getContextForSelection(event);
            case "selection-click":
                return this.handleSelectionClickEvent(event);
            case "selection":
                return [];
            default:
                console.error("unknown event type ", event?.eventTypeName);
                return [];
        }
    }

    private handleEsc(): UIReaction[] {
        // Cancel a prepared-but-uncommitted regroup and resync the engine to the real model.
        if (this.pendingRegroup) {
            this.pendingRegroup = null;
            const scene = this.replay();
            return [
                { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
                { type: "sendCommand", cmd: "setScene", args: [scene] },
            ];
        }
        return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
    }

    private handleAddProduct(event: any): UIReaction[] {
        if (!event.handles?.length) return [];
        // A new product enters as a free group of one, offset along X by the running cursor so
        // products land adjacent and in view instead of overlapping.
        const position: Vec3 = [this.freeCursorX, 0, 0];
        this.freeCursorX += this.productExtent(event.handles);
        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
        this.eventStore.push(createEvent);
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
        ];
    }

    // PROTOTYPE — keyboard build shortcut ("+" held + arrow), fed from Fire3D. Works off the last
    // active selection taken from the store. right/left dock a COPY of the active part onto its open
    // CS_OUT_R_0 / CS_OUT_L_0 end (only if that end is still free); up/down nudge the active group
    // along Y. Docking PMIs are hardcoded for now — this is a thinking prop, not the final wiring.
    private handleKeyboardBuild(event: any): UIReaction[] {
        this.replay();
        const active = this.selection[this.selection.length - 1]?.pickedId;
        const activeRoot = active ? this.groups.elementRootOf(active) : null;
        const groupId = activeRoot ? this.groups.groupIdOfElement(activeRoot) : null;
        if (!active || !activeRoot || !groupId) return [];

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

        // right / left: dock a copy of the active part onto its open end
        const anchorPmi = event.direction === "right" ? "CS_OUT_R_0" : "CS_OUT_L_0";
        const joinPmi = event.direction === "right" ? "CS_EIN_L" : "CS_EIN_R";
        if (this.groups.occupiedPmis().some((o) => o.element === activeRoot && o.pmi === anchorPmi)) {
            return []; // no room at the active part's chosen end
        }
        const scene = this.groups.sceneOfElement(activeRoot);
        if (!scene) return [];
        const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        this.eventStore.push(createEvent);
        this.eventStore.push({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: groupId,
            movingGroupId: createEvent.groupId,
            anchorRootId: activeRoot,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: anchorPmi,
            joinPmiId: joinPmi,
            rot: IDENTITY_QUAT,
        } as GroupEvent);
        // Chain: move the active selection onto the fresh copy so the next Shift+arrow docks onto it.
        this.selection = [{ pickedId: createEvent.rootHandleId, level: 0 }];
        return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
    }
    // Default click selects the single element; re-clicking the same spot escalates up the role
    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
    private handleSelectionClickEvent(event: any): UIReaction[] {
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        const additive = event.selectionAction === "add";
        if (!event.target) {
            if (!additive) this.selection = [];
            return this.selectionReactions(stop);
        }
        this.replay();
        const pickedId = event.target.id;
        const chain = this.groups.selectableAncestors(pickedId);
        if (!chain.length) return [stop];

        if (additive) {
            const present = this.selection.some((p) => p.pickedId === pickedId);
            this.selection = present
                ? this.selection.filter((p) => p.pickedId !== pickedId)
                : [...this.selection, { pickedId, level: 0 }];
        } else {
            const sole = this.selection.length === 1 ? this.selection[0] : null;
            const level = sole && sole.pickedId === pickedId ? Math.min(sole.level + 1, chain.length - 1) : 0;
            this.selection = [{ pickedId, level }];
        }
        return this.selectionReactions(stop);
    }

    // Right-click escalation: only meaningful for a single selected pick; bump it one level.
    private handleEscalateSelection(event: any): UIReaction[] {
        this.replay();
        if (this.selection.length === 1 && this.selection[0].pickedId === event.pickedId) {
            const chain = this.groups.selectableAncestors(event.pickedId);
            this.selection[0].level = Math.min(this.selection[0].level + 1, chain.length - 1);
        }
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        return this.selectionReactions(stop).slice(1); // no stop needed here, just the selection
    }

    // The node ids currently selected (each pick resolved to its escalated node).
    private selectedNodeIds(): string[] {
        return this.selection
            .map((p) => {
                const chain = this.groups.selectableAncestors(p.pickedId);
                return chain[Math.min(p.level, chain.length - 1)];
            })
            .filter((id): id is string => !!id);
    }

    private selectionReactions(stop: UIReaction): UIReaction[] {
        const selectors = this.selectedNodeIds().map((id) => "#" + id);
        return [stop, { type: "sendCommand", cmd: "set_selection", args: [selectors] }];
    }

    private handleInteractionMode(event: any): UIReaction[] {
        switch (event.modeName) {
            case "rotation":
                return this.handleRotateEnd(event);
            case "movement":
                return this.handleMoveEnd(event);
            case "placement":
                return this.handlePlacementEnd(event);
            default:
                console.error(`Unknown interaction mode: ${event.modeName}`);
                return [];
        }
    }

    // Move/rotate act on group nodes — the engine moves every member natively. All moved subjects are
    // recorded, together with the pending regroup (if any), as one undo step.
    private handleMoveEnd(event: any): UIReaction[] {
        const moves = extractMoveData(event);
        if (!moves.length) return [];
        this.replay();
        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
        for (const m of moves) {
            const groupId = this.resolveGroupId(m.handleId);
            if (!groupId) continue;
            group.push({
                type: "group-move",
                timestamp: Date.now(),
                groupId,
                position: m.position,
                rotation: this.groups.transformOf(groupId)?.rot ?? IDENTITY_QUAT,
            });
        }
        return this.commitInteraction(group);
    }

    private handleRotateEnd(event: any): UIReaction[] {
        const rotations = extractRotateData(event);
        if (!rotations.length) return [];
        this.replay();
        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
        for (const r of rotations) {
            const groupId = this.resolveGroupId(r.handleId);
            if (!groupId) continue;
            group.push({
                type: "group-rotate",
                timestamp: Date.now(),
                groupId,
                position: r.position,
                rotation: r.rotation,
            });
        }
        return this.commitInteraction(group);
    }

    // Push the interaction (regroup + per-subject transforms) as one undo step and clear the pending.
    private commitInteraction(group: GroupEvent[]): UIReaction[] {
        this.pendingRegroup = null;
        if (group.length) this.eventStore.pushGroup(group);
        const scene = this.replay();
        return [{ type: "sendCommand", cmd: "setScene", args: [scene] }];
    }

    // Start move/rotate strictly on the current selection: consolidate it into movable groups (break a
    // group only if a partial selection requires it), keep that regroup pending, select the resulting
    // group nodes and start the engine mode. Already-whole-group selections move without regrouping.
    private handlePrepareAction(event: any): UIReaction[] {
        this.pendingRegroup = null;
        this.replay();
        const elementRoots = this.selectionElementRoots();
        if (!elementRoots.length) return [];

        const { event: regroup, moveGroupIds } = this.groups.buildRegroupEvent(elementRoots);
        this.pendingRegroup = regroup;
        const scene = this.replay();

        const selectors = moveGroupIds
            .map((id) => this.groups.groupKeyOf(id))
            .filter((k): k is string => !!k)
            .map((k) => "#" + k);
        const startCmd = event.mode === "rotate" ? "start_rotate_mode" : "start_move_mode";
        return [
            { type: "sendCommand", cmd: "setScene", args: [scene] },
            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
            { type: "sendCommand", cmd: startCmd, args: [] },
        ];
    }

    // The element roots covered by the current selection: a selected part is itself, a selected group
    // node expands to all its members.
    private selectionElementRoots(): string[] {
        const roots = new Set<string>();
        for (const node of this.selectedNodeIds()) {
            if (this.groups.roleOfId(node) === ROLE.assembly) {
                for (const m of this.groups.membersOfKey(node)) roots.add(m);
            } else {
                roots.add(node);
            }
        }
        return [...roots];
    }

    private handlePlacementEnd(event: any): UIReaction[] {
        const data = extractPlacementData(event);
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        this.replay();

        // The subject is always a top-level group node (an element to re-place is detached into its
        // own group first — see handleDetachElement — so the engine never gets a nested subject).
        const movingGroupId = this.resolveGroupId(data.handleId);
        if (!movingGroupId) return [stop];
        const placement = this.placementEvent(data, movingGroupId);
        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;
        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;
    }

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

    private handleUndo(): UIReaction[] {
        if (!this.eventStore.canUndo) return [];
        this.eventStore.undo();
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
            { type: "notification", message: "Action reverted" },
        ];
    }

    private handleRedo(): UIReaction[] {
        if (!this.eventStore.canRedo) return [];
        this.eventStore.redo();
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
            { type: "notification", message: "Action reapplied" },
        ];
    }

    private getContextForSelection(event: any): UIReaction[] {
        if (!event.pointed?.length) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
        this.replay();
        const pickedId = event.pointed[0].entity.id;
        const groupSelector = this.groupSelectorOf(pickedId);
        if (!groupSelector) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];

        // The single picked element (for "re-place this element", as opposed to the whole group).
        const elementRoot = this.groups.elementRootOf(pickedId);

        // Right-click on something already in the selection keeps the whole set (so it doesn't drop a
        // multi-select or an escalation); a fresh target selects just that element. The escalate entry
        // only shows for a single-pick selection that can still go higher.
        const chain = this.groups.selectableAncestors(pickedId);
        if (!this.selection.some((p) => p.pickedId === pickedId)) this.selection = [{ pickedId, level: 0 }];
        const level = this.selection.find((p) => p.pickedId === pickedId)?.level ?? 0;
        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
        const canEscalate = this.selection.length === 1 && level < chain.length - 1;
        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode
        // dereferences dragPoints[0] and would crash on an empty array).
        const dragPoints = this.placementDragPoints(groupSelector);

        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" } }],
                    },
                    ...(dragPoints.length > 0
                        ? ([
                              {
                                  label: "Platzieren",
                                  reactions: [
                                      {
                                          type: "sendCommand",
                                          cmd: "start_placement_mode",
                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],
                                      },
                                  ],
                              },
                          ] as MenuEntry[])
                        : []),
                    ...(elementRoot
                        ? ([
                              {
                                  label: "Element neu platzieren",
                                  // Detach runs in the processor (records the split), then it starts
                                  // placement on the freed element's own top-level group node.
                                  reactions: [
                                      { type: "dispatchEvent", event: { eventTypeName: "detach-element", elementId: elementRoot } },
                                  ],
                              },
                          ] as MenuEntry[])
                        : []),
                ],
            },
        ];
    }

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

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

    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.
    private productExtent(handles: any[]): number {
        const root = handles.find((h) =>
            h.components?.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),
        );
        const pts: number[][] = [];
        for (const c of root?.components ?? []) {
            if (c._resourceClass === "component_pmi") pts.push(c.properties.position.value);
        }
        if (!pts.length) return 0;
        const span = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
        return Math.max(span(0), span(1), span(2));
    }
}


Jetzt kenne ich die neue Logik: PMI-Namen sind `CS_EIN_L/CS_EIN_R` (Andock-/Join-Punkte am beweglichen Teil) und `CS_OUT_L_0/CS_OUT_R_0…` (offene Enden am Ziel), Teile tragen `class-…`-Tags, und `DockingRules` (movingClass→targetClass→dragPoint→dropPoints) steuern das Placement. Ich lese die echten PMI-Positionen der Fixture, um die Test-Geometrie darauf umzustellen.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
python3 -c "
import json
d=json.load(open('.storybook/products/schutzzaun_basic/SBS1044PRA.json'))
hs=d['handles']
root=None
for h in hs:
    tr=[c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform']
    if tr and not tr[0].get('parentKey'):
        root=h; break
print('ROOT tags:', root.get('tags'))
print('--- alle Root-PMIs: id, position, rotation ---')
for c in root['components']:
    if c.get('_resourceClass')=='component_pmi':
        p=c['properties'].get('position',{}).get('value')
        r=c['properties'].get('rotation',{}).get('value')
        print(f\"{c['id']:16} pos={p}  rot={r}  tags={c.get('tags')}\")
"

Root-PMIs mit Positionen und Rotationen dumpen


ROOT tags: ['class-schutzzaun_basic-tuer']
--- alle Root-PMIs: id, position, rotation ---
CS_EIN           pos=[-40, -33, 0]  rot=[0.0, 4.71238898038469, 0.0]  tags=['name-CS_EIN']
CS_BASE_PRE      pos=[-40.0, -33.0, 0.0]  rot=[1.5707963267948966, -1.5707963267948966, 0.0]  tags=['name-CS_BASE_PRE']
CS_BASE          pos=[-40.0, -33.0, 0.0]  rot=[-1.5707963267948966, -6.123233995741237e-17, 1.5707963267948966]  tags=['name-CS_BASE']
CS_EIN_L         pos=[-20.0, 1067.0, -20.0]  rot=[-0.0, -1.5707963267948966, 0.0]  tags=['name-CS_EIN_L']
CS_EIN_R         pos=[1064.0, 1067.0, -20.0]  rot=[-0.0, 1.5707963267948966, 0.0]  tags=['name-CS_EIN_R']
CS_EIN_START_L   pos=[-40.0, -33.0, -20.0]  rot=[-0.0, -1.5707963267948966, 0.0]  tags=['name-CS_EIN_START_L']
CS_EIN_START_R   pos=[1084.0, -33.0, -20.0]  rot=[-0.0, 1.5707963267948966, 0.0]  tags=['name-CS_EIN_START_R']
CS_OUT_R_0       pos=[1114.0, 1067.0, -20.0]  rot=[-0.0, -1.5707963267948966, 0.0]  tags=['name-CS_OUT_R_0']
CS_OUT_L_0       pos=[-70.0, 1067.0, -20.0]  rot=[-0.0, 1.5707963267948966, 0.0]  tags=['name-CS_OUT_L_0']
CS_OUT_R_90      pos=[1064.0, 1067.0, -110.0]  rot=[0.0, -2.449293598299986e-16, 0.0]  tags=['name-CS_OUT_R_90']
CS_OUT_L_90      pos=[-20.0, 1067.0, -110.0]  rot=[0.0, -2.449293598299986e-16, 0.0]  tags=['name-CS_OUT_L_90']
CS_OUT_R_90_B    pos=[1114.0, 1067.0, -20.0]  rot=[0.0, -2.449293598299986e-16, 0.0]  tags=['name-CS_OUT_R_90_B']
CS_OUT_L_90_B    pos=[-70.0, 1067.0, -20.0]  rot=[0.0, -2.449293598299986e-16, 0.0]  tags=['name-CS_OUT_L_90_B']
CS_OUT_R_270     pos=[1064.0, 1067.0, 30.0]  rot=[3.141592653589793, 3.673940397441252e-16, 3.141592653589793]  tags=['name-CS_OUT_R_270']
CS_OUT_L_270     pos=[-20.0, 1067.0, 30.0]  rot=[3.141592653589793, 3.673940397441252e-16, 3.141592653589793]  tags=['name-CS_OUT_L_270']
CS_OUT_R_270_B   pos=[1114.0, 1067.0, -20.0]  rot=[3.141592653589793, 3.673940397441252e-16, 3.141592653589793]  tags=['name-CS_OUT_R_270_B']
CS_OUT_L_270_B   pos=[-70.0, 1067.0, -20.0]  rot=[3.141592653589793, 3.673940397441252e-16, 3.141592653589793]  tags=['name-CS_OUT_L_270_B']
CS_SELECT_L_0    pos=[-340.0, 1067.0, -20.0]  rot=[-0.0, -1.5707963267948966, 0.0]  tags=['name-CS_SELECT_L_0']
CS_SELECT_R_0    pos=[1384.0, 1067.0, -20.0]  rot=[-0.0, 1.5707963267948966, 0.0]  tags=['name-CS_SELECT_R_0']
CS_SELECT_L_90   pos=[-40.0, 1067.0, -340.0]  rot=[3.141592653589793, 3.673940397441252e-16, 3.141592653589793]  tags=['name-CS_SELECT_L_90']
CS_SELECT_R_90   pos=[1084.0, 1067.0, -340.0]  rot=[3.141592653589793, 3.673940397441252e-16, 3.141592653589793]  tags=['name-CS_SELECT_R_90']
CS_SELECT_L_270  pos=[-40.0, 1067.0, 300.0]  rot=[0.0, -2.449293598299986e-16, 0.0]  tags=['name-CS_SELECT_L_270']
CS_SELECT_R_270  pos=[1084.0, 1067.0, 300.0]  rot=[0.0, -2.449293598299986e-16, 0.0]  tags=['name-CS_SELECT_R_270']


Die echten Punkte ergeben eine saubere Kette: `CS_EIN_L` (join) auf `CS_OUT_R_0` (anchor) → Offset `1114 − (−20) = 1134` in x, und beide tragen `rot=[0,−π/2,0]`, also `dockRotation = identity` (keine Drehung). Für den 90°-Fall liefert das Andocken auf `CS_OUT_R_90` (rot≈0) eine echte Drehung. Ich prüfe noch, wie `replay` die Merge-Rotation bezieht (Event-`rot` vs. `dockRotation`).


420	                    transform: { pos: event.position, rot: event.rotation },
421	                });
422	                this.elementToGroup.set(event.rootHandleId, event.groupId);
423	                break;
424	            }
425	            case "group-merge": {
426	                const target = this.groups.get(event.targetGroupId);
427	                const moving = this.groups.get(event.movingGroupId);
428	                if (!target || !moving) break;
429	                // A docking point must never be booked twice. openEndExclusions already keeps the
430	                // placement UI off occupied points, but guard here too: if the anchor or join point is
431	                // already in use, drop the merge rather than let a double-booked edge into the graph.
432	                const occupied = this.occupiedPmis();
433	                const busy = (element: string, pmi: string) =>
434	                    occupied.some((o) => o.element === element && o.pmi === pmi);
435	                if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;
436	                target.members.push(...moving.members);
437	                target.edges.push(...moving.edges);
438	                target.edges.push({
439	                    from: event.anchorRootId,
440	                    to: event.joinRootId,
441	                    fromPmi: event.anchorPmiId,
442	                    toPmi: event.joinPmiId,
443	                    rot: event.rot,
444	                });
445	                for (const member of moving.members) this.elementToGroup.set(member, target.id);
446	                this.groups.delete(event.movingGroupId);
447	                break;
448	            }
449	            case "group-move":
450	            case "group-rotate": {
451	                const g = this.groups.get(event.groupId);
452	                if (!g) break;
453	                g.transform = { pos: event.position, rot: event.rotation };
454	                break;
455	            }
456	            case "group-detach": {
457	                if (!this.groups.has(event.sourceGroupId)) break;
458	                for (const ng of event.newGroups) {
459	                    this.groups.set(ng.groupId, {
460	                        id: ng.groupId,
461	                        key: ng.groupKey,
462	                        members: [...ng.members],
463	                        edges: ng.edges.map((e) => ({ ...e })),
464	                        transform: ng.transform,
465	                    });
466	                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
467	                }
468	                this.groups.delete(event.sourceGroupId);
469	                break;
470	            }
471	            case "group-regroup": {
472	                for (const sid of event.sourceGroupIds) this.groups.delete(sid);
473	                for (const ng of event.newGroups) {
474	                    this.groups.set(ng.groupId, {
475	                        id: ng.groupId,
476	                        key: ng.groupKey,
477	                        members: [...ng.members],
478	                        edges: ng.edges.map((e) => ({ ...e })),
479	                        transform: ng.transform,
480	                    });
481	                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
482	                }
483	                break;
484	            }
485	        }
486	    }
487
488	    private materialize(baseScene: Scene): Scene {
489	        const handles: Handle[] = [...(baseScene?.handles ?? [])];

                    transform: { pos: event.position, rot: event.rotation },
                });
                this.elementToGroup.set(event.rootHandleId, event.groupId);
                break;
            }
            case "group-merge": {
                const target = this.groups.get(event.targetGroupId);
                const moving = this.groups.get(event.movingGroupId);
                if (!target || !moving) break;
                // A docking point must never be booked twice. openEndExclusions already keeps the
                // placement UI off occupied points, but guard here too: if the anchor or join point is
                // already in use, drop the merge rather than let a double-booked edge into the graph.
                const occupied = this.occupiedPmis();
                const busy = (element: string, pmi: string) =>
                    occupied.some((o) => o.element === element && o.pmi === pmi);
                if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;
                target.members.push(...moving.members);
                target.edges.push(...moving.edges);
                target.edges.push({
                    from: event.anchorRootId,
                    to: event.joinRootId,
                    fromPmi: event.anchorPmiId,
                    toPmi: event.joinPmiId,
                    rot: event.rot,
                });
                for (const member of moving.members) this.elementToGroup.set(member, target.id);
                this.groups.delete(event.movingGroupId);
                break;
            }
            case "group-move":
            case "group-rotate": {
                const g = this.groups.get(event.groupId);
                if (!g) break;
                g.transform = { pos: event.position, rot: event.rotation };
                break;
            }
            case "group-detach": {
                if (!this.groups.has(event.sourceGroupId)) break;
                for (const ng of event.newGroups) {
                    this.groups.set(ng.groupId, {
                        id: ng.groupId,
                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                this.groups.delete(event.sourceGroupId);
                break;
            }
            case "group-regroup": {
                for (const sid of event.sourceGroupIds) this.groups.delete(sid);
                for (const ng of event.newGroups) {
                    this.groups.set(ng.groupId, {
                        id: ng.groupId,
                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                break;
            }
        }
    }

    private materialize(baseScene: Scene): Scene {
        const handles: Handle[] = [...(baseScene?.handles ?? [])];


Ich lese den Rest der Testdatei und die Materialisierung (wie `translation`/`rotation` gesetzt werden), damit die neue Geometrie exakt stimmt.


488	    private materialize(baseScene: Scene): Scene {
489	        const handles: Handle[] = [...(baseScene?.handles ?? [])];
490	        if (this.groups.size > 0) handles.push(this.sceneRootHandle());
491
492	        for (const g of this.groups.values()) {
493	            const layout = this.computeLayout(g);
494	            for (const member of g.members) {
495	                const subtree = this.elementHandles.get(member) ?? [];
496	                const root = this.handles.get(member);
497	                const tr = this.rootTransform(root);
498	                const lay = layout.get(member) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };
499	                // Parent the element root to the group node. The member carries its group-local rotation
500	                // (identity for a plain left/right chain; non-identity for a branch docked at an angle);
501	                // the group node carries the group's world orientation on top.
502	                tr.parentKey = g.key;
503	                tr.translation = lay.pos;
504	                tr.rotation = lay.rot;
505	                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
506	                handles.push(...subtree);
507	            }
508	            handles.push(this.groupNodeHandle(g));
509	        }
510	        this.buildNodeIndex(handles);
511	        return { ...baseScene, handles };
512	    }
513
514	    // Group-local rigid transforms (pos + rot) per member. A group is a tree (each merge joins two
515	    // disjoint parts with one edge), so transforms propagate from an arbitrary origin over the

    private materialize(baseScene: Scene): Scene {
        const handles: Handle[] = [...(baseScene?.handles ?? [])];
        if (this.groups.size > 0) handles.push(this.sceneRootHandle());

        for (const g of this.groups.values()) {
            const layout = this.computeLayout(g);
            for (const member of g.members) {
                const subtree = this.elementHandles.get(member) ?? [];
                const root = this.handles.get(member);
                const tr = this.rootTransform(root);
                const lay = layout.get(member) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };
                // Parent the element root to the group node. The member carries its group-local rotation
                // (identity for a plain left/right chain; non-identity for a branch docked at an angle);
                // the group node carries the group's world orientation on top.
                tr.parentKey = g.key;
                tr.translation = lay.pos;
                tr.rotation = lay.rot;
                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
                handles.push(...subtree);
            }
            handles.push(this.groupNodeHandle(g));
        }
        this.buildNodeIndex(handles);
        return { ...baseScene, handles };
    }

    // Group-local rigid transforms (pos + rot) per member. A group is a tree (each merge joins two
    // disjoint parts with one edge), so transforms propagate from an arbitrary origin over the


228	    });
229
230	    it("leaves a plain (identity) merge unrotated", () => {
231	        const { gp, ids, events } = buildChain(2);
232	        const scene = gp.replay(base(), events);
233	        expect(transformOf(scene, ids[1]).rotation).toEqual(IDENTITY_QUAT);
234	    });
235
236	    it("eulerZyxRadToQuat: 90° about Z equals the [w,x,y,z] Z90 quat", () => {
237	        const q = eulerZyxRadToQuat([0, 0, Math.PI / 2]);
238	        expect(q[0]).toBeCloseTo(Z90[0]);
239	        expect(q[1]).toBeCloseTo(Z90[1]);
240	        expect(q[2]).toBeCloseTo(Z90[2]);
241	        expect(q[3]).toBeCloseTo(Z90[3]);
242	    });
243
244	    it("dockRotation of the plain left/right points is identity", () => {
245	        const gp = new GroupProcessor();
246	        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
247	        const e2 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
248	        gp.replay(base(), [e1, e2]);
249	        const r = gp.dockRotation(e1.rootHandleId, "CS_RECHTS", e2.rootHandleId, "CS_LINKS");
250	        expect(r[0]).toBeCloseTo(1);
251	        expect(r[1]).toBeCloseTo(0);
252	        expect(r[2]).toBeCloseTo(0);
253	        expect(r[3]).toBeCloseTo(0);
254	    });
255	});
256
257	describe("GroupProcessor — areConnected", () => {
258	    it("is true for a contiguous run, false across a gap", () => {
259	        const { gp, ids, events } = buildChain(3);
260	        gp.replay(base(), events);
261	        const [a, b, c] = ids;
262	        expect(gp.areConnected([a, b])).toBe(true);
263	        expect(gp.areConnected([a, b, c])).toBe(true);
264	        expect(gp.areConnected([a, c])).toBe(false); // b missing → induced subgraph splits
265	        expect(gp.areConnected([b])).toBe(true);
266	    });
267	});

    });

    it("leaves a plain (identity) merge unrotated", () => {
        const { gp, ids, events } = buildChain(2);
        const scene = gp.replay(base(), events);
        expect(transformOf(scene, ids[1]).rotation).toEqual(IDENTITY_QUAT);
    });

    it("eulerZyxRadToQuat: 90° about Z equals the [w,x,y,z] Z90 quat", () => {
        const q = eulerZyxRadToQuat([0, 0, Math.PI / 2]);
        expect(q[0]).toBeCloseTo(Z90[0]);
        expect(q[1]).toBeCloseTo(Z90[1]);
        expect(q[2]).toBeCloseTo(Z90[2]);
        expect(q[3]).toBeCloseTo(Z90[3]);
    });

    it("dockRotation of the plain left/right points is identity", () => {
        const gp = new GroupProcessor();
        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
        const e2 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
        gp.replay(base(), [e1, e2]);
        const r = gp.dockRotation(e1.rootHandleId, "CS_RECHTS", e2.rootHandleId, "CS_LINKS");
        expect(r[0]).toBeCloseTo(1);
        expect(r[1]).toBeCloseTo(0);
        expect(r[2]).toBeCloseTo(0);
        expect(r[3]).toBeCloseTo(0);
    });
});

describe("GroupProcessor — areConnected", () => {
    it("is true for a contiguous run, false across a gap", () => {
        const { gp, ids, events } = buildChain(3);
        gp.replay(base(), events);
        const [a, b, c] = ids;
        expect(gp.areConnected([a, b])).toBe(true);
        expect(gp.areConnected([a, b, c])).toBe(true);
        expect(gp.areConnected([a, c])).toBe(false); // b missing → induced subgraph splits
        expect(gp.areConnected([b])).toBe(true);
    });
});


Ich verifiziere `eulerZyxRadToQuat`/`quatRotate`, damit die unabhängige 90°-Achsen-Prüfung im Test korrekt ist.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
echo "=== eulerZyxRadToQuat + quatRotate ==="
grep -n 'export function eulerZyxRadToQuat\|export function quatRotate\|export function quatMul\|export function quatConj\|export const IDENTITY_QUAT\|export type Quat\|export type Vec3' src/configurator/model/transformMath.ts
echo
sed -n '/export function eulerZyxRadToQuat/,/^}/p' src/configurator/model/transformMath.ts
echo "--- quatRotate ---"
sed -n '/export function quatRotate/,/^}/p' src/configurator/model/transformMath.ts

Euler/Quat-Helfer inspizieren


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

export function eulerZyxRadToQuat(rad: Vec3): Quat {
    const x = rad[0],
        y = rad[1],
        z = rad[2];
    const cx = Math.cos(x / 2),
        cy = Math.cos(y / 2),
        cz = Math.cos(z / 2);
    const sx = Math.sin(x / 2),
        sy = Math.sin(y / 2),
        sz = Math.sin(z / 2);
    return [
        cx * cy * cz + sx * sy * sz,
        sx * cy * cz - cx * sy * sz,
        cx * sy * cz + sx * cy * sz,
        cx * cy * sz - sx * sy * cz,
    ];
}
--- quatRotate ---
export function quatRotate(q: Quat, v: Vec3): Vec3 {
    const [w, x, y, z] = q;
    const tx = 2 * (y * v[2] - z * v[1]);
    const ty = 2 * (z * v[0] - x * v[2]);
    const tz = 2 * (x * v[1] - y * v[0]);
    return [
        v[0] + w * tx + (y * tz - z * ty),
        v[1] + w * ty + (z * tx - x * tz),
        v[2] + w * tz + (x * ty - y * tx),
    ];
}


Verifiziert: `eulerZyxRadToQuat([0,-π/2,0])` ist eine reine Y-Drehung, `dockRotation(CS_OUT_R_90, CS_EIN_L) ≈ Ry(+90°)`, und `[1,0,0]→[0,0,-1]`. Geometrie geht auf (`pos(B)=[1084,0,-130]`). Ich baue die Tests um.


Edit

import { quatRotate, IDENTITY_QUAT, eulerZyxRadToQuat, type Quat } from "./transformMath";

import { quatRotate, IDENTITY_QUAT, eulerZyxRadToQuat, type Quat, type Vec3 } from "./transformMath";


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

import { describe, it, expect } from "vitest";

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

-import { quatRotate, IDENTITY_QUAT, eulerZyxRadToQuat, type Quat } from "./transformMath";

+import { quatRotate, IDENTITY_QUAT, eulerZyxRadToQuat, type Quat, type Vec3 } from "./transformMath";

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

// A real schutzzaun part: root handle (key "root") with CS_LINKS at 0 and CS_RECHTS at 2.168, plus a


// A real schutzzaun part: root handle (key "root") with CS_LINKS at 0 and CS_RECHTS at 2.168, plus a
// scaled subtree of colliders. Cloned fresh each time so ids don't leak between cases.
const product = () => structuredClone(productData);
const WIDTH = 2.168; // CS_RECHTS.x of the part

// A real schutzzaun_basic door part (class-tag "class-schutzzaun_basic-tuer"). Its root handle carries
// the placement-logic PMIs: CS_EIN_L/CS_EIN_R (entry/join points), CS_OUT_L_0/CS_OUT_R_0 (open left/
// right ends) and the 90° branch outs CS_OUT_*_90. Cloned fresh each time so ids don't leak.
const product = () => structuredClone(productData);
// A part docks its left entry (CS_EIN_L, x=-20) onto the previous part's right end (CS_OUT_R_0,
// x=1114); the x-gap between those points is one chain step.
const STEP = 1114 - -20; // 1134


// A real schutzzaun part: root handle (key "root") with CS_LINKS at 0 and CS_RECHTS at 2.168, plus a
// scaled subtree of colliders. Cloned fresh each time so ids don't leak between cases.
const product = () => structuredClone(productData);
const WIDTH = 2.168; // CS_RECHTS.x of the part

// A real schutzzaun_basic door part (class-tag "class-schutzzaun_basic-tuer"). Its root handle carries
// the placement-logic PMIs: CS_EIN_L/CS_EIN_R (entry/join points), CS_OUT_L_0/CS_OUT_R_0 (open left/
// right ends) and the 90° branch outs CS_OUT_*_90. Cloned fresh each time so ids don't leak.
const product = () => structuredClone(productData);
// A part docks its left entry (CS_EIN_L, x=-20) onto the previous part's right end (CS_OUT_R_0,
// x=1114); the x-gap between those points is one chain step.
const STEP = 1114 - -20; // 1134

import { quatRotate, IDENTITY_QUAT, eulerZyxRadToQuat, type Quat, type Vec3 } from "./transformMath";

-// A real schutzzaun part: root handle (key "root") with CS_LINKS at 0 and CS_RECHTS at 2.168, plus a

-// scaled subtree of colliders. Cloned fresh each time so ids don't leak between cases.

+// A real schutzzaun_basic door part (class-tag "class-schutzzaun_basic-tuer"). Its root handle carries

+// the placement-logic PMIs: CS_EIN_L/CS_EIN_R (entry/join points), CS_OUT_L_0/CS_OUT_R_0 (open left/

+// right ends) and the 90° branch outs CS_OUT_*_90. Cloned fresh each time so ids don't leak.

-const WIDTH = 2.168; // CS_RECHTS.x of the part

+// A part docks its left entry (CS_EIN_L, x=-20) onto the previous part's right end (CS_OUT_R_0,

+// x=1114); the x-gap between those points is one chain step.

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


    anchorPmi = "CS_RECHTS",
    joinPmi = "CS_LINKS",

    anchorPmi = "CS_OUT_R_0",
    joinPmi = "CS_EIN_L",


    anchorPmi = "CS_RECHTS",
    joinPmi = "CS_LINKS",

    anchorPmi = "CS_OUT_R_0",
    joinPmi = "CS_EIN_L",

moving: string,
anchor: string,
join: string,
-    anchorPmi = "CS_RECHTS",
-    joinPmi = "CS_LINKS",
+    anchorPmi = "CS_OUT_R_0",
+    joinPmi = "CS_EIN_L",
): GroupEvent => ({
type: "group-merge",
timestamp: 0,


        expect(worldX(scene, b)).toBeCloseTo(WIDTH);
        expect(worldX(scene, c)).toBeCloseTo(2 * WIDTH);

        expect(worldX(scene, b)).toBeCloseTo(STEP);
        expect(worldX(scene, c)).toBeCloseTo(2 * STEP);


        expect(worldX(scene, b)).toBeCloseTo(WIDTH);
        expect(worldX(scene, c)).toBeCloseTo(2 * WIDTH);

        expect(worldX(scene, b)).toBeCloseTo(STEP);
        expect(worldX(scene, c)).toBeCloseTo(2 * STEP);

-        expect(worldX(scene, b)).toBeCloseTo(WIDTH);

-        expect(worldX(scene, c)).toBeCloseTo(2 * WIDTH);

+        expect(worldX(scene, b)).toBeCloseTo(STEP);

+        expect(worldX(scene, c)).toBeCloseTo(2 * STEP);

it("materialises exactly one parentless scene root, group nodes hang under it", () => {


        // Third merge uses the still-open ends (a's left, d's right) — merging on already-occupied
        // points is now rejected by the double-booking guard. d is still the join (tail traversal).
        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_LINKS", "CS_RECHTS")];

        // Third merge uses the still-open ends (a's left CS_OUT_L_0, d's right entry CS_EIN_R) — merging
        // on already-occupied points is rejected by the double-booking guard. d is still the join (tail
        // traversal), so undirected traversal must still reach c.
        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_OUT_L_0", "CS_EIN_R")];


        // Third merge uses the still-open ends (a's left, d's right) — merging on already-occupied
        // points is now rejected by the double-booking guard. d is still the join (tail traversal).
        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_LINKS", "CS_RECHTS")];

        // Third merge uses the still-open ends (a's left CS_OUT_L_0, d's right entry CS_EIN_R) — merging
        // on already-occupied points is rejected by the double-booking guard. d is still the join (tail
        // traversal), so undirected traversal must still reach c.
        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_OUT_L_0", "CS_EIN_R")];

-        // Third merge uses the still-open ends (a's left, d's right) — merging on already-occupied

-        // points is now rejected by the double-booking guard. d is still the join (tail traversal).

-        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_LINKS", "CS_RECHTS")];

+        // Third merge uses the still-open ends (a's left CS_OUT_L_0, d's right entry CS_EIN_R) — merging

+        // on already-occupied points is rejected by the double-booking guard. d is still the join (tail

+        // traversal), so undirected traversal must still reach c.

+        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_OUT_L_0", "CS_EIN_R")];

const scene = gp.replay(base(), events);

for (const id of [a, b, c, d]) expect(transformOf(scene, id).translation).toBeDefined();


        expect(occ).toContainEqual({ element: a, pmi: "CS_RECHTS" });
        expect(occ).toContainEqual({ element: b, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });

        expect(occ).toContainEqual({ element: a, pmi: "CS_OUT_R_0" });
        expect(occ).toContainEqual({ element: b, pmi: "CS_EIN_L" });
        expect(occ).not.toContainEqual({ element: a, pmi: "CS_EIN_L" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_OUT_R_0" });


        expect(occ).toContainEqual({ element: a, pmi: "CS_RECHTS" });
        expect(occ).toContainEqual({ element: b, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });

        expect(occ).toContainEqual({ element: a, pmi: "CS_OUT_R_0" });
        expect(occ).toContainEqual({ element: b, pmi: "CS_EIN_L" });
        expect(occ).not.toContainEqual({ element: a, pmi: "CS_EIN_L" });
        expect(occ).not.toContainEqual({ element: b, pmi: "CS_OUT_R_0" });

-        expect(occ).toContainEqual({ element: a, pmi: "CS_RECHTS" });

-        expect(occ).toContainEqual({ element: b, pmi: "CS_LINKS" });

-        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });

-        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });

+        expect(occ).toContainEqual({ element: a, pmi: "CS_OUT_R_0" });

+        expect(occ).toContainEqual({ element: b, pmi: "CS_EIN_L" });

+        expect(occ).not.toContainEqual({ element: a, pmi: "CS_EIN_L" });

+        expect(occ).not.toContainEqual({ element: b, pmi: "CS_OUT_R_0" });

it("rejects a second merge onto an already-occupied docking point (no double booking)", () => {


        // b docks onto a.CS_RECHTS; c then tries the very same point → rejected, c stays separate.
        gp.replay(base(), [...ev, merge(gA, gB, a, b), merge(gA, gC, a, c)]);
        expect(gp.groupCount()).toBe(2);
        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_RECHTS")).toHaveLength(1);

        // b docks onto a.CS_OUT_R_0; c then tries the very same point → rejected, c stays separate.
        gp.replay(base(), [...ev, merge(gA, gB, a, b), merge(gA, gC, a, c)]);
        expect(gp.groupCount()).toBe(2);
        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_OUT_R_0")).toHaveLength(1);


        // b docks onto a.CS_RECHTS; c then tries the very same point → rejected, c stays separate.
        gp.replay(base(), [...ev, merge(gA, gB, a, b), merge(gA, gC, a, c)]);
        expect(gp.groupCount()).toBe(2);
        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_RECHTS")).toHaveLength(1);

        // b docks onto a.CS_OUT_R_0; c then tries the very same point → rejected, c stays separate.
        gp.replay(base(), [...ev, merge(gA, gB, a, b), merge(gA, gC, a, c)]);
        expect(gp.groupCount()).toBe(2);
        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_OUT_R_0")).toHaveLength(1);

-        // b docks onto a.CS_RECHTS; c then tries the very same point → rejected, c stays separate.

+        // b docks onto a.CS_OUT_R_0; c then tries the very same point → rejected, c stays separate.

-        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_RECHTS")).toHaveLength(1);

+        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_OUT_R_0")).toHaveLength(1);


Jetzt der 90°-Dock-Test auf die echte Placement-Logik (`dockRotation` aus PMI-Frames):


    it("applies the edge rotation to the docked member and rotates its offset", () => {
        const gp = new GroupProcessor();
        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
        const e2 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
        const [a, b] = [e1.rootHandleId, e2.rootHandleId];
        gp.replay(base(), [e1, e2]);
        // Dock B's CS_RECHTS (local [WIDTH,0,0]) onto A's CS_LINKS (origin), turned 90° about Z.
        const rotMerge: GroupEvent = {
            type: "group-merge",
            timestamp: 0,
            targetGroupId: gp.groupIdOfElement(a)!,
            movingGroupId: gp.groupIdOfElement(b)!,
            anchorRootId: a,
            joinRootId: b,
            anchorPmiId: "CS_LINKS",
            joinPmiId: "CS_RECHTS",
            rot: Z90,
        };
        const scene = gp.replay(base(), [e1, e2, rotMerge]);
        const trB = transformOf(scene, b);
        // The member carries the edge rotation in its group-local transform.
        expect(trB.rotation).toEqual(Z90);
        // pos(B) = pos(A) + R_A·at(CS_LINKS=0) − R_B·dock(CS_RECHTS=[WIDTH,0,0]) = −R·[WIDTH,0,0].
        const expected = quatRotate(Z90, [WIDTH, 0, 0]).map((v) => -v);
        expect(trB.translation[0]).toBeCloseTo(expected[0]);
        expect(trB.translation[1]).toBeCloseTo(expected[1]);
        expect(trB.translation[2]).toBeCloseTo(expected[2]);
    });

    it("derives the branch rotation from the PMI frames and rotates the docked member's offset", () => {
        const gp = new GroupProcessor();
        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
        const e2 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
        const [a, b] = [e1.rootHandleId, e2.rootHandleId];
        gp.replay(base(), [e1, e2]);
        // Dock B's straight entry (CS_EIN_L) onto A's 90° branch out (CS_OUT_R_90). The relative rotation
        // is what the placement logic derives via dockRotation from the two points' frames — a quarter
        // turn about Y that swings the docked part onto the branch.
        const rot = gp.dockRotation(a, "CS_OUT_R_90", b, "CS_EIN_L");
        const turnedX = quatRotate(rot, [1, 0, 0]);
        expect(Math.abs(turnedX[0])).toBeCloseTo(0); // +X no longer maps to +X …
        expect(Math.abs(turnedX[2])).toBeCloseTo(1); // … it swings into ±Z → 90° about Y.
        const merge90: GroupEvent = {
            type: "group-merge",
            timestamp: 0,
            targetGroupId: gp.groupIdOfElement(a)!,
            movingGroupId: gp.groupIdOfElement(b)!,
            anchorRootId: a,
            joinRootId: b,
            anchorPmiId: "CS_OUT_R_90",
            joinPmiId: "CS_EIN_L",
            rot,
        };
        const scene = gp.replay(base(), [e1, e2, merge90]);
        const trB = transformOf(scene, b);
        // The member carries the derived rotation in its group-local transform.
        (rot as number[]).forEach((v, i) => expect(trB.rotation[i]).toBeCloseTo(v));
        // A is the origin (identity), so pos(B) = at(CS_OUT_R_90) − rot·dock(CS_EIN_L).
        const at: Vec3 = [1064, 1067, -110];
        const dock: Vec3 = [-20, 1067, -20];
        const rotated = quatRotate(rot, dock);
        const expected = at.map((v, i) => v - rotated[i]);
        expected.forEach((v, i) => expect(trB.translation[i]).toBeCloseTo(v));
    });