Ich untersuche eine Refactoring-Idee in den Projekten unter /Users/marcus.hinz/projects (insbesondere adesso-cpq, geko-fire, geko-core) und /Users/marcus.hinz/claude-projects/robotunits.

Finde und beschreibe folgende Codebestandteile. Gib mir für jeden gefundenen Punkt Dateipfad + relevante Zeilennummern und einen kurzen Auszug der Signatur/Struktur (keine ganzen Dateien dumpen):

1. Den "DefaultCommandProcessor" (Klasse/Objekt). Wie heißt die Datei, welche Methoden hat er, wie werden Commands registriert/verarbeitet? Gibt es schon einen Registrierungs-Mechanismus (register/use/addHandler o.ä.)?

2. Die "Dreipunkt"-Logik ("three-point", "Dreipunkt", 3-Punkt, evtl. "placement", "snap-to-point", "processInput", "PMI-DropPoints"). Wo liegt sie? Ist sie im DefaultCommandProcessor eingebettet oder separat? Welche Funktionen/Klassen gehören dazu, inkl. "Anbau"/Attachment.

3. Wie hängen "PMI" / "PMIs" mit dem reinen Einbau zusammen — wo ist der "reine Einbau über PMIs" (die CPQ-Seite) implementiert?

4. Falls vorhanden: Storybook-Stories, die den CommandProcessor oder die Placement/Dreipunkt-Logik verwenden (Dateien mit .stories.*).

Berichte strukturiert nach diesen 4 Punkten. Nur die Fakten zur Codestruktur, keine Bewertung.


EnterWorktree ExitWorktree Monitor SendMessage TaskStop WebFetch WebSearch


- git-sync: Commit + Push aller persistent konfigurierten Projekt-Repos
- deep-research: Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. - When the user wants a deep, multi-source, fact-checked research report on any topic. BEFORE invoking, check if the question is specific enough to research directly — if underspecified (e.g., "what car to buy" without budget/use-case/region), ask 2-3 clarifying questions to narrow scope. Then pass the refined question as args, weaving the answers in.
- dataviz: Use this skill whenever you are about to create ANY chart, graph, plot, dashboard, or data visualization, in ANY output medium — an HTML or React artifact, inline SVG, plotting code in any library (matplotlib, plotly, d3, Recharts, …), an image/PNG you will render and upload, or a chart shared into Slack. Read it BEFORE writing the first line of chart code, choosing chart colors, building a stat tile / meter / KPI row, or laying out a dashboard. Produces visualizations that read as one system — elegant, accessible, consistent in light and dark — using a brand-neutral placeholder palette you swap for your own. Teaches a design-system-agnostic method: a form heuristic, a color formula with a runnable validator, mark specs, and interaction rules. A validated default palette is documented in `references/palette.md` — swap that file's values for your brand's. Triggers on: "chart", "graph", "plot", "data viz", "visualization", "dashboard", "analytics", "visualize data", "categorical colors", "sequential / diverging palette", "stat tile", "sparkline", "heatmap", "legend", "axis", "tooltip", "chart colors", "color by series".
- artifact-design: Design guidance and fundamentals for Artifacts.
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- verify: Verify that a code change actually does what it's supposed to by exercising it end-to-end and observing behavior — drive the affected flow, not just tests or typecheck. Run before committing nontrivial changes; bootstraps this repo's project verify skill if none exists yet. Don't invoke it on a diff that only touches tests, docs, or other code with no runtime surface to drive (a change to product source always has one) — there's nothing to observe.
- code-review: Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud). Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review.
- simplify: Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring cloud agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.
TRIGGER — read BEFORE opening the target file; don't skip because it "looks like a one-liner" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).
SKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).
- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a GitHub pull request; for your working diff use /code-review
- security-review: Complete a security review of the pending changes on the current branch

git-sync deep-research dataviz artifact-design update-config keybindings-help verify code-review simplify fewer-permission-prompts loop schedule claude-api run init review security-review


I'll search across the projects for these components.


Bash

grep -rl "DefaultCommandProcessor" /Users/marcus.hinz/projects/adesso-cpq /Users/marcus.hinz/projects/geko-fire /Users/marcus.hinz/projects/geko-core /Users/marcus.hinz/claude-projects/robotunits --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null

Find DefaultCommandProcessor files


grep -rli -E "dreipunkt|three-point|threepoint|3-punkt|snap-to-point|droppoint|PMI" /Users/marcus.hinz/projects/adesso-cpq /Users/marcus.hinz/projects/geko-fire /Users/marcus.hinz/projects/geko-core /Users/marcus.hinz/claude-projects/robotunits --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -50

Find three-point/PMI files


find /Users/marcus.hinz/projects/adesso-cpq /Users/marcus.hinz/projects/geko-fire /Users/marcus.hinz/projects/geko-core /Users/marcus.hinz/claude-projects/robotunits -name "*.stories.*" 2>/dev/null | head -50

Find Storybook stories


Let me locate the actual DefaultCommandProcessor implementation and explore the events directory.


ls -la /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/ 2>/dev/null; echo "---MODEL---"; ls -la /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/ 2>/dev/null

List events and model directories


total 112
drwxr-xr-x  7 marcus.hinz  staff    224 Jul  9 19:07 .
drwxr-xr-x  5 marcus.hinz  staff    160 Jun 30 10:23 ..
-rw-r--r--  1 marcus.hinz  staff    237 Jun 30 10:24 CommandProcessor.ts
-rw-r--r--  1 marcus.hinz  staff   7705 Jul  9 12:22 DefaultCommandProcessor.test.ts
-rw-r--r--  1 marcus.hinz  staff  33595 Jul  9 19:07 DefaultCommandProcessor.ts
-rw-r--r--  1 marcus.hinz  staff   1792 Jun 30 10:24 EventStore.ts
-rw-r--r--  1 marcus.hinz  staff   3365 Jul  9 14:59 eventData.ts
---MODEL---
total 120
drwxr-xr-x  5 marcus.hinz  staff    160 Jul  9 19:07 .
drwxr-xr-x  5 marcus.hinz  staff    160 Jun 30 10:23 ..
-rw-r--r--  1 marcus.hinz  staff  15398 Jul  9 09:35 GroupProcessor.test.ts
-rw-r--r--  1 marcus.hinz  staff  37751 Jul  9 19:07 GroupProcessor.ts
-rw-r--r--  1 marcus.hinz  staff   2667 Jul  8 17:07 transformMath.ts


grep -rn "class DefaultCommandProcessor\|DefaultCommandProcessor" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src --include="*.ts" 2>/dev/null | grep -v ".test.ts"

Find DefaultCommandProcessor class def


/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/index.ts:18:export type { DockingRules } from "./configurator/events/DefaultCommandProcessor";
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.stories.ts:5:import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/Fire3d.stories.ts:4:import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/Fire3d.stories.ts:22:            const processor = new DefaultCommandProcessor();
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/Fire3d.stories.ts:40:            const processor = new DefaultCommandProcessor();


Now let me read the key files: CommandProcessor interface and DefaultCommandProcessor.


1	import type { UIReaction } from "../types";
2
3	export class CommandProcessor {
4	    setInitialScene(_scene: unknown): void {
5	        // override in subclass
6	    }
7
8	    handleEvent(_event: unknown): UIReaction[] {
9	        return [];
10	    }
11	}
12

import type { UIReaction } from "../types";

export class CommandProcessor {
    setInitialScene(_scene: unknown): void {
        // override in subclass
    }

    handleEvent(_event: unknown): UIReaction[] {
        return [];
    }
}


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

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

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

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

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

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

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

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


grep -n -E "^\s*(export|class|interface|type|private|public|protected|async|register|use|addHandler|constructor)\s|=>|=\s*function|^\s*[a-zA-Z_]+\s*\(" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts | head -120

Extract signatures from DefaultCommandProcessor


(Bash completed with no output)


Let me extract the structure of DefaultCommandProcessor.ts properly.


grep -nE "(class |interface |export type|private [a-zA-Z]+\(|public |^    [a-zA-Z_]+\(| [a-zA-Z_]+\(.*\):|=> |constructor|handleEvent|setInitialScene|register|switch|case )" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts | head -150

Grep structural lines


grep -n "class\|interface\|export\|constructor\|handleEvent\|setInitialScene\|switch\|case \|private\|public " "/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts" | head -150

Grep structural lines simpler


Check file line count and grep count


659 /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts


1	import type { UIReaction, MenuEntry } from "../types";
2	import { CommandProcessor } from "./CommandProcessor";
3	import { EventStore } from "./EventStore";
4	import { GroupProcessor, ROLE, type GroupEvent } from "../model/GroupProcessor";
5	import { extractMoveData, extractRotateData, extractPlacementData } from "./eventData";
6	import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";
7
8	// Client-supplied placement rules, keyed by the moving part's docking class:
9	//   { movingClass: { targetClass: { dragPointName: dropPointName[] } } }
10	// Classes match the `class-…` tags on part root handles (e.g. "schutzzaun_allround-steher").
11	export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;
12
13	export class DefaultCommandProcessor extends CommandProcessor {
14	    private eventStore = new EventStore();
15	    private groups = new GroupProcessor();
16	    private freeCursorX = 0;
17	    // Current selection as a set of picks. Each pick is a picked geometry plus its escalation level
18	    // (part → assembly → …). A plain click sets a single pick (re-clicking the same one escalates);
19	    // an additive click (multi-select) adds another pick, or toggles it off if already present.
20	    private selection: { pickedId: string; level: number }[] = [];
21	    // A regroup recorded when a move/rotate starts but not yet committed: it is applied during the
22	    // interaction (so the engine sees the movable groups) and pushed together with the move/rotate as
23	    // one undo step on completion — or discarded on cancel. Keeps "break group to move" out of undo.
24	    private pendingRegroup: GroupEvent | null = null;
25
26	    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
27	    constructor(private readonly dockingRules?: DockingRules) {
28	        super();
29	    }
30
31	    setInitialScene(scene: any): void {
32	        this.eventStore.setInitialState(scene);
33	    }
34
35	    private replay(): any {
36	        const events = this.eventStore.getEventsToReplay();
37	        return this.groups.replay(
38	            this.eventStore.getInitialState(),
39	            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
40	        );
41	    }
42
43	    handleEvent(event: any): UIReaction[] {
44	        switch (event?.eventTypeName) {
45	            case "interaction-mode":
46	                if (event.lifecycleState !== "update") return [];
47	                return this.handleInteractionMode(event);
48	            case "add-product":
49	                return this.handleAddProduct(event);
50	            case "keyboard-build":
51	                return this.handleKeyboardBuild(event);
52	            case "detach-element":
53	                return this.handleDetachElement(event);
54	            case "escalate-selection":
55	                return this.handleEscalateSelection(event);
56	            case "prepare-action":
57	                return this.handlePrepareAction(event);
58	            case "undo":
59	                return this.handleUndo();
60	            case "redo":
61	                return this.handleRedo();
62	            case "esc":
63	                return this.handleEsc();
64	            case "context-click":
65	                return this.getContextForSelection(event);
66	            case "selection-click":
67	                return this.handleSelectionClickEvent(event);
68	            case "selection":
69	                return [];
70	            default:
71	                console.error("unknown event type ", event?.eventTypeName);
72	                return [];
73	        }
74	    }
75
76	    private handleEsc(): UIReaction[] {
77	        // Cancel a prepared-but-uncommitted regroup and resync the engine to the real model.
78	        if (this.pendingRegroup) {
79	            this.pendingRegroup = null;
80	            const scene = this.replay();
81	            return [
82	                { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
83	                { type: "sendCommand", cmd: "setScene", args: [scene] },
84	            ];
85	        }
86	        return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
87	    }
88
89	    private handleAddProduct(event: any): UIReaction[] {
90	        if (!event.handles?.length) return [];
91	        // A new product enters as a free group of one, offset along X by the running cursor so
92	        // products land adjacent and in view instead of overlapping.
93	        const position: Vec3 = [this.freeCursorX, 0, 0];
94	        this.freeCursorX += this.productExtent(event.handles);
95	        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
96	        this.eventStore.push(createEvent);
97	        const scene = this.replay();
98	        return [
99	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
100	            { type: "sendCommand", cmd: "setScene", args: [scene] },
101	        ];
102	    }
103
104	    // PROTOTYPE — keyboard build shortcut ("+" held + arrow), fed from Fire3D. Works off the last
105	    // active selection taken from the store. right/left dock a COPY of the active part onto its open
106	    // CS_OUT_R_0 / CS_OUT_L_0 end (only if that end is still free); up/down nudge the active group
107	    // along Y. Docking PMIs are hardcoded for now — this is a thinking prop, not the final wiring.
108	    private handleKeyboardBuild(event: any): UIReaction[] {
109	        this.replay();
110	        const active = this.selection[this.selection.length - 1]?.pickedId;
111	        const activeRoot = active ? this.groups.elementRootOf(active) : null;
112	        const groupId = activeRoot ? this.groups.groupIdOfElement(activeRoot) : null;
113	        if (!active || !activeRoot || !groupId) return [];
114
115	        if (event.direction === "up" || event.direction === "down") {
116	            const t = this.groups.transformOf(groupId);
117	            if (!t) return [];
118	            const step = event.direction === "up" ? 100 : -100;
119	            this.eventStore.push({
120	                type: "group-move",

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

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

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

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

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

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

    handleEvent(event: any): UIReaction[] {
        switch (event?.eventTypeName) {
            case "interaction-mode":
                if (event.lifecycleState !== "update") return [];
                return this.handleInteractionMode(event);
            case "add-product":
                return this.handleAddProduct(event);
            case "keyboard-build":
                return this.handleKeyboardBuild(event);
            case "detach-element":
                return this.handleDetachElement(event);
            case "escalate-selection":
                return this.handleEscalateSelection(event);
            case "prepare-action":
                return this.handlePrepareAction(event);
            case "undo":
                return this.handleUndo();
            case "redo":
                return this.handleRedo();
            case "esc":
                return this.handleEsc();
            case "context-click":
                return this.getContextForSelection(event);
            case "selection-click":
                return this.handleSelectionClickEvent(event);
            case "selection":
                return [];
            default:
                console.error("unknown event type ", event?.eventTypeName);
                return [];
        }
    }

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

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

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

        if (event.direction === "up" || event.direction === "down") {
            const t = this.groups.transformOf(groupId);
            if (!t) return [];
            const step = event.direction === "up" ? 100 : -100;
            this.eventStore.push({
                type: "group-move",


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events && grep -n "private \|handlePlacement\|dock\|Dock\|three\|Three\|drei\|Drei\|PMI\|pmi\|attach\|Attach\|Anbau\|placement\|Placement" DefaultCommandProcessor.ts

Grep placement/docking terms in file


Test grep private


file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts; grep -a -c "private" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts; echo "exit:$?"

Check file type and grep -a


/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts: data
33
exit:0


The file has some binary bytes so grep needs `-a`. Let me extract the structure.


Grep placement/docking with -a


5:import { extractMoveData, extractRotateData, extractPlacementData } from "./eventData";
8:// Client-supplied placement rules, keyed by the moving part's docking class:
11:export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;
26:    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
27:    constructor(private readonly dockingRules?: DockingRules) {
35:    private replay(): any {
65:                return this.getContextForSelection(event);
67:                return this.handleSelectionClickEvent(event);
76:    private handleEsc(): UIReaction[] {
89:    private handleAddProduct(event: any): UIReaction[] {
105:    // active selection taken from the store. right/left dock a COPY of the active part onto its open
107:    // along Y. Docking PMIs are hardcoded for now — this is a thinking prop, not the final wiring.
108:    private handleKeyboardBuild(event: any): UIReaction[] {
129:        // right / left: dock a copy of the active part onto its open end
132:        if (this.groups.occupiedPmis().some((o) => o.element === activeRoot && o.pmi === anchorPmi)) {
150:        // Chain: move the active selection onto the fresh copy so the next Shift+arrow docks onto it.
157:    private handleSelectionClickEvent(event: any): UIReaction[] {
159:        // Intercept a click on one of our select-PMI marker spheres — dock a copy of the element there.
162:            const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
164:            return this.handleMarkerBuild(element, pmi, stop);
189:    // A clicked select marker (CS_SELECT_<side>_<angle>) docks a copy of its element onto the matching
191:    // or CS_EIN_R (left side), and the branch orientation (0/90/270) follows from the PMI frames via
192:    // dockRotation. Occupied ends do nothing. Selection stays on the original so its markers re-render.
193:    private handleMarkerBuild(element: string, pmi: string, stop: UIReaction): UIReaction[] {
195:        // The select marker maps 1:1 to its dock anchor of the same suffix: CS_SELECT_<side>_<angle>
196:        // → CS_OUT_<side>_<angle>. That anchor's frame makes dockRotation orient the copy so it builds
198:        const anchorPmi = pmi.replace("CS_SELECT_", "CS_OUT_");
199:        const joinPmi = pmi.includes("_R_") ? "CS_EIN_L" : "CS_EIN_R";
203:        if (this.groups.occupiedPmis().some((o) => o.element === element && o.pmi === anchorPmi)) {
217:            // Join-Root is `element`, not the fresh copy: the copy is an identical clone (same PMI
218:            // frames) and isn't replayed into the handle map yet, so reading its join PMI would fall
220:            rot: this.groups.dockRotation(element, anchorPmi, element, joinPmi),
222:        // Drop the selection so the markers disappear once the copy is attached.
228:    private handleEscalateSelection(event: any): UIReaction[] {
239:    private selectedNodeIds(): string[] {
248:    private selectionReactions(stop: UIReaction): UIReaction[] {
258:    // On selection, drop a small green sphere at every PMI of the selected element(s). The marker hangs
259:    // under the element root's transform (PMI positions are element-local), rendered via an inline
262:    private withSelectionMarkers(scene: any): any {
265:        const occupied = new Set(this.groups.blockedPmis().map((o) => `${o.element} ${o.pmi}`));
272:                if (c._resourceClass !== "component_pmi") continue;
273:                // Only the select PMIs (CS_SELECT_<side>_<angle>) get a marker, not the base points.
275:                // Show only where a copy can dock: the matching CS_OUT slot must be open per the post
295:                        // Box collider (shapeType 4) around the PMI — this @ad/fire build registers only
307:    private handleInteractionMode(event: any): UIReaction[] {
313:            case "placement":
314:                return this.handlePlacementEnd(event);
323:    private handleMoveEnd(event: any): UIReaction[] {
342:    private handleRotateEnd(event: any): UIReaction[] {
362:    private commitInteraction(group: GroupEvent[]): UIReaction[] {
372:    private handlePrepareAction(event: any): UIReaction[] {
396:    private selectionElementRoots(): string[] {
408:    private handlePlacementEnd(event: any): UIReaction[] {
409:        const data = extractPlacementData(event);
417:        const placement = this.placementEvent(data, movingGroupId);
418:        if (placement) this.eventStore.push(placement);
424:    // Pull a part out of its group into its own top-level group, then start placement on that group's
426:    private handleDetachElement(event: any): UIReaction[] {
439:        const dragPoints = this.placementDragPoints(subject);
444:        // No docking rule for this class → no drag points. fire's placement mode dereferences
445:        // dragPoints[0], so skip start_placement_mode entirely instead of handing it an empty array.
449:                cmd: "start_placement_mode",
457:    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {
475:                    rot: this.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),
490:    private handleUndo(): UIReaction[] {
501:    private handleRedo(): UIReaction[] {
512:    private getContextForSelection(event: any): UIReaction[] {
530:        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode
532:        const dragPoints = this.placementDragPoints(groupSelector);
562:                                          cmd: "start_placement_mode",
574:                                  // placement on the freed element's own top-level group node.
587:    private groupSelectorOf(handleId: string): string | null {
594:    // Docking class of a "#<groupKey>" subject, from the element root's `class-…` tag. Null when the
596:    private classOf(subject: string): string | null {
602:    // Placement drag/drop wiring from the client-supplied docking rules (root-level points only). The
609:    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
611:        const rules = klass ? this.dockingRules?.[klass] : undefined;
614:        const occupied = new Set(this.groups.blockedPmis().map((o) => `${o.element}${o.pmi}`));
638:    private resolveGroupId(handleId: string): string | null {
645:    // Extent of a product from its docking points (domain-agnostic): the largest span across the
646:    // axes, used as the free-placement step so products don't pile up on each other.
647:    private productExtent(handles: any[]): number {
653:            if (c._resourceClass === "component_pmi") pts.push(c.properties.position.value);


Now let me read the placement/docking logic sections in detail.


408	    private handlePlacementEnd(event: any): UIReaction[] {
409	        const data = extractPlacementData(event);
410	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
411	        this.replay();
412
413	        // The subject is always a top-level group node (an element to re-place is detached into its
414	        // own group first — see handleDetachElement — so the engine never gets a nested subject).
415	        const movingGroupId = this.resolveGroupId(data.handleId);
416	        if (!movingGroupId) return [stop];
417	        const placement = this.placementEvent(data, movingGroupId);
418	        if (placement) this.eventStore.push(placement);
419
420	        const scene = this.replay();
421	        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
422	    }
423
424	    // Pull a part out of its group into its own top-level group, then start placement on that group's
425	    // node (a valid top-level subject). The freed neighbour points are open again post-detach.
426	    private handleDetachElement(event: any): UIReaction[] {
427	        this.replay();
428	        const detach = this.groups.buildDetachEvent(event.elementId);
429	        if (!detach) return [];
430	        this.eventStore.push(detach);
431	        const scene = this.replay();
432
433	        const groupId = detach.newGroups.find((ng) => ng.members.includes(event.elementId))?.groupId;
434	        const groupKey = groupId ? this.groups.groupKeyOf(groupId) : null;
435	        const setScene: UIReaction = { type: "sendCommand", cmd: "setScene", args: [scene] };
436	        if (!groupKey) return [setScene];
437
438	        const subject = "#" + groupKey;
439	        const dragPoints = this.placementDragPoints(subject);
440	        const reactions: UIReaction[] = [
441	            setScene,
442	            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
443	        ];
444	        // No docking rule for this class → no drag points. fire's placement mode dereferences
445	        // dragPoints[0], so skip start_placement_mode entirely instead of handing it an empty array.
446	        if (dragPoints.length > 0) {
447	            reactions.push({
448	                type: "sendCommand",
449	                cmd: "start_placement_mode",
450	                args: [{ subject, allowAllDragPoints: true, dragPoints }],
451	            });
452	        }
453	        return reactions;
454	    }
455
456	    // The merge (on snap) or move (free) event for a moving group.
457	    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {
458	        if (data.dropHandleId) {
459	            const anchorRoot = this.groups.elementRootOf(data.dropHandleId);
460	            const joinRoot = data.dragHandleId ? this.groups.elementRootOf(data.dragHandleId) : null;
461	            const targetGroupId = anchorRoot ? this.groups.groupIdOfElement(anchorRoot) : null;
462	            if (anchorRoot && joinRoot && targetGroupId && targetGroupId !== movingGroupId && data.dropPmiId && data.dragPmiId) {
463	                // The snapped points fix the join: the dragged point is placed onto the drop point.
464	                // Direction (any axis) follows from the point positions; the relative rotation (e.g. a
465	                // 90° branch onto a post) follows from the points' frame orientations.
466	                return {
467	                    type: "group-merge",
468	                    timestamp: Date.now(),
469	                    targetGroupId,
470	                    movingGroupId,
471	                    anchorRootId: anchorRoot,
472	                    joinRootId: joinRoot,
473	                    anchorPmiId: data.dropPmiId,
474	                    joinPmiId: data.dragPmiId,
475	                    rot: this.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),
476	                };
477	            }
478	            return null;
479	        }
480	        // No snap: free reposition of the group to the drop position.
481	        return {
482	            type: "group-move",
483	            timestamp: Date.now(),
484	            groupId: movingGroupId,
485	            position: data.position,
486	            rotation: this.groups.transformOf(movingGroupId)?.rot ?? IDENTITY_QUAT,
487	        };
488	    }
489
490	    private handleUndo(): UIReaction[] {
491	        if (!this.eventStore.canUndo) return [];
492	        this.eventStore.undo();
493	        const scene = this.replay();
494	        return [
495	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
496	            { type: "sendCommand", cmd: "setScene", args: [scene] },
497	            { type: "notification", message: "Action reverted" },
498	        ];
499	    }
500
501	    private handleRedo(): UIReaction[] {
502	        if (!this.eventStore.canRedo) return [];
503	        this.eventStore.redo();
504	        const scene = this.replay();
505	        return [
506	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
507	            { type: "sendCommand", cmd: "setScene", args: [scene] },

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

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

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

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

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

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

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

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

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


Now let me read the placementDragPoints and docking rules logic (587-660) and the getContextForSelection.


587	    private groupSelectorOf(handleId: string): string | null {
588	        const groupId = this.resolveGroupId(handleId);
589	        if (!groupId) return null;
590	        const key = this.groups.groupKeyOf(groupId);
591	        return key ? "#" + key : null;
592	    }
593
594	    // Docking class of a "#<groupKey>" subject, from the element root's `class-…` tag. Null when the
595	    // group carries no class — then there are no rules and the part places freely without snapping.
596	    private classOf(subject: string): string | null {
597	        const key = subject.startsWith("#") ? subject.slice(1) : subject;
598	        const groupId = this.groups.groupByKey(key);
599	        return groupId ? this.groups.classOfGroup(groupId) : null;
600	    }
601
602	    // Placement drag/drop wiring from the client-supplied docking rules (root-level points only). The
603	    // moving part's class selects its rule block; per target class, each drag point (CS_EIN_*) maps to
604	    // the drop points (CS_OUT_*) it may snap onto. The class only *finds* the candidate elements in the
605	    // model — the emitted drop selectors then key on the concrete element id (`#<id> .name-<dp>`), a
606	    // whitelist of the currently OPEN points. This keys on the (universal) id instead of excluding
607	    // occupied points by id, so a shared/edge case can only under-offer, never wrongly block a sibling.
608	    // The subject's own members and every occupied point are left out. No rule → no drag points → free.
609	    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
610	        const klass = this.classOf(subject);
611	        const rules = klass ? this.dockingRules?.[klass] : undefined;
612	        if (!rules) return [];
613
614	        const occupied = new Set(this.groups.blockedPmis().map((o) => `${o.element}${o.pmi}`));
615	        const ownKey = subject.startsWith("#") ? subject.slice(1) : subject;
616	        const ownMembers = new Set(this.groups.membersOfKey(ownKey));
617
618	        // Per drag point on the subject, collect the concrete open drop points across every target class.
619	        const byDrag = new Map<string, string[]>();
620	        for (const [targetClass, dragMap] of Object.entries(rules)) {
621	            const targets = this.groups.elementsOfClass(targetClass).filter((el) => !ownMembers.has(el));
622	            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
623	                const open = byDrag.get(dragPoint) ?? [];
624	                for (const el of targets) {
625	                    for (const dp of dropPoints) {
626	                        if (!occupied.has(`${el}${dp}`)) open.push(`#${el} .name-${dp}`);
627	                    }
628	                }
629	                byDrag.set(dragPoint, open);
630	            }
631	        }
632	        return [...byDrag].map(([dragPoint, dropPoints]) => ({
633	            selector: `.name-${dragPoint}`,
634	            dropPoints,
635	        }));
636	    }
637
638	    private resolveGroupId(handleId: string): string | null {
639	        const byKey = this.groups.groupByKey(handleId);
640	        if (byKey) return byKey;
641	        const root = this.groups.elementRootOf(handleId);
642	        return root ? this.groups.groupIdOfElement(root) : null;
643	    }
644
645	    // Extent of a product from its docking points (domain-agnostic): the largest span across the
646	    // axes, used as the free-placement step so products don't pile up on each other.
647	    private productExtent(handles: any[]): number {
648	        const root = handles.find((h) =>
649	            h.components?.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),
650	        );
651	        const pts: number[][] = [];
652	        for (const c of root?.components ?? []) {
653	            if (c._resourceClass === "component_pmi") pts.push(c.properties.position.value);
654	        }
655	        if (!pts.length) return 0;
656	        const span = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
657	        return Math.max(span(0), span(1), span(2));
658	    }
659	}

    private groupSelectorOf(handleId: string): string | null {
        const groupId = this.resolveGroupId(handleId);
        if (!groupId) return null;
        const key = this.groups.groupKeyOf(groupId);
        return key ? "#" + key : null;
    }

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

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

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

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

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

    // Extent of a product from its docking points (domain-agnostic): the largest span across the
    // axes, used as the free-placement step so products don't pile up on each other.
    private productExtent(handles: any[]): number {
        const root = handles.find((h) =>
            h.components?.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),
        );
        const pts: number[][] = [];
        for (const c of root?.components ?? []) {
            if (c._resourceClass === "component_pmi") pts.push(c.properties.position.value);
        }
        if (!pts.length) return 0;
        const span = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
        return Math.max(span(0), span(1), span(2));
    }
}


Now let me examine the GroupProcessor (the geometry/PMI merge logic) and check how PMIs relate to the "reine Einbau" CPQ side.


grep -an "dockRotation\|three\|Three\|drei\|Drei\|threePoint\|three-point\|merge\|Merge\|occupiedPmis\|blockedPmis\|component_pmi\|PMI\|export \|private [a-zA-Z]*(\|ROLE\|class Group" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.ts | head -80

Grep GroupProcessor structure


28:export const ROLE = { scene: "scene", assembly: "assembly", part: "part", geometry: "geometry" } as const;
29:export type NodeRole = (typeof ROLE)[keyof typeof ROLE];
38:export function instantiateScene(scene: Scene): { handles: Handle[]; rootHandleId: string } {
70:export interface Edge {
89:export interface GroupCreateEvent {
100:export interface GroupMergeEvent {
101:    type: "group-merge";
112:export interface GroupTransformEvent {
120:export interface GroupDetachEvent {
131:export interface GroupRegroupEvent {
140:export type GroupEvent =
142:    | GroupMergeEvent
147:export class GroupProcessor {
161:    private nextId(prefix: string): string {
203:    occupiedPmis(): { element: string; pmi: string }[] {
214:    // occupiedPmis plus the junction rule: where two elements meet straight (an edge on CS_OUT_*_0),
219:    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
223:    blockedPmis(): { element: string; pmi: string }[] {
224:        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
421:    private makeComponentGroup(
463:        return this.nearestAncestorOfRole(handleId, ROLE.part);
487:            if (node.role === ROLE.part || node.role === ROLE.assembly) out.push(cur);
504:    private apply(event: GroupEvent): void {
520:            case "group-merge": {
526:                // already in use, drop the merge rather than let a double-booked edge into the graph.
527:                const occupied = this.occupiedPmis();
583:    private materialize(baseScene: Scene): Scene {
600:                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
609:    // Group-local rigid transforms (pos + rot) per member. A group is a tree (each merge joins two
620:    private computeLayout(g: Group): Map<string, Transform> {
673:    private connectedComponents(members: string[], edges: Edge[]): string[][] {
701:    private groupNodeHandle(g: Group): Handle {
704:            tags: [roleTag(ROLE.assembly)],
718:    private sceneRootHandle(): Handle {
721:            tags: [roleTag(ROLE.scene)],
736:    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
739:            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
749:    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
752:            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
763:    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
767:    private rootTransform(handle: Handle): any {
771:    private roleOf(handle: Handle): NodeRole {
775:        return ROLE.geometry;
780:    private buildNodeIndex(handles: Handle[]): void {


grep -arn "reine\|Einbau\|einbau\|PMI\|pmi\|Dreipunkt\|dreipunkt\|three-point\|threePoint" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src /Users/marcus.hinz/claude-projects/robotunits /Users/marcus.hinz/projects/geko-core --include="*.ts" --include="*.vue" --include="*.md" 2>/dev/null | grep -iv "node_modules" | grep -i "einbau\|dreipunkt\|three-point\|threepoint" | head -40

Search Einbau/Dreipunkt terms


/Users/marcus.hinz/claude-projects/robotunits/katalog-schutzzaun_basic-root-nomenklatur.md:27:- **`CS_EIN_*`** — Einbau-/Kupplungspunkte für die Feldverkettung. Nur in **153** Teilen (17 %, die verkettbaren Felder).
/Users/marcus.hinz/claude-projects/robotunits/katalog-schutzzaun_basic-root-nomenklatur.md:69:- Geregelter Einbau nach links (eingehende Seite, `START_L`/`L`): geht.
/Users/marcus.hinz/claude-projects/robotunits/katalog-schutzzaun_basic-root-nomenklatur.md:70:- Geregelter Einbau nach rechts / Weiterreihen (`START_R`/`R`/`R_90`): **kaputt**, solange die rechte Seite nicht um das Feldmaß zurückkorrigiert wird.


Let me read the geometry/PMI docking math and the merge/layout logic.


609	    // Group-local rigid transforms (pos + rot) per member. A group is a tree (each merge joins two
610	    // disjoint parts with one edge), so transforms propagate from an arbitrary origin over the
611	    // UNDIRECTED edge graph — traversing an edge either way.
612	    //
613	    // Each edge fixes that `to.toPmi` coincides with `from.fromPmi` AND that `to` is rotated by
614	    // `edge.rot` relative to `from` (identity = shared orientation, the left/right case). With the
615	    // accumulated part rotation R, a member's docking point sits at world R·pmi, so coincidence gives:
616	    //   forward (cur = from):  pos(to)   = pos(from) + R_from·pmi(from,fromPmi) − R_to·pmi(to,toPmi)
617	    //   reverse (cur = to):    pos(from) = pos(to)   + R_to·pmi(to,toPmi)       − R_from·pmi(from,fromPmi)
618	    // and R_to = R_from ∘ edge.rot (forward), R_from = R_to ∘ edge.rot⁻¹ (reverse). With every rot
619	    // identity this reduces exactly to the former translation-only solver.
620	    private computeLayout(g: Group): Map<string, Transform> {
621	        const out = new Map<string, Transform>();
622	        if (!g.members.length) return out;
623
624	        type Step = { neighbour: string; atPmi: string; dockPmi: string; reverse: boolean; rot: Quat };
625	        const adj = new Map<string, Step[]>();
626	        const link = (node: string, step: Step) => {
627	            if (!adj.has(node)) adj.set(node, []);
628	            adj.get(node)!.push(step);
629	        };
630	        for (const e of g.edges) {
631	            const rot = e.rot ?? IDENTITY_QUAT;
632	            link(e.from, { neighbour: e.to, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: false, rot });
633	            link(e.to, { neighbour: e.from, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: true, rot });
634	        }
635
636	        const origin = g.members[0];
637	        out.set(origin, { pos: [0, 0, 0], rot: IDENTITY_QUAT });
638	        const queue = [origin];
639	        while (queue.length) {
640	            const cur = queue.shift()!;
641	            const t = out.get(cur)!;
642	            for (const s of adj.get(cur) ?? []) {
643	                if (out.has(s.neighbour)) continue;
644	                // `at` is on the `from` side, `dock` on the `to` side.
645	                const fromRoot = s.reverse ? s.neighbour : cur;
646	                const toRoot = s.reverse ? cur : s.neighbour;
647	                const at = this.pmiLocal(fromRoot, s.atPmi);
648	                const dock = this.pmiLocal(toRoot, s.dockPmi);
649	                // Neighbour's accumulated rotation: compose edge.rot forward, its inverse on reverse.
650	                const rotN = s.reverse ? quatMul(t.rot, quatConj(s.rot)) : quatMul(t.rot, s.rot);
651	                const fromRot = s.reverse ? rotN : t.rot;
652	                const toRot = s.reverse ? t.rot : rotN;
653	                const a = quatRotate(fromRot, at);
654	                const d = quatRotate(toRot, dock);
655	                // pos(to) = pos(from) + R_from·at − R_to·dock. On reverse, `cur` IS `to`, so solve for
656	                // `from`: pos(from) = pos(to) + R_to·dock − R_from·at — same vector, opposite sign.
657	                const sign = s.reverse ? -1 : 1;
658	                out.set(s.neighbour, {
659	                    pos: [
660	                        t.pos[0] + sign * (a[0] - d[0]),
661	                        t.pos[1] + sign * (a[1] - d[1]),
662	                        t.pos[2] + sign * (a[2] - d[2]),
663	                    ],
664	                    rot: rotN,
665	                });
666	                queue.push(s.neighbour);
667	            }
668	        }
669	        return out;
670	    }
671
672	    // Connected components of `members` under `edges` (undirected), via BFS.
673	    private connectedComponents(members: string[], edges: Edge[]): string[][] {
674	        const adj = new Map<string, string[]>();
675	        for (const m of members) adj.set(m, []);
676	        for (const e of edges) {
677	            adj.get(e.from)?.push(e.to);
678	            adj.get(e.to)?.push(e.from);

    // Group-local rigid transforms (pos + rot) per member. A group is a tree (each merge joins two
    // disjoint parts with one edge), so transforms propagate from an arbitrary origin over the
    // UNDIRECTED edge graph — traversing an edge either way.
    //
    // Each edge fixes that `to.toPmi` coincides with `from.fromPmi` AND that `to` is rotated by
    // `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;
    }

    // Connected components of `members` under `edges` (undirected), via BFS.
    private connectedComponents(members: string[], edges: Edge[]): string[][] {
        const adj = new Map<string, string[]>();
        for (const m of members) adj.set(m, []);
        for (const e of edges) {
            adj.get(e.from)?.push(e.to);
            adj.get(e.to)?.push(e.from);


736	    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
737	        const handle = this.handles.get(rootHandleId);
738	        for (const comp of handle?.components ?? []) {
739	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
740	                return comp.properties.position.value;
741	            }
742	        }
743	        return [0, 0, 0];
744	    }
745
746	    // The docking point's frame orientation, from its `properties.rotation` (Euler radians, ZYX).
747	    // Most points carry no rotation (CS_LINKS/CS_RECHTS = [0,0,0] → identity); the post/branch points
748	    // (CS_EIN_*) carry the angle that turns a part onto them.
749	    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
750	        const handle = this.handles.get(rootHandleId);
751	        for (const comp of handle?.components ?? []) {
752	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
753	                const rot = comp.properties?.rotation?.value;
754	                return rot ? eulerZyxRadToQuat(rot as Vec3) : IDENTITY_QUAT;
755	            }
756	        }
757	        return IDENTITY_QUAT;
758	    }
759
760	    // Relative rotation of the join part w.r.t. the anchor part for a dock: aligning the join point's
761	    // frame onto the anchor point's turns the join part by R_anchorPmi ∘ R_joinPmi⁻¹. Identity when
762	    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
763	    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
764	        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
765	    }
766
767	    private rootTransform(handle: Handle): any {
768	        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
769	    }
770
771	    private roleOf(handle: Handle): NodeRole {
772	        for (const t of handle?.tags ?? []) {
773	            if (typeof t === "string" && t.startsWith("role-")) return t.slice(5) as NodeRole;
774	        }
775	        return ROLE.geometry;
776	    }
777
778	    // Index every materialised node by handle id → {parent handle id, role}, resolving each
779	    // transform's parentKey to the owning handle. The role resolver walks this.
780	    private buildNodeIndex(handles: Handle[]): void {

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

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

    // Index every materialised node by handle id → {parent handle id, role}, resolving each
    // transform's parentKey to the owning handle. The role resolver walks this.
    private buildNodeIndex(handles: Handle[]): void {


203	    occupiedPmis(): { element: string; pmi: string }[] {
204	        const out: { element: string; pmi: string }[] = [];
205	        for (const g of this.groups.values()) {
206	            for (const e of g.edges) {
207	                out.push({ element: e.from, pmi: e.fromPmi });
208	                out.push({ element: e.to, pmi: e.toPmi });
209	            }
210	        }
211	        return out;
212	    }
213
214	    // occupiedPmis plus the junction rule: where two elements meet straight (an edge on CS_OUT_*_0),
215	    // the two abutting posts share a branch slot per direction — only one 90 and one 270 may sit in
216	    // that gap. So a branch occupied on one post's CS_OUT_<side>_<90|270> also blocks the mirror
217	    // CS_OUT_<otherSide>_<same> on the neighbour. Both the placement drop points and the select
218	    // markers read this, so the rule is graph-constant across positioning and display.
219	    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
220	    // (the panel directions). All element-ends meeting at one post share it, so a build slot is open
221	    // only if its direction is free — i.e. it does not point into another element's body. This is
222	    // read by both the placement drop points and the select markers, so the rule is graph-constant.
223	    blockedPmis(): { element: string; pmi: string }[] {
224	        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
225	        const norm = (v: Vec3): Vec3 => {
226	            const l = Math.hypot(v[0], v[1], v[2]);
227	            return l < 1e-6 ? [0, 0, 0] : [v[0] / l, v[1] / l, v[2] / l];
228	        };
229	        const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
230	        const other = (end: string) => (end === "L" ? "R" : "L");
231	        for (const g of this.groups.values()) {
232	            const layout = this.computeLayout(g);
233	            // Cluster the element-ends that meet at one physical post (union-find over the edges).
234	            const parent = new Map<string, string>();
235	            const find = (x: string): string => {
236	                if (!parent.has(x)) parent.set(x, x);
237	                let r = parent.get(x)!;
238	                if (r !== x) { r = find(r); parent.set(x, r); }
239	                return r;
240	            };
241	            const union = (a: string, b: string) => parent.set(find(a), find(b));
242	            const node = (el: string, end: string) => `${el}|${end}`;
243	            for (const e of g.edges) {
244	                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
245	                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
246	                if (!fs || !ts) continue;
247	                union(node(e.from, fs), node(e.to, ts));
248	            }
249	            const clusters = new Map<string, { el: string; end: string }[]>();
250	            for (const key of parent.keys()) {
251	                const r = find(key);
252	                if (!clusters.has(r)) clusters.set(r, []);
253	                const [el, end] = key.split("|");
254	                clusters.get(r)!.push({ el, end });
255	            }
256	            // World direction (rotation only — one group) of `to − from`, both element-local points.
257	            const dirOf = (el: string, from: Vec3, to: Vec3): Vec3 => {

    occupiedPmis(): { element: string; pmi: string }[] {
        const out: { element: string; pmi: string }[] = [];
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                out.push({ element: e.from, pmi: e.fromPmi });
                out.push({ element: e.to, pmi: e.toPmi });
            }
        }
        return out;
    }

    // occupiedPmis plus the junction rule: where two elements meet straight (an edge on CS_OUT_*_0),
    // the two abutting posts share a branch slot per direction — only one 90 and one 270 may sit in
    // that gap. So a branch occupied on one post's CS_OUT_<side>_<90|270> also blocks the mirror
    // CS_OUT_<otherSide>_<same> on the neighbour. Both the placement drop points and the select
    // markers read this, so the rule is graph-constant across positioning and display.
    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
    // (the panel directions). All element-ends meeting at one post share it, so a build slot is open
    // only if its direction is free — i.e. it does not point into another element's body. This is
    // read by both the placement drop points and the select markers, so the rule is graph-constant.
    blockedPmis(): { element: string; pmi: string }[] {
        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
        const norm = (v: Vec3): Vec3 => {
            const l = Math.hypot(v[0], v[1], v[2]);
            return l < 1e-6 ? [0, 0, 0] : [v[0] / l, v[1] / l, v[2] / l];
        };
        const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
        const other = (end: string) => (end === "L" ? "R" : "L");
        for (const g of this.groups.values()) {
            const layout = this.computeLayout(g);
            // Cluster the element-ends that meet at one physical post (union-find over the edges).
            const parent = new Map<string, string>();
            const find = (x: string): string => {
                if (!parent.has(x)) parent.set(x, x);
                let r = parent.get(x)!;
                if (r !== x) { r = find(r); parent.set(x, r); }
                return r;
            };
            const union = (a: string, b: string) => parent.set(find(a), find(b));
            const node = (el: string, end: string) => `${el}|${end}`;
            for (const e of g.edges) {
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
                if (!fs || !ts) continue;
                union(node(e.from, fs), node(e.to, ts));
            }
            const clusters = new Map<string, { el: string; end: string }[]>();
            for (const key of parent.keys()) {
                const r = find(key);
                if (!clusters.has(r)) clusters.set(r, []);
                const [el, end] = key.split("|");
                clusters.get(r)!.push({ el, end });
            }
            // World direction (rotation only — one group) of `to − from`, both element-local points.
            const dirOf = (el: string, from: Vec3, to: Vec3): Vec3 => {


Now let me investigate the CPQ side ("reiner Einbau über PMIs") — how dockingRules are supplied and PMI catalog docs in robotunits.


grep -arn "dockingRules\|DockingRules" /Users/marcus.hinz/projects/adesso-cpq /Users/marcus.hinz/claude-projects/robotunits /Users/marcus.hinz/projects/geko-core /Users/marcus.hinz/projects/geko-fire --include="*.ts" --include="*.tsx" --include="*.vue" --include="*.js" 2>/dev/null | grep -v node_modules | grep -iv "\.test\."

Find dockingRules supply points


/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/index.ts:18:export type { DockingRules } from "./configurator/events/DefaultCommandProcessor";
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.stories.ts:5:import type { DockingRules } from "../../configurator/events/DefaultCommandProcessor";
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.stories.ts:24:            const dockingRules = ref<DockingRules | null>(null);
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.stories.ts:31:                dockingRules.value = rules;
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.stories.ts:33:            return { catalog, dockingRules };
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.stories.ts:37:                <ProductConfigurator v-if="dockingRules" :docking-rules="dockingRules">
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.vue:5:import { DefaultCommandProcessor, type DockingRules } from "../../configurator/events/DefaultCommandProcessor";
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.vue:10:    dockingRules?: DockingRules;
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.vue:17:const processor = new DefaultCommandProcessor(props.dockingRules);
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:11:export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:26:    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:27:    constructor(private readonly dockingRules?: DockingRules) {
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:611:        const rules = klass ? this.dockingRules?.[klass] : undefined;


ls /Users/marcus.hinz/claude-projects/robotunits/*.md 2>/dev/null; echo "---"; ls /Users/marcus.hinz/claude-projects/robotunits 2>/dev/null | head -40

List robotunits docs


/Users/marcus.hinz/claude-projects/robotunits/OFFENE-PUNKTE.md
/Users/marcus.hinz/claude-projects/robotunits/katalog-schutzzaun_basic-root-nomenklatur.md
---
GEKO-16.xml
GEKO-76-steher-cpq-geko.drawio
GEKO-76.xml
OFFENE-PUNKTE.md
ai-control.json
bin
doc
katalog
katalog-schutzzaun_basic-root-nomenklatur.md
memory


Let me read the story files and the CPQ-side PMI/docking-rule loading, plus check robotunits catalog tooling.


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

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

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

export default meta;

type Story = StoryObj<typeof ProductConfigurator>;

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


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

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

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

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

export default meta;

type Story = StoryObj<typeof Fire3D>;

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

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

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

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

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

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

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

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


Let me check the robotunits catalog tooling and how docking-rules / PMIs are generated on the CPQ side.


grep -arln "docking-rules\|dockingRules\|CS_EIN\|CS_OUT\|component_pmi\|PMI" /Users/marcus.hinz/claude-projects/robotunits/bin /Users/marcus.hinz/claude-projects/robotunits/katalog /Users/marcus.hinz/claude-projects/robotunits/doc 2>/dev/null | head -30; echo "===BIN==="; ls /Users/marcus.hinz/claude-projects/robotunits/bin 2>/dev/null; echo "===DOC==="; ls /Users/marcus.hinz/claude-projects/robotunits/doc 2>/dev/null

Search robotunits catalog/bin for PMI docking generation


ls /Users/marcus.hinz/claude-projects/robotunits/memory 2>/dev/null; echo "===katalog dirs==="; ls /Users/marcus.hinz/claude-projects/robotunits/katalog 2>/dev/null; echo "===search geko for einbau/pmi build==="; grep -arln -iE "CS_EIN|reiner? einbau|dreipunkt|docking-rules|command.?processor" /Users/marcus.hinz/projects/geko-core /Users/marcus.hinz/projects/geko-fire 2>/dev/null | grep -v node_modules | head -20

List robotunits memory/katalog and search geko


MEMORY.md
cpq-branching-mr-ziel.md
cpq-configurator-modul-registry.md
cpq-storybook-start.md
cpq-verbau-90grad-steher.md
cpq-verbau-architektur.md
feedback-git-nur-fast-forward.md
feedback-kein-nexttick.md
feedback-keine-intent-unterstellung.md
feedback-suche-nur-projektordner.md
fire-visual-placement-mode.md
wuerth-plato-backend-read-only.md
===katalog dirs===
rollenfoerderer
schutzzaun_allround
schutzzaun_basic
===search geko for einbau/pmi build===