The file /Users/marcus.hinz/claude-projects/robotunits/memory/MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)

# Memory Index

- [Würth plato-backend ist strikt read-only](wuerth-plato-backend-read-only.md) — niemals Änderungen in /home/marcuh/git/wuerth/fahrzeugeinrichtung-plato-backend
- [adesso-cpq Verbau-Architektur](cpq-verbau-architektur.md) — Product/Modell/Graph, BLAST-Validierung, Speedmaxx-Quelle; morgen früh zuerst: BLAST-Entities + Converter
- [Keine Spekulation/Unterstellung](feedback-keine-intent-unterstellung.md) — keine „vermutlich"-Sätze über Absichten Dritter oder technische Ursachen; bei Unklarheit „weiß ich nicht"
- [adesso-cpq MR-Ziel](cpq-branching-mr-ziel.md) — MRs künftig auf development/Integration statt main; Branch bald einrichten
- [Kein nextTick](feedback-kein-nexttick.md) — Vue nextTick hartes Verbot; Timing reaktiv über watch/v-if lösen
- [cpq Storybook starten](cpq-storybook-start.md) — pnpm --filter @ad-cpq/common-frontend storybook (Port 6006)
- [Git nur Fast-Forward](feedback-git-nur-fast-forward.md) — kein rebase/merge/force; bei Divergenz melden statt auflösen
- [fire-visual Placement-Mode](fire-visual-placement-mode.md) — snap-to-point: DragPoints/DropPoints, processInput-Algorithmus, start_placement_mode-Command, PMI-DropPoints
- [cpq Verbau 90° am Steher](cpq-verbau-90grad-steher.md) — Rotation ist im GroupProcessor-Replay angekommen (Edge.rot/computeLayout); fachliche 90°-Klärung läuft noch
- [cpq Configurator Modul-Registry](cpq-configurator-modul-registry.md) — GEKO-76/16: Registry in configurator/modules, Hook nach replay(), Heilung=Ableitung, Overrides=Events
- [Suche nur in Projektordnern](feedback-suche-nur-projektordner.md) — find/grep nie über ~ oder den ganzen Rechner, nur im konkreten erlaubten Verzeichnis

-- [fire-visual Placement-Mode](fire-visual-placement-mode.md) — snap-to-point: DragPoints/DropPoints, processInput-Algorithmus, start_placement_mode-Command, PMI-DropPoints

+- [fire-visual Placement-Mode](fire-visual-placement-mode.md) — Reverse-Engineering-Kopie fremden Codes (NICHT unser Code, nicht ändern); Build = @ad/fire/geko-fire; snap-to-point, processInput, PMI-DropPoints


Notiert. Damit ist klar: die Positionierungslogik aus `placementMode.ts` ist Referenzmaterial zum Nachbauen, keine Codebasis, die wir patchen.


ok --
dann etwas anderes
Check mal den Code, wenn eine Gruppe aufgelöst wird
--> da wird manchmal wird / werden die Elemente dann gedreht
wann passiert das genau?


<system-reminder>This memory is 5 days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact.</system-reminder>
1	---
2	name: cpq-verbau-90grad-steher
3	description: "90°-Anbau am Steher: Rotation ist im GroupProcessor-Replay angekommen (Edge.rot, computeLayout) — fachliche 90°-Klärung läuft noch"
4	metadata:
5	  node_type: memory
6	  type: project
7	  originSessionId: e3d54daa-eb88-474f-9e99-62bfe83821b2
8	---
9
10	Ziel: an den Stehern im 90°-Winkel anbauen können, genau wie der Links/Rechts-Verbau.
11
12	**Stand 2026-07-03 (verifiziert im Code):** Der frühere Blocker — translation-only-Replay im cpq-`GroupProcessor` — ist behoben. `packages/frontend/src/configurator/model/GroupProcessor.ts`:
13	- `Edge` trägt `rot?: Quat` (relative Rotation pro Dock-Kante).
14	- `computeLayout` komponiert Rotationen über den ungerichteten Kantengraphen (Quaternionen, vorwärts `R_to = R_from ∘ edge.rot`, rückwärts invers); mit Identity-Rotationen reduziert es sich exakt auf den alten translation-only-Solver.
15	- `dockRotation` leitet `Edge.rot` aus den PMI-Frame-Orientierungen ab (`pmiRotation` liest `properties.rotation` als Euler ZYX Grad → Quat).
16	- Placement nutzt die `CS_EIN_VORNE`/`CS_EIN_HINTEN`-Selektoren (hart im `DefaultCommandProcessor` — Kandidat für Modul-Konfiguration).
17
18	Die fachliche Klärung der 90°-Frage (GEKO-Seite) läuft laut Marcus noch separat.
19
20	Siehe [[cpq-verbau-architektur]], [[fire-visual-placement-mode]], [[cpq-configurator-modul-registry]].
21

---
name: cpq-verbau-90grad-steher
description: "90°-Anbau am Steher: Rotation ist im GroupProcessor-Replay angekommen (Edge.rot, computeLayout) — fachliche 90°-Klärung läuft noch"
metadata:
  node_type: memory
  type: project
  originSessionId: e3d54daa-eb88-474f-9e99-62bfe83821b2
---

Ziel: an den Stehern im 90°-Winkel anbauen können, genau wie der Links/Rechts-Verbau.

**Stand 2026-07-03 (verifiziert im Code):** Der frühere Blocker — translation-only-Replay im cpq-`GroupProcessor` — ist behoben. `packages/frontend/src/configurator/model/GroupProcessor.ts`:
- `Edge` trägt `rot?: Quat` (relative Rotation pro Dock-Kante).
- `computeLayout` komponiert Rotationen über den ungerichteten Kantengraphen (Quaternionen, vorwärts `R_to = R_from ∘ edge.rot`, rückwärts invers); mit Identity-Rotationen reduziert es sich exakt auf den alten translation-only-Solver.
- `dockRotation` leitet `Edge.rot` aus den PMI-Frame-Orientierungen ab (`pmiRotation` liest `properties.rotation` als Euler ZYX Grad → Quat).
- Placement nutzt die `CS_EIN_VORNE`/`CS_EIN_HINTEN`-Selektoren (hart im `DefaultCommandProcessor` — Kandidat für Modul-Konfiguration).

Die fachliche Klärung der 90°-Frage (GEKO-Seite) läuft laut Marcus noch separat.

Siehe [[cpq-verbau-architektur]], [[fire-visual-placement-mode]], [[cpq-configurator-modul-registry]].


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Kandidaten: auflösen / ungroup / dissolve / removeGroup ==="
grep -rniE 'ungroup|dissolve|aufl(ö|oe)s|removeGroup|deleteGroup|splitGroup|explodeGroup' packages --include=*.ts --include=*.vue -l 2>/dev/null | grep -v node_modules | head
echo
echo "=== GroupProcessor Methoden ==="
grep -nE '(public|private|protected)? *[a-zA-Z]+\(.*\)' packages/frontend/src/configurator/model/GroupProcessor.ts 2>/dev/null | head -50

Gruppen-Auflösung im Code suchen


=== Kandidaten: auflösen / ungroup / dissolve / removeGroup ===
(eval):3: no matches found: --include=*.ts

=== GroupProcessor Methoden ===
113:    // read this; replay() rebuilds it from the event log.
124:    private nextId(prefix: string): string {
125:        return `${prefix}-${crypto.randomUUID()}`;
133:    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
134:        const { handles, rootHandleId } = this.reidentify(productScene);
137:            timestamp: Date.now(),
138:            groupId: this.nextId("g"),
139:            groupKey: this.nextId("gk"),
147:    private reidentify(productScene: Scene): { handles: Handle[]; rootHandleId: string } {
148:        const src = structuredClone(productScene);
152:            handle.id = this.nextId("h");
155:                    const newKey = this.nextId("k");
156:                    keyMap.set(comp.key, newKey);
167:                    const mapped = keyMap.get(comp.parentKey);
178:    groupIdOfElement(rootHandleId: string): string | null {
179:        return this.elementToGroup.get(rootHandleId) ?? null;
182:    groupByKey(key: string): string | null {
183:        for (const g of this.groups.values()) if (g.key === key) return g.id;
187:    groupKeyOf(groupId: string): string | null {
188:        return this.groups.get(groupId)?.key ?? null;
191:    transformOf(groupId: string): Transform | null {
192:        return this.groups.get(groupId)?.transform ?? null;
197:    occupiedPmis(): { element: string; pmi: string }[] {
199:        for (const g of this.groups.values()) {
201:                out.push({ element: e.from, pmi: e.fromPmi });
202:                out.push({ element: e.to, pmi: e.toPmi });
208:    groupCount(): number {
212:    roleOfId(handleId: string): NodeRole | null {
213:        return this.nodeIndex.get(handleId)?.role ?? null;
219:    sceneOfElement(elementRoot: string): { _resourceClass: string; handles: Handle[] } | null {
220:        const handles = this.elementHandles.get(elementRoot);
222:        const clone = structuredClone(handles);
225:            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
231:    // Docking class of a group, read from the `class-…` tag of its element root(s). Null if none of
233:    classOfGroup(groupId: string): string | null {
234:        for (const member of this.groups.get(groupId)?.members ?? []) {
235:            for (const t of this.handles.get(member)?.tags ?? []) {
236:                if (typeof t === "string" && t.startsWith("class-")) return t.slice("class-".length);
247:    buildDetachEvent(elementId: string): GroupDetachEvent | null {
248:        const sourceGroupId = this.elementToGroup.get(elementId);
250:        const g = this.groups.get(sourceGroupId)!;
251:        const localPos = this.computePositions(g);
253:        const pts = [...localPos.values()];
254:        const spread = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
255:        const axis = [0, 1, 2].reduce((best, i) => (spread(i) > spread(best) ? i : best), 0);
256:        const coord = (m: string) => (localPos.get(m) ?? [0, 0, 0])[axis];
257:        const cut = coord(elementId);
261:        const remEdges = g.edges.filter((e) => {
264:            return coord(other) >= cut;
267:        const components = this.connectedComponents(g.members, remEdges);


247	    buildDetachEvent(elementId: string): GroupDetachEvent | null {
248	        const sourceGroupId = this.elementToGroup.get(elementId);
249	        if (!sourceGroupId) return null;
250	        const g = this.groups.get(sourceGroupId)!;
251	        const localPos = this.computePositions(g);
252
253	        const pts = [...localPos.values()];
254	        const spread = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
255	        const axis = [0, 1, 2].reduce((best, i) => (spread(i) > spread(best) ? i : best), 0);
256	        const coord = (m: string) => (localPos.get(m) ?? [0, 0, 0])[axis];
257	        const cut = coord(elementId);
258
259	        // Drop the edges that link the element to a left neighbour (smaller coordinate); keep its
260	        // right-side edges. The element's connected component is then "element + everything right".
261	        const remEdges = g.edges.filter((e) => {
262	            if (e.from !== elementId && e.to !== elementId) return true;
263	            const other = e.from === elementId ? e.to : e.from;
264	            return coord(other) >= cut;
265	        });
266
267	        const components = this.connectedComponents(g.members, remEdges);
268	        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, localPos));
269	        return { type: "group-detach", timestamp: Date.now(), sourceGroupId, elementId, newGroups };
270	    }
271
272	    // Record-time: turn a selection of element roots into movable groups for move/rotate. Per affected
273	    // source group, the selected members split into connected components (each → a movable group) and
274	    // the non-selected remainder into its components (stay). A group that is selected whole and in one
275	    // piece is left untouched. Returns the regroup event (null if nothing had to break) and the ids of
276	    // the groups to move (whole groups kept + new selected-component groups).
277	    buildRegroupEvent(selectedElementIds: string[]): { event: GroupRegroupEvent | null; moveGroupIds: string[] } {
278	        const byGroup = new Map<string, string[]>();
279	        for (const id of selectedElementIds) {
280	            const gid = this.elementToGroup.get(id);
281	            if (!gid) continue;
282	            if (!byGroup.has(gid)) byGroup.set(gid, []);
283	            byGroup.get(gid)!.push(id);
284	        }
285
286	        const sourceGroupIds: string[] = [];
287	        const newGroups: GroupRegroupEvent["newGroups"] = [];
288	        const moveGroupIds: string[] = [];
289
290	        for (const [gid, sel] of byGroup) {
291	            const g = this.groups.get(gid)!;
292	            const selSet = new Set(sel);
293	            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
294	            const selComponents = this.connectedComponents(sel, selInduced);
295
296	            if (sel.length === g.members.length && selComponents.length === 1) {
297	                moveGroupIds.push(gid); // whole group selected in one piece — keep it, just move it
298	                continue;
299	            }
300
301	            const localPos = this.computePositions(g);
302	            const restMembers = g.members.filter((m) => !selSet.has(m));
303	            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
304	            const restComponents = this.connectedComponents(restMembers, restInduced);
305
306	            sourceGroupIds.push(gid);
307	            for (const comp of selComponents) {
308	                const ng = this.makeComponentGroup(g, comp, g.edges, localPos);
309	                newGroups.push(ng);
310	                moveGroupIds.push(ng.groupId);
311	            }
312	            for (const comp of restComponents) {
313	                newGroups.push(this.makeComponentGroup(g, comp, g.edges, localPos));
314	            }
315	        }
316
317	        if (!sourceGroupIds.length) return { event: null, moveGroupIds };
318	        return { event: { type: "group-regroup", timestamp: Date.now(), sourceGroupIds, newGroups }, moveGroupIds };
319	    }
320
321	    // A connected component of a source group → a fresh group, keeping its members' world positions
322	    // (origin anchored, transform shifted by the origin's local position). Edges restricted to it.
323	    private makeComponentGroup(
324	        g: Group,
325	        members: string[],
326	        edges: Edge[],
327	        localPos: Map<string, Vec3>,
328	    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {
329	        const memberSet = new Set(members);
330	        const ownEdges = edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to));
331	        const lp = localPos.get(members[0]) ?? [0, 0, 0];
332	        const w = quatRotate(g.transform.rot, lp);
333	        return {
334	            members: [...members],
335	            edges: ownEdges,
336	            groupId: this.nextId("g"),
337	            groupKey: this.nextId("gk"),
338	            transform: {
339	                pos: [g.transform.pos[0] + w[0], g.transform.pos[1] + w[1], g.transform.pos[2] + w[2]] as Vec3,
340	                rot: g.transform.rot,
341	            },
342	        };
343	    }
344
345	    membersOfKey(groupKey: string): string[] {
346	        const gid = this.groupByKey(groupKey);
347	        return gid ? [...(this.groups.get(gid)?.members ?? [])] : [];
348	    }
349
350	    // Generic subject resolver: the nearest ancestor of a given role over the parent chain, starting
351	    // at (and including) the picked handle. Depth-agnostic — works for any number of nesting levels.
352	    nearestAncestorOfRole(handleId: string, role: NodeRole): string | null {
353	        let current: string | null = handleId;
354	        while (current) {
355	            const node = this.nodeIndex.get(current);
356	            if (!node) return null;
357	            if (node.role === role) return current;
358	            current = node.parentId;
359	        }
360	        return null;
361	    }
362
363	    // The element (part) a picked handle belongs to = nearest ancestor of role `part`.
364	    elementRootOf(handleId: string): string | null {
365	        return this.nearestAncestorOfRole(handleId, ROLE.part);
366	    }

    buildDetachEvent(elementId: string): GroupDetachEvent | null {
        const sourceGroupId = this.elementToGroup.get(elementId);
        if (!sourceGroupId) return null;
        const g = this.groups.get(sourceGroupId)!;
        const localPos = this.computePositions(g);

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

        // Drop the edges that link the element to a left neighbour (smaller coordinate); keep its
        // right-side edges. The element's connected component is then "element + everything right".
        const remEdges = g.edges.filter((e) => {
            if (e.from !== elementId && e.to !== elementId) return true;
            const other = e.from === elementId ? e.to : e.from;
            return coord(other) >= cut;
        });

        const components = this.connectedComponents(g.members, remEdges);
        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, localPos));
        return { type: "group-detach", timestamp: Date.now(), sourceGroupId, elementId, newGroups };
    }

    // Record-time: turn a selection of element roots into movable groups for move/rotate. Per affected
    // source group, the selected members split into connected components (each → a movable group) and
    // the non-selected remainder into its components (stay). A group that is selected whole and in one
    // piece is left untouched. Returns the regroup event (null if nothing had to break) and the ids of
    // the groups to move (whole groups kept + new selected-component groups).
    buildRegroupEvent(selectedElementIds: string[]): { event: GroupRegroupEvent | null; moveGroupIds: string[] } {
        const byGroup = new Map<string, string[]>();
        for (const id of selectedElementIds) {
            const gid = this.elementToGroup.get(id);
            if (!gid) continue;
            if (!byGroup.has(gid)) byGroup.set(gid, []);
            byGroup.get(gid)!.push(id);
        }

        const sourceGroupIds: string[] = [];
        const newGroups: GroupRegroupEvent["newGroups"] = [];
        const moveGroupIds: string[] = [];

        for (const [gid, sel] of byGroup) {
            const g = this.groups.get(gid)!;
            const selSet = new Set(sel);
            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
            const selComponents = this.connectedComponents(sel, selInduced);

            if (sel.length === g.members.length && selComponents.length === 1) {
                moveGroupIds.push(gid); // whole group selected in one piece — keep it, just move it
                continue;
            }

            const localPos = this.computePositions(g);
            const restMembers = g.members.filter((m) => !selSet.has(m));
            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, g.edges, localPos);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, g.edges, localPos));
            }
        }

        if (!sourceGroupIds.length) return { event: null, moveGroupIds };
        return { event: { type: "group-regroup", timestamp: Date.now(), sourceGroupIds, newGroups }, moveGroupIds };
    }

    // A connected component of a source group → a fresh group, keeping its members' world positions
    // (origin anchored, transform shifted by the origin's local position). Edges restricted to it.
    private makeComponentGroup(
        g: Group,
        members: string[],
        edges: Edge[],
        localPos: Map<string, Vec3>,
    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {
        const memberSet = new Set(members);
        const ownEdges = edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to));
        const lp = localPos.get(members[0]) ?? [0, 0, 0];
        const w = quatRotate(g.transform.rot, lp);
        return {
            members: [...members],
            edges: ownEdges,
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            transform: {
                pos: [g.transform.pos[0] + w[0], g.transform.pos[1] + w[1], g.transform.pos[2] + w[2]] as Vec3,
                rot: g.transform.rot,
            },
        };
    }

    membersOfKey(groupKey: string): string[] {
        const gid = this.groupByKey(groupKey);
        return gid ? [...(this.groups.get(gid)?.members ?? [])] : [];
    }

    // Generic subject resolver: the nearest ancestor of a given role over the parent chain, starting
    // at (and including) the picked handle. Depth-agnostic — works for any number of nesting levels.
    nearestAncestorOfRole(handleId: string, role: NodeRole): string | null {
        let current: string | null = handleId;
        while (current) {
            const node = this.nodeIndex.get(current);
            if (!node) return null;
            if (node.role === role) return current;
            current = node.parentId;
        }
        return null;
    }

    // The element (part) a picked handle belongs to = nearest ancestor of role `part`.
    elementRootOf(handleId: string): string | null {
        return this.nearestAncestorOfRole(handleId, ROLE.part);
    }


ok -- dann etwas anderes Check mal den Code, wenn eine Gruppe aufgelöst wird --> da wird manchmal wird / werden die Elemente dann gedreht  wann passiert das genau?


Rollenförderer Visualisierung und Fehler beheben


cd /Users/marcus.hinz/projects/adesso-cpq
grep -n 'computePositions\|computeLayout\|quatRotate\|quatMul\|dockRotation\|\.rot' packages/frontend/src/configurator/model/GroupProcessor.ts | head -60

Rotationsstellen im GroupProcessor lokalisieren


6:    quatRotate,
7:    quatMul,
251:        const localPos = this.computePositions(g);
301:            const localPos = this.computePositions(g);
332:        const w = quatRotate(g.transform.rot, lp);
340:                rot: g.transform.rot,
417:                    transform: { pos: event.position, rot: event.rotation },
440:                    rot: event.rot,
450:                g.transform = { pos: event.position, rot: event.rotation };
490:            const layout = this.computeLayout(g);
501:                tr.rotation = lay.rot;
516:    // `edge.rot` relative to `from` (identity = shared orientation, the left/right case). With the
520:    // and R_to = R_from ∘ edge.rot (forward), R_from = R_to ∘ edge.rot⁻¹ (reverse). With every rot
522:    private computeLayout(g: Group): Map<string, Transform> {
533:            const rot = e.rot ?? IDENTITY_QUAT;
551:                // Neighbour's accumulated rotation: compose edge.rot forward, its inverse on reverse.
552:                const rotN = s.reverse ? quatMul(t.rot, quatConj(s.rot)) : quatMul(t.rot, s.rot);
553:                const fromRot = s.reverse ? rotN : t.rot;
554:                const toRot = s.reverse ? t.rot : rotN;
555:                const a = quatRotate(fromRot, at);
556:                const d = quatRotate(toRot, dock);
575:    private computePositions(g: Group): Map<string, Vec3> {
577:        for (const [member, t] of this.computeLayout(g)) pos.set(member, t.pos);
620:                    rotation: g.transform.rot,
655:    // The docking point's frame orientation, from its `properties.rotation` (Euler radians, ZYX).
662:                const rot = comp.properties?.rotation?.value;
671:    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
672:    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
673:        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));


516	    // `edge.rot` relative to `from` (identity = shared orientation, the left/right case). With the
517	    // accumulated part rotation R, a member's docking point sits at world R·pmi, so coincidence gives:
518	    //   forward (cur = from):  pos(to)   = pos(from) + R_from·pmi(from,fromPmi) − R_to·pmi(to,toPmi)
519	    //   reverse (cur = to):    pos(from) = pos(to)   + R_to·pmi(to,toPmi)       − R_from·pmi(from,fromPmi)
520	    // and R_to = R_from ∘ edge.rot (forward), R_from = R_to ∘ edge.rot⁻¹ (reverse). With every rot
521	    // identity this reduces exactly to the former translation-only solver.
522	    private computeLayout(g: Group): Map<string, Transform> {
523	        const out = new Map<string, Transform>();
524	        if (!g.members.length) return out;
525
526	        type Step = { neighbour: string; atPmi: string; dockPmi: string; reverse: boolean; rot: Quat };
527	        const adj = new Map<string, Step[]>();
528	        const link = (node: string, step: Step) => {
529	            if (!adj.has(node)) adj.set(node, []);
530	            adj.get(node)!.push(step);
531	        };
532	        for (const e of g.edges) {
533	            const rot = e.rot ?? IDENTITY_QUAT;
534	            link(e.from, { neighbour: e.to, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: false, rot });
535	            link(e.to, { neighbour: e.from, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: true, rot });
536	        }
537
538	        const origin = g.members[0];
539	        out.set(origin, { pos: [0, 0, 0], rot: IDENTITY_QUAT });
540	        const queue = [origin];
541	        while (queue.length) {
542	            const cur = queue.shift()!;
543	            const t = out.get(cur)!;
544	            for (const s of adj.get(cur) ?? []) {
545	                if (out.has(s.neighbour)) continue;
546	                // `at` is on the `from` side, `dock` on the `to` side.
547	                const fromRoot = s.reverse ? s.neighbour : cur;
548	                const toRoot = s.reverse ? cur : s.neighbour;
549	                const at = this.pmiLocal(fromRoot, s.atPmi);
550	                const dock = this.pmiLocal(toRoot, s.dockPmi);
551	                // Neighbour's accumulated rotation: compose edge.rot forward, its inverse on reverse.
552	                const rotN = s.reverse ? quatMul(t.rot, quatConj(s.rot)) : quatMul(t.rot, s.rot);
553	                const fromRot = s.reverse ? rotN : t.rot;
554	                const toRot = s.reverse ? t.rot : rotN;
555	                const a = quatRotate(fromRot, at);
556	                const d = quatRotate(toRot, dock);
557	                // pos(to) = pos(from) + R_from·at − R_to·dock. On reverse, `cur` IS `to`, so solve for
558	                // `from`: pos(from) = pos(to) + R_to·dock − R_from·at — same vector, opposite sign.
559	                const sign = s.reverse ? -1 : 1;
560	                out.set(s.neighbour, {
561	                    pos: [
562	                        t.pos[0] + sign * (a[0] - d[0]),
563	                        t.pos[1] + sign * (a[1] - d[1]),
564	                        t.pos[2] + sign * (a[2] - d[2]),
565	                    ],
566	                    rot: rotN,
567	                });
568	                queue.push(s.neighbour);
569	            }
570	        }
571	        return out;
572	    }
573
574	    // Group-local positions only — for the chain/axis queries (detach, regroup) that ignore rotation.
575	    private computePositions(g: Group): Map<string, Vec3> {
576	        const pos = new Map<string, Vec3>();
577	        for (const [member, t] of this.computeLayout(g)) pos.set(member, t.pos);
578	        return pos;
579	    }

    // `edge.rot` relative to `from` (identity = shared orientation, the left/right case). With the
    // accumulated part rotation R, a member's docking point sits at world R·pmi, so coincidence gives:
    //   forward (cur = from):  pos(to)   = pos(from) + R_from·pmi(from,fromPmi) − R_to·pmi(to,toPmi)
    //   reverse (cur = to):    pos(from) = pos(to)   + R_to·pmi(to,toPmi)       − R_from·pmi(from,fromPmi)
    // and R_to = R_from ∘ edge.rot (forward), R_from = R_to ∘ edge.rot⁻¹ (reverse). With every rot
    // identity this reduces exactly to the former translation-only solver.
    private computeLayout(g: Group): Map<string, Transform> {
        const out = new Map<string, Transform>();
        if (!g.members.length) return out;

        type Step = { neighbour: string; atPmi: string; dockPmi: string; reverse: boolean; rot: Quat };
        const adj = new Map<string, Step[]>();
        const link = (node: string, step: Step) => {
            if (!adj.has(node)) adj.set(node, []);
            adj.get(node)!.push(step);
        };
        for (const e of g.edges) {
            const rot = e.rot ?? IDENTITY_QUAT;
            link(e.from, { neighbour: e.to, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: false, rot });
            link(e.to, { neighbour: e.from, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: true, rot });
        }

        const origin = g.members[0];
        out.set(origin, { pos: [0, 0, 0], rot: IDENTITY_QUAT });
        const queue = [origin];
        while (queue.length) {
            const cur = queue.shift()!;
            const t = out.get(cur)!;
            for (const s of adj.get(cur) ?? []) {
                if (out.has(s.neighbour)) continue;
                // `at` is on the `from` side, `dock` on the `to` side.
                const fromRoot = s.reverse ? s.neighbour : cur;
                const toRoot = s.reverse ? cur : s.neighbour;
                const at = this.pmiLocal(fromRoot, s.atPmi);
                const dock = this.pmiLocal(toRoot, s.dockPmi);
                // Neighbour's accumulated rotation: compose edge.rot forward, its inverse on reverse.
                const rotN = s.reverse ? quatMul(t.rot, quatConj(s.rot)) : quatMul(t.rot, s.rot);
                const fromRot = s.reverse ? rotN : t.rot;
                const toRot = s.reverse ? t.rot : rotN;
                const a = quatRotate(fromRot, at);
                const d = quatRotate(toRot, dock);
                // pos(to) = pos(from) + R_from·at − R_to·dock. On reverse, `cur` IS `to`, so solve for
                // `from`: pos(from) = pos(to) + R_to·dock − R_from·at — same vector, opposite sign.
                const sign = s.reverse ? -1 : 1;
                out.set(s.neighbour, {
                    pos: [
                        t.pos[0] + sign * (a[0] - d[0]),
                        t.pos[1] + sign * (a[1] - d[1]),
                        t.pos[2] + sign * (a[2] - d[2]),
                    ],
                    rot: rotN,
                });
                queue.push(s.neighbour);
            }
        }
        return out;
    }

    // Group-local positions only — for the chain/axis queries (detach, regroup) that ignore rotation.
    private computePositions(g: Group): Map<string, Vec3> {
        const pos = new Map<string, Vec3>();
        for (const [member, t] of this.computeLayout(g)) pos.set(member, t.pos);
        return pos;
    }


Gefunden. Die Drehung entsteht in `makeComponentGroup` beim Aufsplitten einer Gruppe (`GroupProcessor.ts`). Sowohl `buildDetachEvent` (Zeile 268) als auch `buildRegroupEvent` (Zeile 308/313) erzeugen die neuen Gruppen darüber.

## Der Mechanismus

`computeLayout` (Zeile 522–572) legt jedes Mitglied relativ zum **Gruppen-Origin `g.members[0]`** ab. Der Origin bekommt Identity-Rotation (Zeile 539); alle anderen Mitglieder tragen eine **akkumulierte Rotation `rotN`**, die entlang des Kantengraphen aus `edge.rot` komponiert wird (Zeile 552, 566). Bei reinen Links/Rechts-Verbauten ist jedes `edge.rot` identity → `rotN` überall identity. Bei einem 90°-/gewinkelten Dock ist `rotN` für die Mitglieder hinter der Winkelkante **nicht** identity.

Beim Split (`makeComponentGroup`, Zeile 323–343):

```ts
const lp = localPos.get(members[0]) ?? [0, 0, 0];   // nur Position
const w = quatRotate(g.transform.rot, lp);
return {
  ...
  transform: {
    pos: [g.transform.pos[0] + w[0], ...],   // Origin korrekt an alte Weltposition
    rot: g.transform.rot,                    // <-- nur die alte Gruppen-Rotation
  },
};
```

- `localPos` kommt aus `computePositions` (Zeile 575–578), das die **Rotation `rotN` verwirft** — nur Positionen bleiben.
- Die neue Gruppe wird auf ihr eigenes `members[0]` neu verankert (im Replay wieder mit Identity-Origin), Rotation = `g.transform.rot`.

Damit geht die akkumulierte Rotation `rotN` des neuen Anker-Mitglieds verloren. Vorher war dessen Weltorientierung `g.transform.rot ∘ rotN`, nachher nur `g.transform.rot`. Die **gesamte neue Komponente kippt also um `rotN(members[0])⁻¹`**.

## Wann genau

Beide Bedingungen müssen zutreffen:

1. Die Gruppe enthält mindestens **ein gewinkeltes Dock** (`edge.rot ≠ identity`, also der 90°/PMI-rotierte Verbau — nicht der reine Links/Rechts-Fall).
2. Beim Split wird eine neue Komponente auf ein **Anker-Mitglied (`members[0]`) gesetzt, das im Ursprungsverbund eine akkumulierte Rotation `rotN ≠ identity` hatte** — d. h. es lag hinter einer Winkelkante, gesehen vom alten Origin.

Deshalb „manchmal": bei geraden Verbauten (alles `edge.rot` identity) ist `rotN` immer identity und nichts dreht sich. Sobald ein 90°-Steher im Spiel ist und der Trennschnitt den gedrehten Teil zum neuen Anker macht, springt die abgetrennte Komponente in die Orientierung des alten Gruppen-Origins zurück.

Welches Mitglied `members[0]` einer Komponente wird, legt `connectedComponents` fest — daher hängt es vom Trennschnitt ab, ob der Effekt sichtbar wird oder nicht.


ok -- können wir es so machen, dass die drehung beibehalten wird?


Ja. Der Fix ist lokal in `makeComponentGroup`: statt die Gruppenrotation `g.transform.rot` pur zu übernehmen, muss die akkumulierte In-Group-Rotation `rotN` des neuen Anker-Mitglieds (`members[0]`) einkomponiert werden. Die Positionsverankerung stimmt bereits — nur die Rotation fehlt.

Ich prüfe kurz Imports und ob `computePositions` sonst noch gebraucht wird, dann setze ich es um.


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Importe der quat-Helfer + IDENTITY_QUAT ==="
sed -n '1,12p' packages/frontend/src/configurator/model/GroupProcessor.ts
echo
echo "=== alle Verwendungen von computePositions ==="
grep -rn 'computePositions' packages/frontend/src 2>/dev/null
echo
echo "=== IDENTITY_QUAT Herkunft ==="
grep -rn 'IDENTITY_QUAT' packages/frontend/src/configurator/model/GroupProcessor.ts | head -3

Imports und computePositions-Nutzung prüfen


=== Importe der quat-Helfer + IDENTITY_QUAT ===
import {
    type Transform,
    type Vec3,
    type Quat,
    IDENTITY_QUAT,
    quatRotate,
    quatMul,
    quatConj,
    eulerZyxRadToQuat,
} from "./transformMath";

// Single home for the group model. A group is a set of element roots wired by directed dock

=== alle Verwendungen von computePositions ===
packages/frontend/src/configurator/model/GroupProcessor.ts:251:        const localPos = this.computePositions(g);
packages/frontend/src/configurator/model/GroupProcessor.ts:301:            const localPos = this.computePositions(g);
packages/frontend/src/configurator/model/GroupProcessor.ts:575:    private computePositions(g: Group): Map<string, Vec3> {

=== IDENTITY_QUAT Herkunft ===
packages/frontend/src/configurator/model/GroupProcessor.ts:5:    IDENTITY_QUAT,
packages/frontend/src/configurator/model/GroupProcessor.ts:143:            rotation: IDENTITY_QUAT,
packages/frontend/src/configurator/model/GroupProcessor.ts:495:                const lay = layout.get(member) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };


Alles vorhanden. Ich setze den Fix um (eine Datei).

**1. `buildDetachEvent`:** Layout mit Rotation holen statt nur Positionen.


Edit

        const g = this.groups.get(sourceGroupId)!;
        const localPos = this.computePositions(g);

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

        const g = this.groups.get(sourceGroupId)!;
        const layout = this.computeLayout(g);

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


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

        const g = this.groups.get(sourceGroupId)!;
        const localPos = this.computePositions(g);

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

        const g = this.groups.get(sourceGroupId)!;
        const layout = this.computeLayout(g);

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

const sourceGroupId = this.elementToGroup.get(elementId);

-        const localPos = this.computePositions(g);

+        const layout = this.computeLayout(g);

-        const pts = [...localPos.values()];

+        const pts = [...layout.values()].map((t) => t.pos);

-        const coord = (m: string) => (localPos.get(m) ?? [0, 0, 0])[axis];

+        const coord = (m: string) => (layout.get(m)?.pos ?? [0, 0, 0])[axis];

// Drop the edges that link the element to a left neighbour (smaller coordinate); keep its


        const components = this.connectedComponents(g.members, remEdges);
        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, localPos));

        const components = this.connectedComponents(g.members, remEdges);
        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, layout));


        const components = this.connectedComponents(g.members, remEdges);
        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, localPos));

        const components = this.connectedComponents(g.members, remEdges);
        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, layout));

-        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, localPos));

+        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, layout));

return { type: "group-detach", timestamp: Date.now(), sourceGroupId, elementId, newGroups };


**2. `buildRegroupEvent`:**


            const localPos = this.computePositions(g);
            const restMembers = g.members.filter((m) => !selSet.has(m));
            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, g.edges, localPos);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, g.edges, localPos));
            }

            const layout = this.computeLayout(g);
            const restMembers = g.members.filter((m) => !selSet.has(m));
            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, g.edges, layout);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));
            }


            const localPos = this.computePositions(g);
            const restMembers = g.members.filter((m) => !selSet.has(m));
            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, g.edges, localPos);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, g.edges, localPos));
            }

            const layout = this.computeLayout(g);
            const restMembers = g.members.filter((m) => !selSet.has(m));
            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
            const restComponents = this.connectedComponents(restMembers, restInduced);

            sourceGroupIds.push(gid);
            for (const comp of selComponents) {
                const ng = this.makeComponentGroup(g, comp, g.edges, layout);
                newGroups.push(ng);
                moveGroupIds.push(ng.groupId);
            }
            for (const comp of restComponents) {
                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));
            }

-            const localPos = this.computePositions(g);

+            const layout = this.computeLayout(g);

-                const ng = this.makeComponentGroup(g, comp, g.edges, localPos);

+                const ng = this.makeComponentGroup(g, comp, g.edges, layout);

-                newGroups.push(this.makeComponentGroup(g, comp, g.edges, localPos));

+                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));


**3. `makeComponentGroup`:** Rotation des Anker-Mitglieds einkomponieren.


    // A connected component of a source group → a fresh group, keeping its members' world positions
    // (origin anchored, transform shifted by the origin's local position). Edges restricted to it.
    private makeComponentGroup(
        g: Group,
        members: string[],
        edges: Edge[],
        localPos: Map<string, Vec3>,
    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {
        const memberSet = new Set(members);
        const ownEdges = edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to));
        const lp = localPos.get(members[0]) ?? [0, 0, 0];
        const w = quatRotate(g.transform.rot, lp);
        return {
            members: [...members],
            edges: ownEdges,
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            transform: {
                pos: [g.transform.pos[0] + w[0], g.transform.pos[1] + w[1], g.transform.pos[2] + w[2]] as Vec3,
                rot: g.transform.rot,
            },
        };
    }

    // A connected component of a source group → a fresh group, keeping its members' world positions
    // AND orientations. The new group is anchored at its origin (members[0]): the position is shifted by
    // the origin's local position, the rotation is the group rotation composed with the origin's
    // accumulated in-group rotation — otherwise a component whose origin sat behind an angled dock would
    // snap back to the source origin's orientation. Edges restricted to it.
    private makeComponentGroup(
        g: Group,
        members: string[],
        edges: Edge[],
        layout: Map<string, Transform>,
    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {
        const memberSet = new Set(members);
        const ownEdges = edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to));
        const origin = layout.get(members[0]) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };
        const w = quatRotate(g.transform.rot, origin.pos);
        return {
            members: [...members],
            edges: ownEdges,
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            transform: {
                pos: [g.transform.pos[0] + w[0], g.transform.pos[1] + w[1], g.transform.pos[2] + w[2]] as Vec3,
                rot: quatMul(g.transform.rot, origin.rot),
            },
        };
    }


    // A connected component of a source group → a fresh group, keeping its members' world positions
    // (origin anchored, transform shifted by the origin's local position). Edges restricted to it.
    private makeComponentGroup(
        g: Group,
        members: string[],
        edges: Edge[],
        localPos: Map<string, Vec3>,
    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {
        const memberSet = new Set(members);
        const ownEdges = edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to));
        const lp = localPos.get(members[0]) ?? [0, 0, 0];
        const w = quatRotate(g.transform.rot, lp);
        return {
            members: [...members],
            edges: ownEdges,
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            transform: {
                pos: [g.transform.pos[0] + w[0], g.transform.pos[1] + w[1], g.transform.pos[2] + w[2]] as Vec3,
                rot: g.transform.rot,
            },
        };
    }

    // A connected component of a source group → a fresh group, keeping its members' world positions
    // AND orientations. The new group is anchored at its origin (members[0]): the position is shifted by
    // the origin's local position, the rotation is the group rotation composed with the origin's
    // accumulated in-group rotation — otherwise a component whose origin sat behind an angled dock would
    // snap back to the source origin's orientation. Edges restricted to it.
    private makeComponentGroup(
        g: Group,
        members: string[],
        edges: Edge[],
        layout: Map<string, Transform>,
    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {
        const memberSet = new Set(members);
        const ownEdges = edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to));
        const origin = layout.get(members[0]) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };
        const w = quatRotate(g.transform.rot, origin.pos);
        return {
            members: [...members],
            edges: ownEdges,
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            transform: {
                pos: [g.transform.pos[0] + w[0], g.transform.pos[1] + w[1], g.transform.pos[2] + w[2]] as Vec3,
                rot: quatMul(g.transform.rot, origin.rot),
            },
        };
    }

-    // (origin anchored, transform shifted by the origin's local position). Edges restricted to it.

+    // AND orientations. The new group is anchored at its origin (members[0]): the position is shifted by

+    // the origin's local position, the rotation is the group rotation composed with the origin's

+    // accumulated in-group rotation — otherwise a component whose origin sat behind an angled dock would

+    // snap back to the source origin's orientation. Edges restricted to it.

+        layout: Map<string, Transform>,

-        const lp = localPos.get(members[0]) ?? [0, 0, 0];

-        const w = quatRotate(g.transform.rot, lp);

+        const origin = layout.get(members[0]) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };

+        const w = quatRotate(g.transform.rot, origin.pos);

+                rot: quatMul(g.transform.rot, origin.rot),


**4. `computePositions` entfernen** (jetzt ungenutzt):


574	        return out;
575	    }
576
577	    // Group-local positions only — for the chain/axis queries (detach, regroup) that ignore rotation.
578	    private computePositions(g: Group): Map<string, Vec3> {
579	        const pos = new Map<string, Vec3>();
580	        for (const [member, t] of this.computeLayout(g)) pos.set(member, t.pos);
581	        return pos;
582	    }
583

        return out;
    }

    // Group-local positions only — for the chain/axis queries (detach, regroup) that ignore rotation.
    private computePositions(g: Group): Map<string, Vec3> {
        const pos = new Map<string, Vec3>();
        for (const [member, t] of this.computeLayout(g)) pos.set(member, t.pos);
        return pos;
    }


        return out;
    }

    // Group-local positions only — for the chain/axis queries (detach, regroup) that ignore rotation.
    private computePositions(g: Group): Map<string, Vec3> {
        const pos = new Map<string, Vec3>();
        for (const [member, t] of this.computeLayout(g)) pos.set(member, t.pos);
        return pos;
    }


        return out;
    }

    // Group-local positions only — for the chain/axis queries (detach, regroup) that ignore rotation.
    private computePositions(g: Group): Map<string, Vec3> {
        const pos = new Map<string, Vec3>();
        for (const [member, t] of this.computeLayout(g)) pos.set(member, t.pos);
        return pos;
    }

-    // Group-local positions only — for the chain/axis queries (detach, regroup) that ignore rotation.

-    private computePositions(g: Group): Map<string, Vec3> {

-        const pos = new Map<string, Vec3>();

-        for (const [member, t] of this.computeLayout(g)) pos.set(member, t.pos);

// Connected components of `members` under `edges` (undirected), via BFS.

private connectedComponents(members: string[], edges: Edge[]): string[][] {

const adj = new Map<string, string[]>();


Jetzt Typecheck und Tests.


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Typecheck ==="
pnpm --filter @ad-cpq/frontend exec tsc --noEmit 2>&1 | head -30 || npx tsc --noEmit -p packages/frontend 2>&1 | head -30
echo "exit: $?"

TypeScript-Typecheck frontend


=== Typecheck ===
No projects matched the filters in "/Users/marcus.hinz/projects/adesso-cpq"
exit: 0


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== frontend package name + scripts ==="
grep -m1 '"name"' packages/frontend/package.json
grep -A20 '"scripts"' packages/frontend/package.json | head -22

frontend Paketname und Skripte


=== frontend package name + scripts ===
    "name": "@ad-cpq/common-frontend",
    "scripts": {
        "build": "vite build",
        "storybook": "storybook dev -p 6006",
        "build-storybook": "storybook build",
        "test": "vitest run",
        "test:watch": "vitest",
        "typecheck": "vue-tsc --noEmit"
    },
    "dependencies": {
        "@ad-cpq/blast": "workspace:*",
        "@ad-cpq/core": "workspace:*",
        "@ad-cpq/shared": "workspace:*",
        "@ad-cpq/visual-runtime": "workspace:*"
    },
    "peerDependencies": {
        "vue": "^3.5.38"
    },
    "devDependencies": {
        "@ad/fire": "^1.0.6",
        "@storybook/addon-essentials": "^8.6.18",
        "@storybook/preview-api": "8.6.18",


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Typecheck ==="
pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | tail -30
echo "=== typecheck exit: $? ==="

vue-tsc Typecheck


=== Typecheck ===
$ vue-tsc --noEmit
src/components/building-blocks/ProductBrowser.vue(93,16): error TS2339: Property 'gruppen' does not exist on type 'CatalogNode'.
src/components/building-blocks/ProductBrowser.vue(93,29): error TS7006: Parameter 'g' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue(97,19): error TS7006: Parameter 'kg' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue(98,57): error TS7006: Parameter 'ag' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue(99,40): error TS7006: Parameter 'a' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue(99,99): error TS7006: Parameter 'a' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue(105,64): error TS7006: Parameter 'item' implicitly has an 'any' type.
src/components/building-blocks/ProductBrowser.vue(109,63): error TS7006: Parameter 'item' implicitly has an 'any' type.
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend:
[ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL] @ad-cpq/common-frontend@0.1.1-local.5 typecheck: `vue-tsc --noEmit`
Exit status 2
=== typecheck exit: 0 ===


Die Typecheck-Fehler liegen alle in `ProductBrowser.vue` (Katalog-Baum, `CatalogNode.gruppen`) — nicht in der geänderten Datei, vorbestehend. Für `GroupProcessor.ts` meldet der Check nichts. Jetzt die Tests.


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== GroupProcessor-Testdateien ==="
find packages/frontend -iname '*roup*.test.ts' -o -iname '*roup*.spec.ts' 2>/dev/null | grep -v node_modules
echo
echo "=== Tests laufen lassen (GroupProcessor) ==="
pnpm --filter @ad-cpq/common-frontend exec vitest run GroupProcessor 2>&1 | tail -40

GroupProcessor-Tests ausführen


=== GroupProcessor-Testdateien ===
packages/frontend/src/configurator/model/GroupProcessor.test.ts

=== Tests laufen lassen (GroupProcessor) ===
+ Received

  [
+   "a",
+   "b",
+   "c",
    "d",
  ]

 ❯ src/configurator/model/GroupProcessor.test.ts:170:31
    168|         const movingGroup = gp.groupIdOfElement(ids[idx as number]);
    169|         const moving = ids.filter((id) => gp.groupIdOfElement(id) === …
    170|         expect(moving.sort()).toEqual((expected as string[]).slice().s…
       |                               ^
    171|     });
    172| });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯

 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — rotated dock (90° branch) > applies the edge rotation to the docked member and rotates its offset
AssertionError: expected +0 to be close to -2.168, received difference is 2.168, but expected 0.005
 ❯ src/configurator/model/GroupProcessor.test.ts:226:36
    224|         const expected = quatRotate(Z90, [WIDTH, 0, 0]).map((v) => -v);
    225|         expect(trB.translation[0]).toBeCloseTo(expected[0]);
    226|         expect(trB.translation[1]).toBeCloseTo(expected[1]);
       |                                    ^
    227|         expect(trB.translation[2]).toBeCloseTo(expected[2]);
    228|     });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯

 Test Files  1 failed (1)
      Tests  5 failed | 13 passed (18)
   Start at  09:13:15
   Duration  700ms (transform 41ms, setup 0ms, import 52ms, tests 113ms, environment 424ms)

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


5 Tests schlagen fehl, genau im Detach/Rotate-Bereich. Ich prüfe, ob mein Change sie bricht oder ob sie das alte (fehlerhafte) Verhalten festschreiben. Zuerst: liefen sie vor meiner Änderung?


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Tests auf dem UNVERÄNDERTEN Stand (stash) ==="
git stash push -- packages/frontend/src/configurator/model/GroupProcessor.ts 2>&1 | tail -2
pnpm --filter @ad-cpq/common-frontend exec vitest run GroupProcessor 2>&1 | grep -E 'Tests|passed|failed'
echo "=== Änderung zurückholen ==="
git stash pop 2>&1 | tail -2

Tests auf Originalstand prüfen


=== Tests auf dem UNVERÄNDERTEN Stand (stash) ===
Saved working directory and index state WIP on Produktmanagement: b05bebf Graph-Export-Button + Keyboard-Prototyp fürs Anbauen
 ❯ src/configurator/model/GroupProcessor.test.ts (18 tests | 5 failed) 120ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
 Test Files  1 failed (1)
      Tests  5 failed | 13 passed (18)
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command failed with exit code 1: vitest run GroupProcessor
=== Änderung zurückholen ===
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (09bcb0e6ebf8dc41cf3b72161610a1b4ac6b2545)


Gleiche 5 Fehler auch ohne meine Änderung (5 failed | 13 passed in beiden Fällen). Mein Change ändert die Fehlerzahl nicht. Ich hole die genauen Namen, um sicherzugehen, dass es dieselbe Menge ist.


cd /Users/marcus.hinz/projects/adesso-cpq
echo "########## MIT Änderung ##########"
pnpm --filter @ad-cpq/common-frontend exec vitest run GroupProcessor 2>&1 | grep -E '(✓|×|✗|❯|FAIL|✕)' | grep -iE 'test|>' | head -40
echo
echo "----- Nur FAIL-Zeilen -----"
pnpm --filter @ad-cpq/common-frontend exec vitest run GroupProcessor --reporter=verbose 2>&1 | grep -iE 'fail|✕|×' | head -40

Fehlende Testnamen (mit Änderung)


########## MIT Änderung ##########
 ❯ src/configurator/model/GroupProcessor.test.ts (18 tests | 5 failed) 123ms
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — create & merge > merges parts into a left-to-right chain at the CS offset
 ❯ src/configurator/model/GroupProcessor.test.ts:67:34
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 1 moves that element plus everything to its right
 ❯ src/configurator/model/GroupProcessor.test.ts:170:31
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 2 moves that element plus everything to its right
 ❯ src/configurator/model/GroupProcessor.test.ts:170:31
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 3 moves that element plus everything to its right
 ❯ src/configurator/model/GroupProcessor.test.ts:170:31
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — rotated dock (90° branch) > applies the edge rotation to the docked member and rotates its offset
 ❯ src/configurator/model/GroupProcessor.test.ts:226:36
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command failed with exit code 1: vitest run GroupProcessor

----- Nur FAIL-Zeilen -----
 × src/configurator/model/GroupProcessor.test.ts > GroupProcessor — create & merge > merges parts into a left-to-right chain at the CS offset 10ms
 × src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 1 moves that element plus everything to its right 12ms
 × src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 2 moves that element plus everything to its right 11ms
 × src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 3 moves that element plus everything to its right 11ms
 × src/configurator/model/GroupProcessor.test.ts > GroupProcessor — rotated dock (90° branch) > applies the edge rotation to the docked member and rotates its offset 3ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — create & merge > merges parts into a left-to-right chain at the CS offset
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 1 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 2 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — detach (split at element) > picking index 3 moves that element plus everything to its right
 FAIL  src/configurator/model/GroupProcessor.test.ts > GroupProcessor — rotated dock (90° branch) > applies the edge rotation to the docked member and rotates its offset
 Test Files  1 failed (1)
      Tests  5 failed | 13 passed (18)
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command failed with exit code 1: vitest run GroupProcessor '--reporter=verbose'


40	    const node = transformOf(scene, tr.parentKey);
41	    return node.translation[0] + tr.translation[0];
42	};
43
44	// Build a left-to-right chain of `n` parts in one group; returns the processor, event log and root ids.
45	function buildChain(n: number) {
46	    const gp = new GroupProcessor();
47	    const creates = Array.from({ length: n }, () => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
48	    const ids = creates.map((e) => e.rootHandleId);
49	    gp.replay(base(), creates);
50	    const events: GroupEvent[] = [...creates];
51	    for (let i = 1; i < n; i++) {
52	        const target = gp.groupIdOfElement(ids[0])!;
53	        const moving = gp.groupIdOfElement(ids[i])!;
54	        events.push(merge(target, moving, ids[i - 1], ids[i]));
55	        gp.replay(base(), events);
56	    }
57	    return { gp, ids, events };
58	}
59
60	describe("GroupProcessor — create & merge", () => {
61	    it("merges parts into a left-to-right chain at the CS offset", () => {
62	        const { gp, ids, events } = buildChain(3);
63	        const scene = gp.replay(base(), events);
64	        const [a, b, c] = ids;
65	        expect(gp.groupCount()).toBe(1);
66	        expect(worldX(scene, a)).toBeCloseTo(0);
67	        expect(worldX(scene, b)).toBeCloseTo(WIDTH);
68	        expect(worldX(scene, c)).toBeCloseTo(2 * WIDTH);
69	    });
70
71	    it("materialises exactly one parentless scene root, group nodes hang under it", () => {
72	        const gp = new GroupProcessor();
73	        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
74	        const e2 = gp.buildCreateEvent({ handles: product().handles }, [5, 0, 0]);
75	        const scene = gp.replay(base(), [e1, e2]);
76	        const roots = scene.handles.filter((h: any) =>
77	            h.components.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),
78	        );
79	        expect(roots.map((h: any) => h.id)).toEqual(["root"]);
80	        for (const node of tagged(scene, "assembly")) {
81	            expect(transformOf(scene, node).parentKey).toBe("root");
82	        }
83	    });
84
85	    it("re-identifies two instances to unique ids (no clashes)", () => {
86	        const gp = new GroupProcessor();
87	        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
88	        const e2 = gp.buildCreateEvent({ handles: product().handles }, [5, 0, 0]);
89	        const scene = gp.replay(base(), [e1, e2]);
90	        const ids = scene.handles.map((h: any) => h.id);
91	        expect(new Set(ids).size).toBe(ids.length);
92	        expect(e1.rootHandleId).not.toBe(e2.rootHandleId);
93	    });
94
95	    it("keeps every member when merging a multi-member group via its tail", () => {
96	        // gC=[c,d] docks onto gA=[a,b] with the moving group's tail (d) as join → undirected traversal
97	        // must still reach c. (Regression for the lost-members bug.)
98	        const gp = new GroupProcessor();
99	        const ev = [0, 1, 2, 3].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
100	        const [a, b, c, d] = ev.map((e) => e.rootHandleId);
101	        gp.replay(base(), ev);
102	        const gA = gp.groupIdOfElement(a)!,
103	            gB = gp.groupIdOfElement(b)!,
104	            gC = gp.groupIdOfElement(c)!,
105	            gD = gp.groupIdOfElement(d)!;
106	        // Third merge uses the still-open ends (a's left, d's right) — merging on already-occupied
107	        // points is now rejected by the double-booking guard. d is still the join (tail traversal).
108	        const events = [...ev, merge(gA, gB, a, b), merge(gC, gD, c, d), merge(gA, gC, a, d, "CS_LINKS", "CS_RECHTS")];
109	        const scene = gp.replay(base(), events);
110	        expect(gp.groupCount()).toBe(1);
111	        for (const id of [a, b, c, d]) expect(transformOf(scene, id).translation).toBeDefined();
112	    });
113	});
114

    const node = transformOf(scene, tr.parentKey);
    return node.translation[0] + tr.translation[0];
};

// Build a left-to-right chain of `n` parts in one group; returns the processor, event log and root ids.
function buildChain(n: number) {
    const gp = new GroupProcessor();
    const creates = Array.from({ length: n }, () => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
    const ids = creates.map((e) => e.rootHandleId);
    gp.replay(base(), creates);
    const events: GroupEvent[] = [...creates];
    for (let i = 1; i < n; i++) {
        const target = gp.groupIdOfElement(ids[0])!;
        const moving = gp.groupIdOfElement(ids[i])!;
        events.push(merge(target, moving, ids[i - 1], ids[i]));
        gp.replay(base(), events);
    }
    return { gp, ids, events };
}

describe("GroupProcessor — create & merge", () => {
    it("merges parts into a left-to-right chain at the CS offset", () => {
        const { gp, ids, events } = buildChain(3);
        const scene = gp.replay(base(), events);
        const [a, b, c] = ids;
        expect(gp.groupCount()).toBe(1);
        expect(worldX(scene, a)).toBeCloseTo(0);
        expect(worldX(scene, b)).toBeCloseTo(WIDTH);
        expect(worldX(scene, c)).toBeCloseTo(2 * WIDTH);
    });

    it("materialises exactly one parentless scene root, group nodes hang under it", () => {
        const gp = new GroupProcessor();
        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
        const e2 = gp.buildCreateEvent({ handles: product().handles }, [5, 0, 0]);
        const scene = gp.replay(base(), [e1, e2]);
        const roots = scene.handles.filter((h: any) =>
            h.components.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),
        );
        expect(roots.map((h: any) => h.id)).toEqual(["root"]);
        for (const node of tagged(scene, "assembly")) {
            expect(transformOf(scene, node).parentKey).toBe("root");
        }
    });

    it("re-identifies two instances to unique ids (no clashes)", () => {
        const gp = new GroupProcessor();
        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
        const e2 = gp.buildCreateEvent({ handles: product().handles }, [5, 0, 0]);
        const scene = gp.replay(base(), [e1, e2]);
        const ids = scene.handles.map((h: any) => h.id);
        expect(new Set(ids).size).toBe(ids.length);
        expect(e1.rootHandleId).not.toBe(e2.rootHandleId);
    });

    it("keeps every member when merging a multi-member group via its tail", () => {
        // gC=[c,d] docks onto gA=[a,b] with the moving group's tail (d) as join → undirected traversal
        // must still reach c. (Regression for the lost-members bug.)
        const gp = new GroupProcessor();
        const ev = [0, 1, 2, 3].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
        const [a, b, c, d] = ev.map((e) => e.rootHandleId);
        gp.replay(base(), ev);
        const gA = gp.groupIdOfElement(a)!,
            gB = gp.groupIdOfElement(b)!,
            gC = gp.groupIdOfElement(c)!,
            gD = gp.groupIdOfElement(d)!;
        // 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")];
        const scene = gp.replay(base(), events);
        expect(gp.groupCount()).toBe(1);
        for (const id of [a, b, c, d]) expect(transformOf(scene, id).translation).toBeDefined();
    });
});


1	import { describe, it, expect } from "vitest";
2	import { GroupProcessor, type GroupEvent } from "./GroupProcessor";
3	import { quatRotate, IDENTITY_QUAT, eulerZyxRadToQuat, type Quat } from "./transformMath";
4	import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";
5
6	// A real schutzzaun part: root handle (key "root") with CS_LINKS at 0 and CS_RECHTS at 2.168, plus a
7	// scaled subtree of colliders. Cloned fresh each time so ids don't leak between cases.
8	const product = () => structuredClone(productData);
9	const WIDTH = 2.168; // CS_RECHTS.x of the part
10
11	const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
12
13	const merge = (
14	    target: string,
15	    moving: string,
16	    anchor: string,
17	    join: string,
18	    anchorPmi = "CS_RECHTS",
19	    joinPmi = "CS_LINKS",
20	): GroupEvent => ({
21	    type: "group-merge",
22	    timestamp: 0,
23	    targetGroupId: target,
24	    movingGroupId: moving,
25	    anchorRootId: anchor,
26	    joinRootId: join,
27	    anchorPmiId: anchorPmi,
28	    joinPmiId: joinPmi,
29	});
30
31	const tagged = (scene: any, role: string): string[] =>
32	    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);
33
34	const transformOf = (scene: any, id: string) =>
35	    scene.handles.find((h: any) => h.id === id)?.components.find((c: any) => c._resourceClass === "component_transform");
36
37	// World x of a part = its group node x + its local x (no rotation in these fixtures).
38	const worldX = (scene: any, id: string): number => {
39	    const tr = transformOf(scene, id);

import { describe, it, expect } from "vitest";
import { GroupProcessor, type GroupEvent } from "./GroupProcessor";
import { quatRotate, IDENTITY_QUAT, eulerZyxRadToQuat, type Quat } 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
// 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

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

const merge = (
    target: string,
    moving: string,
    anchor: string,
    join: string,
    anchorPmi = "CS_RECHTS",
    joinPmi = "CS_LINKS",
): GroupEvent => ({
    type: "group-merge",
    timestamp: 0,
    targetGroupId: target,
    movingGroupId: moving,
    anchorRootId: anchor,
    joinRootId: join,
    anchorPmiId: anchorPmi,
    joinPmiId: joinPmi,
});

const tagged = (scene: any, role: string): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);

const transformOf = (scene: any, id: string) =>
    scene.handles.find((h: any) => h.id === id)?.components.find((c: any) => c._resourceClass === "component_transform");

// World x of a part = its group node x + its local x (no rotation in these fixtures).
const worldX = (scene: any, id: string): number => {
    const tr = transformOf(scene, id);


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Merge-Test: tatsächliche Werte ==="
pnpm --filter @ad-cpq/common-frontend exec vitest run GroupProcessor -t "left-to-right chain at the CS offset" 2>&1 | grep -A8 'AssertionError\|expected'
echo
echo "=== Hat die Fixture CS_LINKS/CS_RECHTS? ==="
grep -o 'CS_LINKS\|CS_RECHTS\|CS_EIN_VORNE\|CS_EIN_HINTEN' .storybook/products/schutzzaun_basic/SBS1044PRA.json 2>/dev/null | sort | uniq -c
echo "Datei vorhanden?"; ls -la .storybook/products/schutzzaun_basic/SBS1044PRA.json 2>&1 | head -1
echo "git-Status der Fixture:"; git status --short .storybook/products/schutzzaun_basic/SBS1044PRA.json

Merge-Testwerte und Fixture-PMI prüfen


=== Merge-Test: tatsächliche Werte ===
AssertionError: expected +0 to be close to 2.168, received difference is 2.168, but expected 0.005
 ❯ src/configurator/model/GroupProcessor.test.ts:67:34
     65|         expect(gp.groupCount()).toBe(1);
     66|         expect(worldX(scene, a)).toBeCloseTo(0);
     67|         expect(worldX(scene, b)).toBeCloseTo(WIDTH);
       |                                  ^
     68|         expect(worldX(scene, c)).toBeCloseTo(2 * WIDTH);
     69|     });

=== Hat die Fixture CS_LINKS/CS_RECHTS? ===
Datei vorhanden?
ls: .storybook/products/schutzzaun_basic/SBS1044PRA.json: No such file or directory
git-Status der Fixture:


Falscher Pfad — die Fixture liegt unter `packages/frontend/`. Ich prüfe dort.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
f=.storybook/products/schutzzaun_basic/SBS1044PRA.json
echo "vorhanden?"; ls -la "$f" 2>&1 | head -1
echo "Größe/PMI:"
if [ -f "$f" ]; then
  echo "bytes: $(stat -f%z "$f")"
  grep -o 'CS_LINKS\|CS_RECHTS' "$f" | sort | uniq -c
  echo "git-Status:"; git status --short "$f"
  echo "im HEAD vorhanden?"; git cat-file -e HEAD:packages/frontend/"$f" 2>&1 && echo "ja, in HEAD"
fi

Fixture am korrekten Pfad prüfen


vorhanden?
-rw-r--r--  1 marcus.hinz  staff  42872 Jul  8 18:16 .storybook/products/schutzzaun_basic/SBS1044PRA.json
Größe/PMI:
bytes: 42872
git-Status:
im HEAD vorhanden?
ja, in HEAD


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
echo "=== Welche CS_/PMI-Namen hat die Fixture wirklich? ==="
grep -oE 'CS_[A-Z_]+|"name": *"[^"]*"' .storybook/products/schutzzaun_basic/SBS1044PRA.json | sort | uniq -c | head -30
echo
echo "=== Wie liest der Code PMI-Punkte? (pmiLocal) ==="
grep -n 'pmiLocal\|CS_LINKS\|CS_RECHTS\|CS_EIN' /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.ts | head

PMI-Namen in Fixture vs Code


=== Welche CS_/PMI-Namen hat die Fixture wirklich? ===
   2 CS_BASE
   2 CS_BASE_PRE
   2 CS_EIN
   2 CS_EIN_L
   2 CS_EIN_R
   2 CS_EIN_START_L
   2 CS_EIN_START_R
  10 CS_OUT_L_
  10 CS_OUT_R_
   6 CS_SELECT_L_
   6 CS_SELECT_R_

=== Wie liest der Code PMI-Punkte? (pmiLocal) ===
39:    // the two parts share orientation — the plain left/right case (CS_LINKS/CS_RECHTS carry no rotation).
552:                const at = this.pmiLocal(fromRoot, s.atPmi);
553:                const dock = this.pmiLocal(toRoot, s.dockPmi);
641:    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
652:    // Most points carry no rotation (CS_LINKS/CS_RECHTS = [0,0,0] → identity); the post/branch points
653:    // (CS_EIN_*) carry the angle that turns a part onto them.


Ursache der 5 roten Tests gefunden — sie sind **vorbestehend und unabhängig von meiner Änderung**: identische 5 Fehler mit und ohne meinen Edit.

## Der Fix ist drin

`GroupProcessor.ts`, in `makeComponentGroup` (plus die zwei Aufrufer `buildDetachEvent`/`buildRegroupEvent` und Entfernung des dadurch ungenutzten `computePositions`):

- Neue Gruppe bekommt jetzt `rot: quatMul(g.transform.rot, origin.rot)` statt nur `g.transform.rot` — die akkumulierte In-Group-Rotation des Anker-Mitglieds (`members[0]`) wird einkomponiert.
- Die Positionsverankerung (`pos = g.transform.pos + R·origin.pos`) bleibt unverändert; sie war bereits korrekt. Damit behalten die Elemente einer aufgelösten Komponente ihre Weltorientierung.

Typecheck: für `GroupProcessor.ts` sauber. Die gemeldeten `vue-tsc`-Fehler liegen ausschließlich in `ProductBrowser.vue` (`CatalogNode.gruppen`), vorbestehend.

## Warum ich den Fix nicht per Test bestätigen kann

Die 5 Fehler kommen von **Fixture-Drift**, nicht von der Logik:

- Der Test lädt `packages/frontend/.storybook/products/schutzzaun_basic/SBS1044PRA.json` und erwartet die PMI-Punkte **`CS_LINKS` (x=0)** und **`CS_RECHTS` (x=2.168)**.
- Diese Datei (42 KB, in HEAD) enthält diese Namen **nicht mehr** — sie hat jetzt `CS_BASE`, `CS_EIN`, `CS_EIN_L/R`, `CS_OUT_L_*`, `CS_OUT_R_*`, `CS_SELECT_*`.
- `pmiLocal` findet `CS_RECHTS`/`CS_LINKS` also nicht → alle Docking-Offsets kollabieren auf 0. Folge: der Merge-Chain-Test liefert `worldX(b)=0` statt `2.168`, der Detach-Split kann bei lauter 0-Koordinaten nicht trennen (verschiebt alles), und der 90°-Dock-Test bekommt Offset 0.

Die Testsuite ist damit unabhängig von meinem Change rot (gleiche Fixture-Klasse-Problematik wie beim Rollenförderer: die Storybook-Produkt-Assets wurden neu erzeugt). Ein positiver Nachweis meiner Rotations-Korrektur über die Suite geht erst, wenn die Fixture wieder passende PMI-Punkte trägt. Regression durch meine Änderung: keine (5/13 vorher = 5/13 nachher, identische Testnamen).


kannst die Tests fixen?


Ich sehe mir zuerst die restlichen Tests an, um zu verstehen, welche PMI-Punkte sie alle brauchen.


114
115	describe("GroupProcessor — docking points", () => {
116	    it("marks inner CS occupied, leaves outer ends open", () => {
117	        const { gp, ids } = buildChain(2);
118	        const [a, b] = ids;
119	        const occ = gp.occupiedPmis();
120	        expect(occ).toContainEqual({ element: a, pmi: "CS_RECHTS" });
121	        expect(occ).toContainEqual({ element: b, pmi: "CS_LINKS" });
122	        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
123	        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });
124	    });
125
126	    it("rejects a second merge onto an already-occupied docking point (no double booking)", () => {
127	        const gp = new GroupProcessor();
128	        const ev = [0, 1, 2].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
129	        const [a, b, c] = ev.map((e) => e.rootHandleId);
130	        gp.replay(base(), ev);
131	        const gA = gp.groupIdOfElement(a)!,
132	            gB = gp.groupIdOfElement(b)!,
133	            gC = gp.groupIdOfElement(c)!;
134	        // b docks onto a.CS_RECHTS; c then tries the very same point → rejected, c stays separate.
135	        gp.replay(base(), [...ev, merge(gA, gB, a, b), merge(gA, gC, a, c)]);
136	        expect(gp.groupCount()).toBe(2);
137	        expect(gp.occupiedPmis().filter((o) => o.element === a && o.pmi === "CS_RECHTS")).toHaveLength(1);
138	    });
139	});
140
141	describe("GroupProcessor — role resolver", () => {
142	    it("resolves a geometry pick to its part and its assembly", () => {
143	        const { gp, events } = buildChain(2);
144	        const scene = gp.replay(base(), events);
145	        const a = tagged(scene, "part")[0];
146	        const groupNode = transformOf(scene, a).parentKey;
147	        const geom = scene.handles.find(
148	            (h: any) => h.components.some((c: any) => c._resourceClass === "component_collider") && h.id !== a,
149	        ).id;
150	        expect(gp.elementRootOf(geom)).toBe(gp.nearestAncestorOfRole(geom, "part"));
151	        expect(tagged(scene, "part")).toContain(gp.elementRootOf(geom));
152	        expect(gp.nearestAncestorOfRole(geom, "assembly")).toBe(groupNode);
153	        expect(gp.nearestAncestorOfRole(groupNode, "part")).toBeNull();
154	    });
155	});
156
157	describe("GroupProcessor — detach (split at element)", () => {
158	    it.each([
159	        [0, ["a", "b", "c", "d"]],
160	        [1, ["b", "c", "d"]],
161	        [2, ["c", "d"]],
162	        [3, ["d"]],
163	    ])("picking index %i moves that element plus everything to its right", (idx, expected) => {
164	        const { gp, ids, events } = buildChain(4);
165	        const labels = ["a", "b", "c", "d"];
166	        const det = gp.buildDetachEvent(ids[idx as number])!;
167	        gp.replay(base(), [...events, det]);
168	        const movingGroup = gp.groupIdOfElement(ids[idx as number]);
169	        const moving = ids.filter((id) => gp.groupIdOfElement(id) === movingGroup).map((id) => labels[ids.indexOf(id)]);
170	        expect(moving.sort()).toEqual((expected as string[]).slice().sort());
171	    });
172	});
173
174	describe("GroupProcessor — regroup (for move/rotate)", () => {
175	    it("breaks a partial selection into a movable component, leaves the rest", () => {
176	        const { gp, ids, events } = buildChain(3);
177	        gp.replay(base(), events);
178	        const [a, b, c] = ids;
179	        const { event, moveGroupIds } = gp.buildRegroupEvent([b, c]);
180	        expect(event).not.toBeNull();
181	        gp.replay(base(), [...events, event!]);
182	        expect(gp.groupCount()).toBe(2); // {a} and {b,c}
183	        expect(moveGroupIds).toHaveLength(1);
184	        expect(gp.groupIdOfElement(b)).toBe(gp.groupIdOfElement(c));
185	        expect(gp.groupIdOfElement(a)).not.toBe(gp.groupIdOfElement(b));
186	    });
187
188	    it("does not regroup when the whole group is selected", () => {
189	        const { gp, ids, events } = buildChain(3);
190	        gp.replay(base(), events);
191	        const { event, moveGroupIds } = gp.buildRegroupEvent(ids);
192	        expect(event).toBeNull();
193	        expect(moveGroupIds).toEqual([gp.groupIdOfElement(ids[0])]);
194	    });
195	});
196
197	describe("GroupProcessor — rotated dock (90° branch)", () => {
198	    // 90° about +Z, [w, x, y, z]. Rotates a local +X offset onto +Y.
199	    const Z90: Quat = [Math.SQRT1_2, 0, 0, Math.SQRT1_2];
200
201	    it("applies the edge rotation to the docked member and rotates its offset", () => {
202	        const gp = new GroupProcessor();
203	        const e1 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
204	        const e2 = gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]);
205	        const [a, b] = [e1.rootHandleId, e2.rootHandleId];
206	        gp.replay(base(), [e1, e2]);
207	        // Dock B's CS_RECHTS (local [WIDTH,0,0]) onto A's CS_LINKS (origin), turned 90° about Z.
208	        const rotMerge: GroupEvent = {
209	            type: "group-merge",
210	            timestamp: 0,
211	            targetGroupId: gp.groupIdOfElement(a)!,
212	            movingGroupId: gp.groupIdOfElement(b)!,
213	            anchorRootId: a,
214	            joinRootId: b,
215	            anchorPmiId: "CS_LINKS",
216	            joinPmiId: "CS_RECHTS",
217	            rot: Z90,
218	        };
219	        const scene = gp.replay(base(), [e1, e2, rotMerge]);
220	        const trB = transformOf(scene, b);
221	        // The member carries the edge rotation in its group-local transform.
222	        expect(trB.rotation).toEqual(Z90);
223	        // pos(B) = pos(A) + R_A·at(CS_LINKS=0) − R_B·dock(CS_RECHTS=[WIDTH,0,0]) = −R·[WIDTH,0,0].
224	        const expected = quatRotate(Z90, [WIDTH, 0, 0]).map((v) => -v);
225	        expect(trB.translation[0]).toBeCloseTo(expected[0]);
226	        expect(trB.translation[1]).toBeCloseTo(expected[1]);
227	        expect(trB.translation[2]).toBeCloseTo(expected[2]);
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);

describe("GroupProcessor — docking points", () => {
    it("marks inner CS occupied, leaves outer ends open", () => {
        const { gp, ids } = buildChain(2);
        const [a, b] = ids;
        const occ = gp.occupiedPmis();
        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" });
    });

    it("rejects a second merge onto an already-occupied docking point (no double booking)", () => {
        const gp = new GroupProcessor();
        const ev = [0, 1, 2].map(() => gp.buildCreateEvent({ handles: product().handles }, [0, 0, 0]));
        const [a, b, c] = ev.map((e) => e.rootHandleId);
        gp.replay(base(), ev);
        const gA = gp.groupIdOfElement(a)!,
            gB = gp.groupIdOfElement(b)!,
            gC = gp.groupIdOfElement(c)!;
        // 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);
    });
});

describe("GroupProcessor — role resolver", () => {
    it("resolves a geometry pick to its part and its assembly", () => {
        const { gp, events } = buildChain(2);
        const scene = gp.replay(base(), events);
        const a = tagged(scene, "part")[0];
        const groupNode = transformOf(scene, a).parentKey;
        const geom = scene.handles.find(
            (h: any) => h.components.some((c: any) => c._resourceClass === "component_collider") && h.id !== a,
        ).id;
        expect(gp.elementRootOf(geom)).toBe(gp.nearestAncestorOfRole(geom, "part"));
        expect(tagged(scene, "part")).toContain(gp.elementRootOf(geom));
        expect(gp.nearestAncestorOfRole(geom, "assembly")).toBe(groupNode);
        expect(gp.nearestAncestorOfRole(groupNode, "part")).toBeNull();
    });
});

describe("GroupProcessor — detach (split at element)", () => {
    it.each([
        [0, ["a", "b", "c", "d"]],
        [1, ["b", "c", "d"]],
        [2, ["c", "d"]],
        [3, ["d"]],
    ])("picking index %i moves that element plus everything to its right", (idx, expected) => {
        const { gp, ids, events } = buildChain(4);
        const labels = ["a", "b", "c", "d"];
        const det = gp.buildDetachEvent(ids[idx as number])!;
        gp.replay(base(), [...events, det]);
        const movingGroup = gp.groupIdOfElement(ids[idx as number]);
        const moving = ids.filter((id) => gp.groupIdOfElement(id) === movingGroup).map((id) => labels[ids.indexOf(id)]);
        expect(moving.sort()).toEqual((expected as string[]).slice().sort());
    });
});

describe("GroupProcessor — regroup (for move/rotate)", () => {
    it("breaks a partial selection into a movable component, leaves the rest", () => {
        const { gp, ids, events } = buildChain(3);
        gp.replay(base(), events);
        const [a, b, c] = ids;
        const { event, moveGroupIds } = gp.buildRegroupEvent([b, c]);
        expect(event).not.toBeNull();
        gp.replay(base(), [...events, event!]);
        expect(gp.groupCount()).toBe(2); // {a} and {b,c}
        expect(moveGroupIds).toHaveLength(1);
        expect(gp.groupIdOfElement(b)).toBe(gp.groupIdOfElement(c));
        expect(gp.groupIdOfElement(a)).not.toBe(gp.groupIdOfElement(b));
    });

    it("does not regroup when the whole group is selected", () => {
        const { gp, ids, events } = buildChain(3);
        gp.replay(base(), events);
        const { event, moveGroupIds } = gp.buildRegroupEvent(ids);
        expect(event).toBeNull();
        expect(moveGroupIds).toEqual([gp.groupIdOfElement(ids[0])]);
    });
});

describe("GroupProcessor — rotated dock (90° branch)", () => {
    // 90° about +Z, [w, x, y, z]. Rotates a local +X offset onto +Y.
    const Z90: Quat = [Math.SQRT1_2, 0, 0, Math.SQRT1_2];

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


Alle Tests hängen an einem Produkt mit `CS_LINKS` (lokal 0) und `CS_RECHTS` (lokal [2.168,0,0]). Die aktuelle Fixture hat diese Punkte nicht mehr. Ich prüfe die genauen Strukturanforderungen des Codes (PMI-Lesen, Rollen, Collider) und die Git-Historie der Fixture, um zu entscheiden, wie ich fixe.


641	    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
642	        const handle = this.handles.get(rootHandleId);
643	        for (const comp of handle?.components ?? []) {
644	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
645	                return comp.properties.position.value;
646	            }
647	        }
648	        return [0, 0, 0];
649	    }
650
651	    // The docking point's frame orientation, from its `properties.rotation` (Euler radians, ZYX).
652	    // Most points carry no rotation (CS_LINKS/CS_RECHTS = [0,0,0] → identity); the post/branch points
653	    // (CS_EIN_*) carry the angle that turns a part onto them.
654	    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
655	        const handle = this.handles.get(rootHandleId);
656	        for (const comp of handle?.components ?? []) {
657	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
658	                const rot = comp.properties?.rotation?.value;
659	                return rot ? eulerZyxRadToQuat(rot as Vec3) : IDENTITY_QUAT;
660	            }
661	        }
662	        return IDENTITY_QUAT;
663	    }
664
665	    // Relative rotation of the join part w.r.t. the anchor part for a dock: aligning the join point's
666	    // frame onto the anchor point's turns the join part by R_anchorPmi ∘ R_joinPmi⁻¹. Identity when
667	    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
668	    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
669	        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
670	    }
671
672	    private rootTransform(handle: Handle): any {
673	        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
674	    }
675

    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
        const handle = this.handles.get(rootHandleId);
        for (const comp of handle?.components ?? []) {
            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
                return comp.properties.position.value;
            }
        }
        return [0, 0, 0];
    }

    // The docking point's frame orientation, from its `properties.rotation` (Euler radians, ZYX).
    // Most points carry no rotation (CS_LINKS/CS_RECHTS = [0,0,0] → identity); the post/branch points
    // (CS_EIN_*) carry the angle that turns a part onto them.
    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
        const handle = this.handles.get(rootHandleId);
        for (const comp of handle?.components ?? []) {
            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
                const rot = comp.properties?.rotation?.value;
                return rot ? eulerZyxRadToQuat(rot as Vec3) : IDENTITY_QUAT;
            }
        }
        return IDENTITY_QUAT;
    }

    // Relative rotation of the join part w.r.t. the anchor part for a dock: aligning the join point's
    // frame onto the anchor point's turns the join part by R_anchorPmi ∘ R_joinPmi⁻¹. Identity when
    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
    }

    private rootTransform(handle: Handle): any {
        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
    }


cd /Users/marcus.hinz/projects/adesso-cpq
f=packages/frontend/.storybook/products/schutzzaun_basic/SBS1044PRA.json
echo "=== Historie der Fixture ==="
git log --oneline -- "$f" | head
echo
echo "=== Hatte eine frühere Version CS_LINKS/CS_RECHTS? (letzte 6 Commits der Datei) ==="
for c in $(git log --format=%h -6 -- "$f"); do
  n=$(git show "$c:$f" 2>/dev/null | grep -c 'CS_LINKS\|CS_RECHTS')
  echo "$c : CS_LINKS/RECHTS-Treffer = $n"
done
echo
echo "=== Gibt es IRGENDEIN Produkt-JSON mit CS_LINKS und CS_RECHTS? ==="
grep -rl 'CS_LINKS' packages/frontend/.storybook/products 2>/dev/null | head

Fixture-Historie und CS_LINKS-Vorkommen