# Offene Punkte — bei jedem Start prüfen und abhaken

- **Verbau: fehlende zweite Kante beim Loop-Schluss (mit Team klären).**
  Symptom: Im kleinen, geschlossenen Quadrat bleibt ein Gerade-Slot (`CS_OUT_*_0`) fälschlich
  offen; ein Bau darauf erzeugt ein überlappendes Duplikat.
  Ursache: `group-merge` kodiert genau eine Junction (eine Kante). Ein Snap, der zwei Posten
  gleichzeitig berührt (Schleifenschluss), erzeugt bei uns nur die eine Kante am Anker; die
  zweite Berührung wird nicht als Event/Kante erfasst. `blockedPmis` ist kanten-getrieben und
  sieht die Junction ohne Kante nicht.
  Kein Kollisionscode, kein geometrisches Nachmessen (das wäre nur Symptom eines
  unterspezifizierten Events).
  Der Snap liegt in FireVisual (nicht unser Code); wir bauen nur daneben. Vor Eigenbau erst
  mit dem Team klären, ob FireVisual die zweite Junction bereits liefert / es dort eine Lösung
  gibt.

jq -Rs '{systemMessage: ., hookSpecificOutput:{hookEventName:"SessionStart", additionalContext: .}}' /Users/marcus.hinz/claude-projects/robotunits/OFFENE-PUNKTE.md


# Offene Punkte — bei jedem Start prüfen und abhaken

- **Verbau: fehlende zweite Kante beim Loop-Schluss (mit Team klären).**
  Symptom: Im kleinen, geschlossenen Quadrat bleibt ein Gerade-Slot (`CS_OUT_*_0`) fälschlich
  offen; ein Bau darauf erzeugt ein überlappendes Duplikat.
  Ursache: `group-merge` kodiert genau eine Junction (eine Kante). Ein Snap, der zwei Posten
  gleichzeitig berührt (Schleifenschluss), erzeugt bei uns nur die eine Kante am Anker; die
  zweite Berührung wird nicht als Event/Kante erfasst. `blockedPmis` ist kanten-getrieben und
  sieht die Junction ohne Kante nicht.
  Kein Kollisionscode, kein geometrisches Nachmessen (das wäre nur Symptom eines
  unterspezifizierten Events).
  Der Snap liegt in FireVisual (nicht unser Code); wir bauen nur daneben. Vor Eigenbau erst
  mit dem Team klären, ob FireVisual die zweite Junction bereits liefert / es dort eine Lösung
  gibt.


ok --
TODO liste leeren
Nichts zu tun - wir können das Problem nicht deterministisch löen


CronCreate CronDelete CronList DesignSync EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Monitor NotebookEdit PushNotification RemoteTrigger SendMessage TaskCreate TaskGet TaskList TaskOutput TaskStop TaskUpdate WebFetch WebSearch


claude claude-code-guide Explore general-purpose Plan statusline-setup

- claude: Catch-all for any task that doesn't fit a more specific agent. FleetView's default when no agent name is typed. (Tools: *)

- claude-code-guide: Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - Messages API for directly passing messages to Claude, Tool Runner (`client.beta.messages.tool_runner`) for running an agentic loop over your own tools, manual tool-use loops, Managed Agents for server-hosted agents with a managed sandbox, prompt caching, and general Anthropic SDK usage; (4) Claude Tag (Claude in Slack) - what it is, setting it up for a Slack workspace, `/install-slack-app`. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage. (Tools: Bash, Read, WebFetch, WebSearch)

- Explore: Read-only search agent for broad fan-out searches — when answering means sweeping many files, directories, or naming conventions and you only need the conclusion, not the file dumps. It reads excerpts rather than whole files, so it locates code; it doesn't review or audit it. Specify search breadth: "medium" for moderate exploration, "very thorough" for multiple locations and naming conventions. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)

- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)


- 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


1	# Offene Punkte — bei jedem Start prüfen und abhaken
2
3	- **Verbau: fehlende zweite Kante beim Loop-Schluss (mit Team klären).**
4	  Symptom: Im kleinen, geschlossenen Quadrat bleibt ein Gerade-Slot (`CS_OUT_*_0`) fälschlich
5	  offen; ein Bau darauf erzeugt ein überlappendes Duplikat.
6	  Ursache: `group-merge` kodiert genau eine Junction (eine Kante). Ein Snap, der zwei Posten
7	  gleichzeitig berührt (Schleifenschluss), erzeugt bei uns nur die eine Kante am Anker; die
8	  zweite Berührung wird nicht als Event/Kante erfasst. `blockedPmis` ist kanten-getrieben und
9	  sieht die Junction ohne Kante nicht.
10	  Kein Kollisionscode, kein geometrisches Nachmessen (das wäre nur Symptom eines
11	  unterspezifizierten Events).
12	  Der Snap liegt in FireVisual (nicht unser Code); wir bauen nur daneben. Vor Eigenbau erst
13	  mit dem Team klären, ob FireVisual die zweite Junction bereits liefert / es dort eine Lösung
14	  gibt.
15


Write

# Offene Punkte — bei jedem Start prüfen und abhaken


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

-- **Verbau: fehlende zweite Kante beim Loop-Schluss (mit Team klären).**

-  Symptom: Im kleinen, geschlossenen Quadrat bleibt ein Gerade-Slot (`CS_OUT_*_0`) fälschlich

-  offen; ein Bau darauf erzeugt ein überlappendes Duplikat.

-  Ursache: `group-merge` kodiert genau eine Junction (eine Kante). Ein Snap, der zwei Posten

-  gleichzeitig berührt (Schleifenschluss), erzeugt bei uns nur die eine Kante am Anker; die

-  zweite Berührung wird nicht als Event/Kante erfasst. `blockedPmis` ist kanten-getrieben und

-  Kein Kollisionscode, kein geometrisches Nachmessen (das wäre nur Symptom eines

-  Der Snap liegt in FireVisual (nicht unser Code); wir bauen nur daneben. Vor Eigenbau erst

-  mit dem Team klären, ob FireVisual die zweite Junction bereits liefert / es dort eine Lösung

# Offene Punkte — bei jedem Start prüfen und abhaken

- **Verbau: fehlende zweite Kante beim Loop-Schluss (mit Team klären).**
  Symptom: Im kleinen, geschlossenen Quadrat bleibt ein Gerade-Slot (`CS_OUT_*_0`) fälschlich
  offen; ein Bau darauf erzeugt ein überlappendes Duplikat.
  Ursache: `group-merge` kodiert genau eine Junction (eine Kante). Ein Snap, der zwei Posten
  gleichzeitig berührt (Schleifenschluss), erzeugt bei uns nur die eine Kante am Anker; die
  zweite Berührung wird nicht als Event/Kante erfasst. `blockedPmis` ist kanten-getrieben und
  sieht die Junction ohne Kante nicht.
  Kein Kollisionscode, kein geometrisches Nachmessen (das wäre nur Symptom eines
  unterspezifizierten Events).
  Der Snap liegt in FireVisual (nicht unser Code); wir bauen nur daneben. Vor Eigenbau erst
  mit dem Team klären, ob FireVisual die zweite Junction bereits liefert / es dort eine Lösung
  gibt.


OFFENE-PUNKTE.md geleert.


gut
next
diese dreipunkt logik ist eigentlich ein Geko feature - keines von cpq - cpq kann nur den reinen Einbua über PMIs
Ich würde diese Trennung gern im Storybook schon mal vorwegnehmen
köönen wir

- die Dreipunkte Logik inkl. Anbau auslagern
- dem Defaultcommandprozessor eine Metode geben, mit der die Logik registriert werden kann?

Planen / Vorschläge?


Zum Planen muss ich den relevanten Code sehen — DefaultCommandProcessor und die Dreipunkt-Logik. Ich suche gezielt.


Agent

Find DefaultCommandProcessor and Dreipunkt logic

Prompt:
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.


Async agent launched successfully. (This tool result is internal metadata — never quote or paste any part of it, including the agentId below, into a user-facing reply.)
agentId: a9150e5ca1cbb7821 (internal ID - do not mention to user. Use SendMessage with to: 'a9150e5ca1cbb7821', summary: '<5-10 word recap>' to continue this agent.)
The agent is working in the background. You will be notified automatically when it completes.
Do not duplicate this agent's work — avoid working with the same files or topics it is using.
output_file: /private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/a37553af-b32b-4577-a395-bb0adfdab8ca/tasks/a9150e5ca1cbb7821.output
Do NOT Read or tail this file via the shell tool — it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification.

Find DefaultCommandProcessor and Dreipunkt logic

Prompt:
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.


gut next diese dreipunkt logik ist eigentlich ein Geko feature - keines von cpq - cpq kann nur den reinen Einbua über PMIs Ich würde diese Trennung gern im Storybook schon mal vorwegnehmen köönen wir…


Der Explore-Agent läuft. Ich warte auf das Ergebnis.


ToolSearch

Question: select:Monitor


Question: select:Monitor


Ich warte auf den Explore-Agent; die Benachrichtigung kommt automatisch.


Gut - eine Änderung
nach dem Anbau soll das neu angebaute Teil selektiert werden


Notiert: nach dem Anbau wird das neu angebaute Teil selektiert — kommt in den Plan. Ich warte weiter auf den Explore-Agent.


<task-notification>
<task-id>a9150e5ca1cbb7821</task-id>
<tool-use-id>toolu_01CX3npmbeb2x6YfUFMGqKqV</tool-use-id>
<output-file>/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/a37553af-b32b-4577-a395-bb0adfdab8ca/tasks/a9150e5ca1cbb7821.output</output-file>
<status>completed</status>
<summary>Agent "Find DefaultCommandProcessor and Dreipunkt logic" finished</summary>
<note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note>
<result>Ich habe alle vier Punkte gefunden. Hier die strukturierten Fakten zur Codestruktur.

---

## 1. DefaultCommandProcessor

**Datei:** `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts` (659 Zeilen)
Basisklasse/Interface: `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/CommandProcessor.ts` (Z. 3-11) — nur zwei überschreibbare Methoden `setInitialScene(scene)` und `handleEvent(event): UIReaction[]`.

**Klasse** (Z. 13-29):
```ts
export class DefaultCommandProcessor extends CommandProcessor {
    private eventStore = new EventStore();
    private groups = new GroupProcessor();
    private selection: { pickedId: string; level: number }[] = [];
    private pendingRegroup: GroupEvent | null = null;
    constructor(private readonly dockingRules?: DockingRules) { super(); }
```

**Zentrales Dispatch** ist ein `switch` über `event.eventTypeName` in `handleEvent` (Z. 43-74), das an private `handle*`-Methoden delegiert. Event-Typen: `interaction-mode`, `add-product`, `keyboard-build`, `detach-element`, `escalate-selection`, `prepare-action`, `undo`, `redo`, `esc`, `context-click`, `selection-click`, `selection`.

**Kern-Engpass** `private replay()` (Z. 35-41): ruft `groups.replay(initialState, events)` — laut Memory der geplante Hook-Punkt für Module.

**Registrierungs-Mechanismus:** Aktuell **keiner**. Es gibt kein `register`/`use`/`addHandler`. Erweiterung läuft heute nur über zwei Wege: (a) das feste `switch`, (b) den Konstruktor-Parameter `dockingRules` (reine Daten-Injektion). Der geplante Registry-Ansatz ist bisher nur konzeptionell dokumentiert (siehe Punkt 3 / Memory `cpq-configurator-modul-registry.md`), nicht im Code.

**Tests:** `DefaultCommandProcessor.test.ts` (gleicher Ordner).

---

## 2. Dreipunkt-/Placement-Logik

Diese Logik ist **verteilt auf drei Ebenen**:

**a) Fremdcode (`@ad/fire`, nicht editieren):** Der eigentliche Snap-to-Point-Algorithmus `processInput` und die `PmiDropPoint`-Klassen liegen im Drittanbieter-Paket, gebaut nach `/Users/marcus.hinz/projects/geko-fire/browser/index.js`. Vollständige Beschreibung in Memory `/Users/marcus.hinz/claude-projects/robotunits/memory/fire-visual-placement-mode.md` (Placement-Mode, Drag-/Drop-Points, `pmiDropPoint`, Command `start_placement_mode`).

**b) CPQ-Steuerung im DefaultCommandProcessor** (eingebettet, nicht separat):
- `handleInteractionMode` (Z. 307-321) mit `case "placement": return this.handlePlacementEnd(event)`
- `handlePlacementEnd` (Z. 408-422)
- `placementEvent` (Z. 457-488): erzeugt bei Snap einen `group-merge` (Dreipunkt: anchorPmi ← dropPoint, joinPmi ← dragPoint, plus `rot` aus `dockRotation`), sonst `group-move`
- `handleDetachElement` (Z. 426-454): löst Teil aus Gruppe, startet `start_placement_mode`
- `placementDragPoints` (Z. 609-636): baut Drag-/Drop-Selektoren (`.name-CS_EIN_*` / `#<id> .name-<dp>`) aus den `dockingRules`
- Zusätzlicher „Anbau"-Weg per Klick auf Select-Marker: `handleSelectionClickEvent` (Z. 157-187) und `handleMarkerBuild` (Z. 193-226) — dockt eine Kopie an `CS_OUT_*`/`CS_EIN_*`; sowie Prototyp `handleKeyboardBuild` (Z. 108-155)
- Datenextraktion in `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/eventData.ts`: `extractPlacementData` (Z. 16-31, liest `dragPointSelector`/`dropPointSelector`, `lastHandleId`/`lastPmiId`).

**c) Geometrie/Dreipunkt-Mathematik im GroupProcessor:** `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.ts`
- `dockRotation(anchorRoot, anchorPmi, joinRoot, joinPmi)` (Z. 763-765) und `pmiRotation`/`pmiLocal` (Z. 736-758)
- `computeLayout` (Z. 620-670): der eigentliche Rigid-Transform-Solver über den Kanten-Graph (Punkt-Koinzidenz + Relativrotation)
- `apply`/`case "group-merge"` (Z. 520-542): fügt die Edge (from/to/fromPmi/toPmi/rot) ein
- `occupiedPmis`/`blockedPmis` (Z. 203-255): welche PMIs belegt/gesperrt sind (Junction-Regel für 90°/270°-Steher).

Der Begriff „Dreipunkt/three-point" taucht als String **nicht** im Code auf; die Logik firmiert als „placement / snap / dock / merge".

---

## 3. PMI ↔ reiner Einbau (CPQ-Seite)

**dockingRules** ist der Vertrag zwischen domänen-agnostischem CPQ und dem geko-Client:
- Typdefinition: `DockingRules = Record<string, Record<string, Record<string, string[]>>>` in `DefaultCommandProcessor.ts` Z. 8-11 (movingClass → targetClass → dragPoint → dropPoints[]); Re-Export in `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/index.ts` Z. 18.
- Konsumiert nur in `placementDragPoints` (Z. 609-636).
- Injektion über Prop: `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.vue` (Z. 5-17: `new DefaultCommandProcessor(props.dockingRules)`).

**„Reiner Einbau über PMIs"** = die geregelte Feldverkettung über die `CS_EIN_*`/`CS_OUT_*`-Punkte. Diese ist nicht als eigener CPQ-Codepfad implementiert, sondern ergibt sich aus (a) den Docking-Regeln (Daten) und (b) der PMI-Geometrie im Katalog. Dokumentiert/spezifiziert in:
- `/Users/marcus.hinz/claude-projects/robotunits/katalog-schutzzaun_basic-root-nomenklatur.md` — PMI-Nomenklatur `CS_BASE/EIN/OUT/SELECT`, Semantik L/R/START/_90, und der Befund, dass der „geregelte Einbau nach rechts" wegen versetzter R-Punkt-Koordinaten kaputt ist (Z. 26-71).
- Katalog-Rohdaten (PMI-Definitionen `component_pmi`): `/Users/marcus.hinz/claude-projects/robotunits/katalog/{schutzzaun_basic,schutzzaun_allround,rollenfoerderer}/*.json`.
- Architektur-/Abgrenzungskonzept (CPQ vs. geko, PMI-Verbindungen, geplante Modul-Registry als Ort für „reinen Einbau"): Memories `/Users/marcus.hinz/claude-projects/robotunits/memory/cpq-verbau-architektur.md`, `cpq-verbau-90grad-steher.md`, `cpq-configurator-modul-registry.md`.

Wichtig laut Memory `cpq-configurator-modul-registry.md` (Z. 12-16): Die hartkodierten `.name-CS_EIN_*`-Selektoren im DefaultCommandProcessor sollen in eine Modul-Placement-Konfiguration ausgelagert werden; Selbstheilung/Ableitung soll nie in den EventStore, manuelle Overrides schon (als undo-bare Modul-Events). Das ist die Refactoring-Stoßrichtung — aktuell nur konzeptionell.

---

## 4. Storybook-Stories mit CommandProcessor / Placement

Zwei relevante Story-Dateien (beide `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/`):

- **`Fire3d.stories.ts`** — importiert und instanziiert direkt `new DefaultCommandProcessor()` (Z. 4, 22, 40). Zwei Stories: `Default` (Z. 18-34, lädt Szene `SBS1044PLA.json`) und `MultipleZaun` (Z. 36-70, `add-product`-Dispatch). Kein Docking (kein dockingRules-Arg).

- **`ProductConfigurator.stories.ts`** — importiert Typ `DockingRules` (Z. 5), lädt `catalog.json` + `docking-rules.json` per fetch (Z. 22-31) und reicht sie als Prop `:docking-rules` an `ProductConfigurator.vue` (das intern den Processor baut). Story `Configurator` (Z. 17-50). Das ist die Story, die die vollständige Placement/Docking-Kette abbildet.

Weitere `.stories.*` im Projekt betreffen nur UI-Basiskomponenten (BaseButton, Inputs, Dialoge etc.) und sind für den CommandProcessor/Placement irrelevant.</result>
<usage><subagent_tokens>53469</subagent_tokens><tool_uses>34</tool_uses><duration_ms>203256</duration_ms></usage>
</task-notification>


Ich lese die Schlüsseldateien selbst, um den Plan konkret zu machen.


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	import type { UIReaction, MenuEntry } from "../types";
2	import { CommandProcessor } from "./CommandProcessor";
3	import { EventStore } from "./EventStore";
4	import { GroupProcessor, ROLE, type GroupEvent } from "../model/GroupProcessor";
5	import { extractMoveData, extractRotateData, extractPlacementData } from "./eventData";
6	import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";
7
8	// Client-supplied placement rules, keyed by the moving part's docking class:
9	//   { movingClass: { targetClass: { dragPointName: dropPointName[] } } }
10	// Classes match the `class-…` tags on part root handles (e.g. "schutzzaun_allround-steher").
11	export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;
12
13	export class DefaultCommandProcessor extends CommandProcessor {
14	    private eventStore = new EventStore();
15	    private groups = new GroupProcessor();
16	    private freeCursorX = 0;
17	    // Current selection as a set of picks. Each pick is a picked geometry plus its escalation level
18	    // (part → assembly → …). A plain click sets a single pick (re-clicking the same one escalates);
19	    // an additive click (multi-select) adds another pick, or toggles it off if already present.
20	    private selection: { pickedId: string; level: number }[] = [];
21	    // A regroup recorded when a move/rotate starts but not yet committed: it is applied during the
22	    // interaction (so the engine sees the movable groups) and pushed together with the move/rotate as
23	    // one undo step on completion — or discarded on cancel. Keeps "break group to move" out of undo.
24	    private pendingRegroup: GroupEvent | null = null;
25
26	    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
27	    constructor(private readonly dockingRules?: DockingRules) {
28	        super();
29	    }
30
31	    setInitialScene(scene: any): void {
32	        this.eventStore.setInitialState(scene);
33	    }
34
35	    private replay(): any {
36	        const events = this.eventStore.getEventsToReplay();
37	        return this.groups.replay(
38	            this.eventStore.getInitialState(),
39	            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
40	        );
41	    }
42
43	    handleEvent(event: any): UIReaction[] {
44	        switch (event?.eventTypeName) {
45	            case "interaction-mode":
46	                if (event.lifecycleState !== "update") return [];
47	                return this.handleInteractionMode(event);
48	            case "add-product":
49	                return this.handleAddProduct(event);
50	            case "keyboard-build":
51	                return this.handleKeyboardBuild(event);
52	            case "detach-element":
53	                return this.handleDetachElement(event);
54	            case "escalate-selection":
55	                return this.handleEscalateSelection(event);
56	            case "prepare-action":
57	                return this.handlePrepareAction(event);
58	            case "undo":
59	                return this.handleUndo();
60	            case "redo":
61	                return this.handleRedo();
62	            case "esc":
63	                return this.handleEsc();
64	            case "context-click":
65	                return this.getContextForSelection(event);
66	            case "selection-click":
67	                return this.handleSelectionClickEvent(event);
68	            case "selection":
69	                return [];
70	            default:
71	                console.error("unknown event type ", event?.eventTypeName);
72	                return [];
73	        }
74	    }
75
76	    private handleEsc(): UIReaction[] {
77	        // Cancel a prepared-but-uncommitted regroup and resync the engine to the real model.
78	        if (this.pendingRegroup) {
79	            this.pendingRegroup = null;
80	            const scene = this.replay();
81	            return [
82	                { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
83	                { type: "sendCommand", cmd: "setScene", args: [scene] },
84	            ];
85	        }
86	        return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
87	    }
88
89	    private handleAddProduct(event: any): UIReaction[] {
90	        if (!event.handles?.length) return [];
91	        // A new product enters as a free group of one, offset along X by the running cursor so
92	        // products land adjacent and in view instead of overlapping.
93	        const position: Vec3 = [this.freeCursorX, 0, 0];
94	        this.freeCursorX += this.productExtent(event.handles);
95	        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
96	        this.eventStore.push(createEvent);
97	        const scene = this.replay();
98	        return [
99	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
100	            { type: "sendCommand", cmd: "setScene", args: [scene] },
101	        ];
102	    }
103
104	    // PROTOTYPE — keyboard build shortcut ("+" held + arrow), fed from Fire3D. Works off the last
105	    // active selection taken from the store. right/left dock a COPY of the active part onto its open
106	    // CS_OUT_R_0 / CS_OUT_L_0 end (only if that end is still free); up/down nudge the active group
107	    // along Y. Docking PMIs are hardcoded for now — this is a thinking prop, not the final wiring.
108	    private handleKeyboardBuild(event: any): UIReaction[] {
109	        this.replay();
110	        const active = this.selection[this.selection.length - 1]?.pickedId;
111	        const activeRoot = active ? this.groups.elementRootOf(active) : null;
112	        const groupId = activeRoot ? this.groups.groupIdOfElement(activeRoot) : null;
113	        if (!active || !activeRoot || !groupId) return [];
114
115	        if (event.direction === "up" || event.direction === "down") {
116	            const t = this.groups.transformOf(groupId);
117	            if (!t) return [];
118	            const step = event.direction === "up" ? 100 : -100;
119	            this.eventStore.push({
120	                type: "group-move",
121	                timestamp: Date.now(),
122	                groupId,
123	                position: [t.pos[0], t.pos[1] + step, t.pos[2]],
124	                rotation: t.rot,
125	            } as GroupEvent);
126	            return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
127	        }
128
129	        // right / left: dock a copy of the active part onto its open end
130	        const anchorPmi = event.direction === "right" ? "CS_OUT_R_0" : "CS_OUT_L_0";
131	        const joinPmi = event.direction === "right" ? "CS_EIN_L" : "CS_EIN_R";
132	        if (this.groups.occupiedPmis().some((o) => o.element === activeRoot && o.pmi === anchorPmi)) {
133	            return []; // no room at the active part's chosen end
134	        }
135	        const scene = this.groups.sceneOfElement(activeRoot);
136	        if (!scene) return [];
137	        const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);
138	        this.eventStore.push(createEvent);
139	        this.eventStore.push({
140	            type: "group-merge",
141	            timestamp: Date.now(),
142	            targetGroupId: groupId,
143	            movingGroupId: createEvent.groupId,
144	            anchorRootId: activeRoot,
145	            joinRootId: createEvent.rootHandleId,
146	            anchorPmiId: anchorPmi,
147	            joinPmiId: joinPmi,
148	            rot: IDENTITY_QUAT,
149	        } as GroupEvent);
150	        // Chain: move the active selection onto the fresh copy so the next Shift+arrow docks onto it.
151	        this.selection = [{ pickedId: createEvent.rootHandleId, level: 0 }];
152	        return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
153	    }
154	    // Default click selects the single element; re-clicking the same spot escalates up the role
155	    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
156	    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
157	    private handleSelectionClickEvent(event: any): UIReaction[] {
158	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
159	        // Intercept a click on one of our select-PMI marker spheres — dock a copy of the element there.
160	        const targetId: unknown = event.target?.id;
161	        if (typeof targetId === "string" && targetId.startsWith("sel-marker-")) {
162	            const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
163	            const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
164	            return this.handleMarkerBuild(element, pmi, stop);
165	        }
166	        const additive = event.selectionAction === "add";
167	        if (!event.target) {
168	            if (!additive) this.selection = [];
169	            return this.selectionReactions(stop);
170	        }
171	        this.replay();
172	        const pickedId = event.target.id;
173	        const chain = this.groups.selectableAncestors(pickedId);
174	        if (!chain.length) return [stop];
175
176	        if (additive) {
177	            const present = this.selection.some((p) => p.pickedId === pickedId);
178	            this.selection = present
179	                ? this.selection.filter((p) => p.pickedId !== pickedId)
180	                : [...this.selection, { pickedId, level: 0 }];
181	        } else {
182	            const sole = this.selection.length === 1 ? this.selection[0] : null;
183	            const level = sole && sole.pickedId === pickedId ? Math.min(sole.level + 1, chain.length - 1) : 0;
184	            this.selection = [{ pickedId, level }];
185	        }
186	        return this.selectionReactions(stop);
187	    }
188
189	    // A clicked select marker (CS_SELECT_<side>_<angle>) docks a copy of its element onto the matching
190	    // open end: the anchor is the corresponding CS_OUT point, the copy joins with CS_EIN_L (right side)
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[] {
194	        this.replay();
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
197	        // in the direction the marker sits (0 = straight, 90 = front, 270 = back).
198	        const anchorPmi = pmi.replace("CS_SELECT_", "CS_OUT_");
199	        const joinPmi = pmi.includes("_R_") ? "CS_EIN_L" : "CS_EIN_R";
200	        const groupId = this.groups.groupIdOfElement(element);
201	        const scene = this.groups.sceneOfElement(element);
202	        if (!groupId || !scene) return [stop];
203	        if (this.groups.occupiedPmis().some((o) => o.element === element && o.pmi === anchorPmi)) {
204	            return [stop]; // that end is already taken
205	        }
206	        const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);
207	        this.eventStore.push(createEvent);
208	        this.eventStore.push({
209	            type: "group-merge",
210	            timestamp: Date.now(),
211	            targetGroupId: groupId,
212	            movingGroupId: createEvent.groupId,
213	            anchorRootId: element,
214	            joinRootId: createEvent.rootHandleId,
215	            anchorPmiId: anchorPmi,
216	            joinPmiId: joinPmi,
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
219	            // back to IDENTITY and tilt the copy. Reading both frames off the original is exact.
220	            rot: this.groups.dockRotation(element, anchorPmi, element, joinPmi),
221	        } as GroupEvent);
222	        // Drop the selection so the markers disappear once the copy is attached.
223	        this.selection = [];
224	        return this.selectionReactions(stop);
225	    }
226
227	    // Right-click escalation: only meaningful for a single selected pick; bump it one level.
228	    private handleEscalateSelection(event: any): UIReaction[] {
229	        this.replay();
230	        if (this.selection.length === 1 && this.selection[0].pickedId === event.pickedId) {
231	            const chain = this.groups.selectableAncestors(event.pickedId);
232	            this.selection[0].level = Math.min(this.selection[0].level + 1, chain.length - 1);
233	        }
234	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
235	        return this.selectionReactions(stop).slice(1); // no stop needed here, just the selection
236	    }
237
238	    // The node ids currently selected (each pick resolved to its escalated node).
239	    private selectedNodeIds(): string[] {
240	        return this.selection
241	            .map((p) => {
242	                const chain = this.groups.selectableAncestors(p.pickedId);
243	                return chain[Math.min(p.level, chain.length - 1)];
244	            })
245	            .filter((id): id is string => !!id);
246	    }
247
248	    private selectionReactions(stop: UIReaction): UIReaction[] {
249	        const scene = this.withSelectionMarkers(this.replay());
250	        const selectors = this.selectedNodeIds().map((id) => "#" + id);
251	        return [
252	            stop,
253	            { type: "sendCommand", cmd: "setScene", args: [scene] },
254	            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
255	        ];
256	    }
257
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
260	    // parametric sphere + basic material, with a box collider so it's clickable (this build has no
261	    // sphere collider). Tagged `name-select-marker`. No selection → scene unchanged.
262	    private withSelectionMarkers(scene: any): any {
263	        const roots = new Set(this.selectionElementRoots());
264	        if (!roots.size) return scene;
265	        const occupied = new Set(this.groups.blockedPmis().map((o) => `${o.element} ${o.pmi}`));
266	        const markers: any[] = [];
267	        for (const h of scene.handles ?? []) {
268	            if (!roots.has(h.id)) continue;
269	            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
270	            if (!tr?.key) continue;
271	            for (const c of h.components ?? []) {
272	                if (c._resourceClass !== "component_pmi") continue;
273	                // Only the select PMIs (CS_SELECT_<side>_<angle>) get a marker, not the base points.
274	                if (!String(c.id).startsWith("CS_SELECT")) continue;
275	                // Show only where a copy can dock: the matching CS_OUT slot must be open per the post
276	                // rule (blockedPmis already folds in occupancy and shared-post directions).
277	                const anchorPmi = String(c.id).replace("CS_SELECT_", "CS_OUT_");
278	                if (occupied.has(`${h.id} ${anchorPmi}`)) continue;
279	                const pos = c.properties?.position?.value ?? [0, 0, 0];
280	                markers.push({
281	                    id: `sel-marker-${h.id}-${c.id}`,
282	                    tags: ["name-select-marker"],
283	                    components: [
284	                        { _resourceClass: "component_transform", parentKey: tr.key, translation: pos, rotation: [0, 0, 0], scale: [1, 1, 1] },
285	                        // A real mesh sphere via the built-in parametric factory (key "sphere-3d", param
286	                        // `diameter`) — the same component_renderer path the config's arrow gizmo uses, so it
287	                        // renders in this build and yields proper localBounds (no getBounds crash on select).
288	                        {
289	                            _resourceClass: "component_renderer",
290	                            castShadow: false,
291	                            receiveShadow: false,
292	                            geometry: { _resourceClass: "geometry_parametric", parameters: { factoryName: "sphere-3d", diameter: 120 } },
293	                            material: { _resourceClass: "material_basic", diffuse: [0, 1, 0], depthTest: true },
294	                        },
295	                        // Box collider (shapeType 4) around the PMI — this @ad/fire build registers only
296	                        // box/capsule/mesh shape descriptors (no sphere), so shapeType 2 has no descriptor and
297	                        // crashes selection-bounds. triggersSelection defaults true → the raycast hits it and
298	                        // reports the marker as the click target. The visible marker stays the round mesh above.
299	                        { _resourceClass: "component_collider", type: 2, shapeType: 4, center: [0, 0, 0], dimension: [80, 80, 80] },
300	                    ],
301	                });
302	            }
303	        }
304	        return { ...scene, handles: [...(scene.handles ?? []), ...markers] };
305	    }
306
307	    private handleInteractionMode(event: any): UIReaction[] {
308	        switch (event.modeName) {
309	            case "rotation":
310	                return this.handleRotateEnd(event);
311	            case "movement":
312	                return this.handleMoveEnd(event);
313	            case "placement":
314	                return this.handlePlacementEnd(event);
315	            default:
316	                console.error(`Unknown interaction mode: ${event.modeName}`);
317	                return [];
318	        }
319	    }
320
321	    // Move/rotate act on group nodes — the engine moves every member natively. All moved subjects are
322	    // recorded, together with the pending regroup (if any), as one undo step.
323	    private handleMoveEnd(event: any): UIReaction[] {
324	        const moves = extractMoveData(event);
325	        if (!moves.length) return [];
326	        this.replay();
327	        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
328	        for (const m of moves) {
329	            const groupId = this.resolveGroupId(m.handleId);
330	            if (!groupId) continue;
331	            group.push({
332	                type: "group-move",
333	                timestamp: Date.now(),
334	                groupId,
335	                position: m.position,
336	                rotation: this.groups.transformOf(groupId)?.rot ?? IDENTITY_QUAT,
337	            });
338	        }
339	        return this.commitInteraction(group);
340	    }
341
342	    private handleRotateEnd(event: any): UIReaction[] {
343	        const rotations = extractRotateData(event);
344	        if (!rotations.length) return [];
345	        this.replay();
346	        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
347	        for (const r of rotations) {
348	            const groupId = this.resolveGroupId(r.handleId);
349	            if (!groupId) continue;
350	            group.push({
351	                type: "group-rotate",
352	                timestamp: Date.now(),
353	                groupId,
354	                position: r.position,
355	                rotation: r.rotation,
356	            });
357	        }
358	        return this.commitInteraction(group);
359	    }
360
361	    // Push the interaction (regroup + per-subject transforms) as one undo step and clear the pending.
362	    private commitInteraction(group: GroupEvent[]): UIReaction[] {
363	        this.pendingRegroup = null;
364	        if (group.length) this.eventStore.pushGroup(group);
365	        const scene = this.replay();
366	        return [{ type: "sendCommand", cmd: "setScene", args: [scene] }];
367	    }
368
369	    // Start move/rotate strictly on the current selection: consolidate it into movable groups (break a
370	    // group only if a partial selection requires it), keep that regroup pending, select the resulting
371	    // group nodes and start the engine mode. Already-whole-group selections move without regrouping.
372	    private handlePrepareAction(event: any): UIReaction[] {
373	        this.pendingRegroup = null;
374	        this.replay();
375	        const elementRoots = this.selectionElementRoots();
376	        if (!elementRoots.length) return [];
377
378	        const { event: regroup, moveGroupIds } = this.groups.buildRegroupEvent(elementRoots);
379	        this.pendingRegroup = regroup;
380	        const scene = this.replay();
381
382	        const selectors = moveGroupIds
383	            .map((id) => this.groups.groupKeyOf(id))
384	            .filter((k): k is string => !!k)
385	            .map((k) => "#" + k);
386	        const startCmd = event.mode === "rotate" ? "start_rotate_mode" : "start_move_mode";
387	        return [
388	            { type: "sendCommand", cmd: "setScene", args: [scene] },
389	            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
390	            { type: "sendCommand", cmd: startCmd, args: [] },
391	        ];
392	    }
393
394	    // The element roots covered by the current selection: a selected part is itself, a selected group
395	    // node expands to all its members.
396	    private selectionElementRoots(): string[] {
397	        const roots = new Set<string>();
398	        for (const node of this.selectedNodeIds()) {
399	            if (this.groups.roleOfId(node) === ROLE.assembly) {
400	                for (const m of this.groups.membersOfKey(node)) roots.add(m);
401	            } else {
402	                roots.add(node);
403	            }
404	        }
405	        return [...roots];
406	    }
407
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] },
508	            { type: "notification", message: "Action reapplied" },
509	        ];
510	    }
511
512	    private getContextForSelection(event: any): UIReaction[] {
513	        if (!event.pointed?.length) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
514	        this.replay();
515	        const pickedId = event.pointed[0].entity.id;
516	        const groupSelector = this.groupSelectorOf(pickedId);
517	        if (!groupSelector) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
518
519	        // The single picked element (for "re-place this element", as opposed to the whole group).
520	        const elementRoot = this.groups.elementRootOf(pickedId);
521
522	        // Right-click on something already in the selection keeps the whole set (so it doesn't drop a
523	        // multi-select or an escalation); a fresh target selects just that element. The escalate entry
524	        // only shows for a single-pick selection that can still go higher.
525	        const chain = this.groups.selectableAncestors(pickedId);
526	        if (!this.selection.some((p) => p.pickedId === pickedId)) this.selection = [{ pickedId, level: 0 }];
527	        const level = this.selection.find((p) => p.pickedId === pickedId)?.level ?? 0;
528	        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
529	        const canEscalate = this.selection.length === 1 && level < chain.length - 1;
530	        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode
531	        // dereferences dragPoints[0] and would crash on an empty array).
532	        const dragPoints = this.placementDragPoints(groupSelector);
533
534	        return [
535	            { type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },
536	            {
537	                type: "contextMenu",
538	                entries: [
539	                    ...(canEscalate
540	                        ? ([
541	                              {
542	                                  label: "Übergeordnete Gruppe wählen",
543	                                  reactions: [{ type: "dispatchEvent", event: { eventTypeName: "escalate-selection", pickedId } }],
544	                              },
545	                          ] as MenuEntry[])
546	                        : []),
547	                    {
548	                        label: "Move",
549	                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "move" } }],
550	                    },
551	                    {
552	                        label: "Rotate",
553	                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "rotate" } }],
554	                    },
555	                    ...(dragPoints.length > 0
556	                        ? ([
557	                              {
558	                                  label: "Platzieren",
559	                                  reactions: [
560	                                      {
561	                                          type: "sendCommand",
562	                                          cmd: "start_placement_mode",
563	                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],
564	                                      },
565	                                  ],
566	                              },
567	                          ] as MenuEntry[])
568	                        : []),
569	                    ...(elementRoot
570	                        ? ([
571	                              {
572	                                  label: "Element neu platzieren",
573	                                  // Detach runs in the processor (records the split), then it starts
574	                                  // placement on the freed element's own top-level group node.
575	                                  reactions: [
576	                                      { type: "dispatchEvent", event: { eventTypeName: "detach-element", elementId: elementRoot } },
577	                                  ],
578	                              },
579	                          ] as MenuEntry[])
580	                        : []),
581	                ],
582	            },
583	        ];
584	    }
585
586	    // A picked handle (part geometry or group node) → "#<groupKey>" of the owning group node.
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	}
660

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

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

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

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

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

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

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

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

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

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

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

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

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

    // A clicked select marker (CS_SELECT_<side>_<angle>) docks a copy of its element onto the matching
    // open end: the anchor is the corresponding CS_OUT point, the copy joins with CS_EIN_L (right side)
    // or CS_EIN_R (left side), and the branch orientation (0/90/270) follows from the PMI frames via
    // dockRotation. Occupied ends do nothing. Selection stays on the original so its markers re-render.
    private handleMarkerBuild(element: string, pmi: string, stop: UIReaction): UIReaction[] {
        this.replay();
        // The select marker maps 1:1 to its dock anchor of the same suffix: CS_SELECT_<side>_<angle>
        // → CS_OUT_<side>_<angle>. That anchor's frame makes dockRotation orient the copy so it builds
        // in the direction the marker sits (0 = straight, 90 = front, 270 = back).
        const anchorPmi = pmi.replace("CS_SELECT_", "CS_OUT_");
        const joinPmi = pmi.includes("_R_") ? "CS_EIN_L" : "CS_EIN_R";
        const groupId = this.groups.groupIdOfElement(element);
        const scene = this.groups.sceneOfElement(element);
        if (!groupId || !scene) return [stop];
        if (this.groups.occupiedPmis().some((o) => o.element === element && o.pmi === anchorPmi)) {
            return [stop]; // that end is already taken
        }
        const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        this.eventStore.push(createEvent);
        this.eventStore.push({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: groupId,
            movingGroupId: createEvent.groupId,
            anchorRootId: element,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: anchorPmi,
            joinPmiId: joinPmi,
            // Join-Root is `element`, not the fresh copy: the copy is an identical clone (same PMI
            // frames) and isn't replayed into the handle map yet, so reading its join PMI would fall
            // back to IDENTITY and tilt the copy. Reading both frames off the original is exact.
            rot: this.groups.dockRotation(element, anchorPmi, element, joinPmi),
        } as GroupEvent);
        // Drop the selection so the markers disappear once the copy is attached.
        this.selection = [];
        return this.selectionReactions(stop);
    }

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

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

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

    // On selection, drop a small green sphere at every PMI of the selected element(s). The marker hangs
    // under the element root's transform (PMI positions are element-local), rendered via an inline
    // parametric sphere + basic material, with a box collider so it's clickable (this build has no
    // sphere collider). Tagged `name-select-marker`. No selection → scene unchanged.
    private withSelectionMarkers(scene: any): any {
        const roots = new Set(this.selectionElementRoots());
        if (!roots.size) return scene;
        const occupied = new Set(this.groups.blockedPmis().map((o) => `${o.element} ${o.pmi}`));
        const markers: any[] = [];
        for (const h of scene.handles ?? []) {
            if (!roots.has(h.id)) continue;
            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
            if (!tr?.key) continue;
            for (const c of h.components ?? []) {
                if (c._resourceClass !== "component_pmi") continue;
                // Only the select PMIs (CS_SELECT_<side>_<angle>) get a marker, not the base points.
                if (!String(c.id).startsWith("CS_SELECT")) continue;
                // Show only where a copy can dock: the matching CS_OUT slot must be open per the post
                // rule (blockedPmis already folds in occupancy and shared-post directions).
                const anchorPmi = String(c.id).replace("CS_SELECT_", "CS_OUT_");
                if (occupied.has(`${h.id} ${anchorPmi}`)) continue;
                const pos = c.properties?.position?.value ?? [0, 0, 0];
                markers.push({
                    id: `sel-marker-${h.id}-${c.id}`,
                    tags: ["name-select-marker"],
                    components: [
                        { _resourceClass: "component_transform", parentKey: tr.key, translation: pos, rotation: [0, 0, 0], scale: [1, 1, 1] },
                        // A real mesh sphere via the built-in parametric factory (key "sphere-3d", param
                        // `diameter`) — the same component_renderer path the config's arrow gizmo uses, so it
                        // renders in this build and yields proper localBounds (no getBounds crash on select).
                        {
                            _resourceClass: "component_renderer",
                            castShadow: false,
                            receiveShadow: false,
                            geometry: { _resourceClass: "geometry_parametric", parameters: { factoryName: "sphere-3d", diameter: 120 } },
                            material: { _resourceClass: "material_basic", diffuse: [0, 1, 0], depthTest: true },
                        },
                        // Box collider (shapeType 4) around the PMI — this @ad/fire build registers only
                        // box/capsule/mesh shape descriptors (no sphere), so shapeType 2 has no descriptor and
                        // crashes selection-bounds. triggersSelection defaults true → the raycast hits it and
                        // reports the marker as the click target. The visible marker stays the round mesh above.
                        { _resourceClass: "component_collider", type: 2, shapeType: 4, center: [0, 0, 0], dimension: [80, 80, 80] },
                    ],
                });
            }
        }
        return { ...scene, handles: [...(scene.handles ?? []), ...markers] };
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return [
            { type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },
            {
                type: "contextMenu",
                entries: [
                    ...(canEscalate
                        ? ([
                              {
                                  label: "Übergeordnete Gruppe wählen",
                                  reactions: [{ type: "dispatchEvent", event: { eventTypeName: "escalate-selection", pickedId } }],
                              },
                          ] as MenuEntry[])
                        : []),
                    {
                        label: "Move",
                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "move" } }],
                    },
                    {
                        label: "Rotate",
                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "rotate" } }],
                    },
                    ...(dragPoints.length > 0
                        ? ([
                              {
                                  label: "Platzieren",
                                  reactions: [
                                      {
                                          type: "sendCommand",
                                          cmd: "start_placement_mode",
                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],
                                      },
                                  ],
                              },
                          ] as MenuEntry[])
                        : []),
                    ...(elementRoot
                        ? ([
                              {
                                  label: "Element neu platzieren",
                                  // Detach runs in the processor (records the split), then it starts
                                  // placement on the freed element's own top-level group node.
                                  reactions: [
                                      { type: "dispatchEvent", event: { eventTypeName: "detach-element", elementId: elementRoot } },
                                  ],
                              },
                          ] as MenuEntry[])
                        : []),
                ],
            },
        ];
    }

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

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


Gut - eine Änderung nach dem Anbau soll das neu angebaute Teil selektiert werden


Code ist klar. Kurz die Fakten zur Ausgangslage, dann Vorschlag und die eine offene Grenzfrage.

**Ist-Zustand**
- `DefaultCommandProcessor` dispatcht über ein hartes `switch` in `handleEvent` (Z. 43–74). Kein Registrierungs-Mechanismus, nur der Konstruktor-Parameter `dockingRules` als Daten-Injektion.
- Die Dreipunkt-/Anbau-Logik ist quer eingebettet, nicht separat:
  - Snap-Merge mit Rotation: `handlePlacementEnd` (408), `placementEvent` (457), `dockRotation`.
  - Docking-Wiring: `handleDetachElement` (426), `placementDragPoints` (609), `classOf` (596), `dockingRules`.
  - Anbau: `handleKeyboardBuild` (108), Marker-Abfang in `handleSelectionClickEvent` (161–165), `handleMarkerBuild` (193), `withSelectionMarkers` (262).

**Vorschlag — Modul-Schnittstelle + `register()`**

Ein `PlacementModule` mit Hook-Punkten, weil die Logik nicht nur eigene Event-Typen hat, sondern auch in Selektion und Szene-Rendering eingreift:

```ts
interface PlacementModule {
  handleEvent?(event, ctx): UIReaction[] | null;      // placement / detach-element / keyboard-build; null = nicht zuständig
  interceptSelectionClick?(event, ctx): UIReaction[] | null;  // sel-marker-* Abfang
  decorateScene?(scene, ctx): Scene;                  // withSelectionMarkers
  contextEntries?(ctx): MenuEntry[];                  // "Platzieren" / "Element neu platzieren"
}
```

`DefaultCommandProcessor.register(module)` hängt es in eine Liste. Der Prozessor ruft an den Seams die registrierten Module; `handleEvent`-Default und Marker-Abfang fallen erst auf Core zurück, wenn kein Modul zuständig ist. Das Modul bekommt einen `ctx` mit Zugriff auf `groups`, `eventStore`, `replay()`, `selection` (get/set) — die `dockingRules` wandern in das Modul.

**Wandert ins GEKO-Modul:** `handlePlacementEnd`, `placementEvent`, `handleDetachElement`, `placementDragPoints`, `classOf`, `handleKeyboardBuild`, `handleMarkerBuild`, `withSelectionMarkers`, Marker-Abfang, `dockingRules`, `dockRotation`-Nutzung, die Placement/Detach-Context-Einträge.

**Bleibt CPQ-Core:** Selektion, Move/Rotate, `add-product`, Undo/Redo, Esc, Basis-Context-Menü, `replay()`, `register()`.

**Neue Anforderung (Select nach Anbau):** `handleMarkerBuild` setzt heute `this.selection = []` (223). Künftig `selection = [{ pickedId: createEvent.rootHandleId, level: 0 }]`, damit das neue Teil selektiert ist und seine Marker neu rendern. `handleKeyboardBuild` chained die Selektion bereits auf die Kopie (151) — bleibt so.

**Storybook (Trennung vorwegnehmen):**
- `Fire3d.stories.ts`: `new DefaultCommandProcessor()` ohne Modul → reines CPQ (Selektion/Move/Rotate/Add).
- Zweite Story / `ProductConfigurator`: `p.register(new GekoPlacementModule(dockingRules))` → volle Dreipunkt+Anbau-Kette.

Eine Grenze muss ich von dir wissen, bevor ich die Aufteilung schneide: