File created successfully at: /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/modules/GekoPlacementModule.ts (file state is current in your context — no need to Read it back)

import type { UIReaction, MenuEntry } from "../types";
import type { GroupEvent } from "../model/GroupProcessor";
import { IDENTITY_QUAT } from "../model/transformMath";
import { extractPlacementData } from "../events/eventData";
import type { CommandContext, EventHooks, PlacementModule, PrepareResult } from "../events/PlacementModule";

// 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[]>>>;

// The geko-specific placement/docking behaviour, lifted out of the domain-agnostic core: three-point
// snap orientation (dockRotation), docking-rules drag/drop wiring, and the marker "Anbau". It records
// merge/create events through the context and reads the shared group model — no second processor.
export class GekoPlacementModule implements PlacementModule {
    // dockingRules come from the cpq client (geko) — cpq itself is domain-agnostic.
    constructor(private readonly dockingRules: DockingRules = {}) {}

    hooks(): Record<string, EventHooks> {
        return {
            // A marker click docks a copy (prepare, vetoes the plain selection); every selection gets
            // its PMI markers back on the resulting scene (refine).
            "selection-click": {
                prepare: (event, ctx) => this.interceptMarkerClick(event, ctx),
                refine: (_event, ctx, reactions) => this.decorateSelection(reactions, ctx),
            },
            // Escalation re-renders the selection → markers again.
            "escalate-selection": {
                refine: (_event, ctx, reactions) => this.decorateSelection(reactions, ctx),
            },
            // The context menu gains "Platzieren" / "Element neu platzieren" for dockable subjects.
            "context-click": {
                refine: (event, ctx, reactions) => this.appendContextEntries(event, ctx, reactions),
            },
            // Placement end (snap → merge) rides on the interaction-mode event; move/rotate stay core.
            "interaction-mode": {
                prepare: (event, ctx) => this.interceptPlacement(event, ctx),
            },
            // Detach-for-placement and the keyboard build prototype have no core default.
            "detach-element": {
                replace: (event, ctx) => this.detachElement(event, ctx),
            },
            "keyboard-build": {
                replace: (event, ctx) => this.keyboardBuild(event, ctx),
            },
        };
    }

    // ---- Anbau via select marker -------------------------------------------------------------------

    // Intercept a click on one of our select-PMI marker spheres — dock a copy of the element there and
    // stop the plain selection from running.
    private interceptMarkerClick(event: any, ctx: CommandContext): PrepareResult {
        const targetId: unknown = event.target?.id;
        if (typeof targetId !== "string" || !targetId.startsWith("sel-marker-")) return null;
        const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
        const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        return { stopDefault: true, reactions: this.markerBuild(element, pmi, stop, ctx) };
    }

    // 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. The fresh copy is selected so its markers re-render.
    private markerBuild(element: string, pmi: string, stop: UIReaction, ctx: CommandContext): UIReaction[] {
        ctx.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 = ctx.groups.groupIdOfElement(element);
        const scene = ctx.groups.sceneOfElement(element);
        if (!groupId || !scene) return [stop];
        if (ctx.groups.occupiedPmis().some((o) => o.element === element && o.pmi === anchorPmi)) {
            return [stop]; // that end is already taken
        }
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        ctx.record({
            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: ctx.groups.dockRotation(element, anchorPmi, element, joinPmi),
        } as GroupEvent);
        // Select the freshly attached copy: its markers render on it and the next build chains off it.
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return ctx.selectionReactions(stop);
    }

    // ---- Selection markers (refine) ----------------------------------------------------------------

    // Add the PMI markers to the setScene reaction's scene. No setScene → nothing to decorate.
    private decorateSelection(reactions: UIReaction[], ctx: CommandContext): UIReaction[] {
        const idx = reactions.findIndex((r) => r.type === "sendCommand" && r.cmd === "setScene");
        if (idx < 0) return reactions;
        const scene = (reactions[idx] as Extract<UIReaction, { type: "sendCommand" }>).args[0];
        const decorated = this.withSelectionMarkers(scene, ctx);
        const out = reactions.slice();
        out[idx] = { type: "sendCommand", cmd: "setScene", args: [decorated] };
        return out;
    }

    // Drop a small green sphere at every open select-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. No selection → scene
    // unchanged.
    private withSelectionMarkers(scene: any, ctx: CommandContext): any {
        const roots = new Set(ctx.selectionElementRoots());
        if (!roots.size) return scene;
        const occupied = new Set(ctx.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] };
    }

    // ---- Placement end (snap → merge) --------------------------------------------------------------

    private interceptPlacement(event: any, ctx: CommandContext): PrepareResult {
        if (event.modeName !== "placement" || event.lifecycleState !== "update") return null;
        return { stopDefault: true, reactions: this.placementEnd(event, ctx) };
    }

    private placementEnd(event: any, ctx: CommandContext): UIReaction[] {
        const data = extractPlacementData(event);
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        ctx.replay();
        // The subject is always a top-level group node (an element to re-place is detached into its own
        // group first — see detachElement — so the engine never gets a nested subject).
        const movingGroupId = ctx.resolveGroupId(data.handleId);
        if (!movingGroupId) return [stop];
        const placement = this.placementEvent(data, movingGroupId, ctx);
        if (placement) ctx.record(placement);
        const scene = ctx.replay();
        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
    }

    // The merge (on snap) or move (free) event for a moving group.
    private placementEvent(data: any, movingGroupId: string, ctx: CommandContext): GroupEvent | null {
        if (data.dropHandleId) {
            const anchorRoot = ctx.groups.elementRootOf(data.dropHandleId);
            const joinRoot = data.dragHandleId ? ctx.groups.elementRootOf(data.dragHandleId) : null;
            const targetGroupId = anchorRoot ? ctx.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: ctx.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: ctx.groups.transformOf(movingGroupId)?.rot ?? IDENTITY_QUAT,
        };
    }

    // ---- Detach for re-placement -------------------------------------------------------------------

    // 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 detachElement(event: any, ctx: CommandContext): UIReaction[] {
        ctx.replay();
        const detach = ctx.groups.buildDetachEvent(event.elementId);
        if (!detach) return [];
        ctx.record(detach);
        const scene = ctx.replay();

        const groupId = detach.newGroups.find((ng) => ng.members.includes(event.elementId))?.groupId;
        const groupKey = groupId ? ctx.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, ctx);
        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;
    }

    // ---- Keyboard build prototype ------------------------------------------------------------------

    // PROTOTYPE — keyboard build shortcut ("+" held + arrow), fed from Fire3D. Works off the last active
    // selection. 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.
    private keyboardBuild(event: any, ctx: CommandContext): UIReaction[] {
        ctx.replay();
        const selection = ctx.getSelection();
        const active = selection[selection.length - 1]?.pickedId;
        const activeRoot = active ? ctx.groups.elementRootOf(active) : null;
        const groupId = activeRoot ? ctx.groups.groupIdOfElement(activeRoot) : null;
        if (!active || !activeRoot || !groupId) return [];

        if (event.direction === "up" || event.direction === "down") {
            const t = ctx.groups.transformOf(groupId);
            if (!t) return [];
            const step = event.direction === "up" ? 100 : -100;
            ctx.record({
                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: [ctx.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 (ctx.groups.occupiedPmis().some((o) => o.element === activeRoot && o.pmi === anchorPmi)) {
            return []; // no room at the active part's chosen end
        }
        const scene = ctx.groups.sceneOfElement(activeRoot);
        if (!scene) return [];
        const createEvent = ctx.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        ctx.record(createEvent);
        ctx.record({
            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.
        ctx.setSelection([{ pickedId: createEvent.rootHandleId, level: 0 }]);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }

    // ---- Context menu entries (refine) -------------------------------------------------------------

    // Append "Platzieren" (if the subject has open drag points) and "Element neu platzieren" (if a
    // single element is picked) to the core context menu. No contextMenu reaction → nothing to add.
    private appendContextEntries(event: any, ctx: CommandContext, reactions: UIReaction[]): UIReaction[] {
        const menuIdx = reactions.findIndex((r) => r.type === "contextMenu");
        if (menuIdx < 0) return reactions;
        const pickedId: unknown = event.pointed?.[0]?.entity?.id;
        if (typeof pickedId !== "string") return reactions;
        const groupSelector = ctx.groupSelectorOf(pickedId);
        if (!groupSelector) return reactions;

        const elementRoot = ctx.groups.elementRootOf(pickedId);
        const dragPoints = this.placementDragPoints(groupSelector, ctx);
        const extra: MenuEntry[] = [];
        if (dragPoints.length > 0) {
            extra.push({
                label: "Platzieren",
                reactions: [
                    { type: "sendCommand", cmd: "start_placement_mode", args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }] },
                ],
            });
        }
        if (elementRoot) {
            extra.push({
                label: "Element neu platzieren",
                // Detach runs via a dispatched event (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 } }],
            });
        }
        if (!extra.length) return reactions;
        const menu = reactions[menuIdx] as Extract<UIReaction, { type: "contextMenu" }>;
        const out = reactions.slice();
        out[menuIdx] = { type: "contextMenu", entries: [...menu.entries, ...extra] };
        return out;
    }

    // ---- Docking rules wiring ----------------------------------------------------------------------

    // 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, ctx: CommandContext): Array<{ selector: string; dropPoints: string[] }> {
        const klass = this.classOf(subject, ctx);
        const rules = klass ? this.dockingRules[klass] : undefined;
        if (!rules) return [];

        const occupied = new Set(ctx.groups.blockedPmis().map((o) => `${o.element} ${o.pmi}`));
        const ownKey = subject.startsWith("#") ? subject.slice(1) : subject;
        const ownMembers = new Set(ctx.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 = ctx.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,
        }));
    }

    // 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, ctx: CommandContext): string | null {
        const key = subject.startsWith("#") ? subject.slice(1) : subject;
        const groupId = ctx.groups.groupByKey(key);
        return groupId ? ctx.groups.classOfGroup(groupId) : null;
    }
}


Das weiß ich nicht das passt - fäürchte ich - nicht so ganz Dann las uns das gleiuch imAnschluss nach denm erstwen Umbau diskttieren baust mal los?


**3. Core: `register()`, Hook-Pipeline, GEKO-Logik raus**


Write

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 } from "./eventData";
import { IDENTITY_QUAT } from "../model/transformMath";
import type { CommandContext, PlacementModule, Pick } from "./PlacementModule";

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: Pick[] = [];
    // 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;

    // Registered placement modules (geko etc.). Domain-specific placement/docking lives there; the core
    // is domain-agnostic. Hooks are indexed per event type so dispatch stays O(1).
    private readonly prepareHooks = new Map<string, Array<(e: any, c: CommandContext) => ReturnType<NonNullable<import("./PlacementModule").EventHooks["prepare"]>>>>();
    private readonly replaceHooks = new Map<string, (e: any, c: CommandContext) => UIReaction[]>();
    private readonly refineHooks = new Map<string, Array<(e: any, c: CommandContext, r: UIReaction[]) => UIReaction[]>>();

    // The handle modules drive the core through. Built once; arrows read live `this` state.
    private readonly ctx: CommandContext = {
        groups: this.groups,
        record: (event) => this.eventStore.push(event),
        recordGroup: (events) => this.eventStore.pushGroup(events),
        replay: () => this.replay(),
        getSelection: () => this.selection,
        setSelection: (picks) => {
            this.selection = picks;
        },
        selectionReactions: (stop) => this.selectionReactions(stop),
        selectionElementRoots: () => this.selectionElementRoots(),
        resolveGroupId: (handleId) => this.resolveGroupId(handleId),
        groupSelectorOf: (handleId) => this.groupSelectorOf(handleId),
    };

    // Register a placement module. prepare/refine hooks chain in registration order; a second replace
    // for the same event type is rejected (hard error, no silent overwrite).
    register(module: PlacementModule): void {
        for (const [type, hooks] of Object.entries(module.hooks())) {
            if (hooks.prepare) {
                const list = this.prepareHooks.get(type) ?? [];
                list.push(hooks.prepare);
                this.prepareHooks.set(type, list);
            }
            if (hooks.refine) {
                const list = this.refineHooks.get(type) ?? [];
                list.push(hooks.refine);
                this.refineHooks.set(type, list);
            }
            if (hooks.replace) {
                if (this.replaceHooks.has(type)) {
                    console.error(`[DefaultCommandProcessor] a replace hook is already registered for "${type}" — rejecting the second`);
                } else {
                    this.replaceHooks.set(type, hooks.replace);
                }
            }
        }
    }

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

    // prepare → (replace | core default) → refine. A prepare that stops the default short-circuits the
    // middle; refine always runs over whatever reactions were produced.
    handleEvent(event: any): UIReaction[] {
        const type: string = event?.eventTypeName;
        for (const prepare of this.prepareHooks.get(type) ?? []) {
            const result = prepare(event, this.ctx);
            if (result?.stopDefault) return this.refine(type, event, result.reactions ?? []);
        }
        const replace = this.replaceHooks.get(type);
        const reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);
        return this.refine(type, event, reactions);
    }

    private refine(type: string, event: any, reactions: UIReaction[]): UIReaction[] {
        let out = reactions;
        for (const f of this.refineHooks.get(type) ?? []) out = f(event, this.ctx, out);
        return out;
    }

    // The domain-agnostic core: selection, move/rotate, add-product, undo/redo, esc, context menu base.
    private coreHandle(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 "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: [number, number, number] = [this.freeCursorX, 0, 0];
        this.freeCursorX += this.productExtent(event.handles);
        const createEvent = this.groups.buildCreateEvent({ handles: event.handles } as never, position);
        this.eventStore.push(createEvent);
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
        ];
    }

    // Default click selects the single element; re-clicking the same spot escalates up the role
    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
    private handleSelectionClickEvent(event: any): UIReaction[] {
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        const additive = event.selectionAction === "add";
        if (!event.target) {
            if (!additive) this.selection = [];
            return this.selectionReactions(stop);
        }
        this.replay();
        const pickedId = event.target.id;
        const chain = this.groups.selectableAncestors(pickedId);
        if (!chain.length) return [stop];

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

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

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

    // The standard selection reactions: stop, the replayed scene, and the resolved selection. Scene
    // decoration (e.g. placement markers) is left to a module's refine hook.
    private selectionReactions(stop: UIReaction): UIReaction[] {
        const scene = this.replay();
        const selectors = this.selectedNodeIds().map((id) => "#" + id);
        return [
            stop,
            { type: "sendCommand", cmd: "setScene", args: [scene] },
            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
        ];
    }

    private handleInteractionMode(event: any): UIReaction[] {
        switch (event.modeName) {
            case "rotation":
                return this.handleRotateEnd(event);
            case "movement":
                return this.handleMoveEnd(event);
            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 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: [] }];

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

        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" } }],
                    },
                ],
            },
        ];
    }

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

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


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

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

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: Pick[] = [];
    // 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;

    // Registered placement modules (geko etc.). Domain-specific placement/docking lives there; the core
    // is domain-agnostic. Hooks are indexed per event type so dispatch stays O(1).
    private readonly prepareHooks = new Map<string, Array<(e: any, c: CommandContext) => ReturnType<NonNullable<import("./PlacementModule").EventHooks["prepare"]>>>>();
    private readonly replaceHooks = new Map<string, (e: any, c: CommandContext) => UIReaction[]>();
    private readonly refineHooks = new Map<string, Array<(e: any, c: CommandContext, r: UIReaction[]) => UIReaction[]>>();

    // The handle modules drive the core through. Built once; arrows read live `this` state.
    private readonly ctx: CommandContext = {
        groups: this.groups,
        record: (event) => this.eventStore.push(event),
        recordGroup: (events) => this.eventStore.pushGroup(events),
        replay: () => this.replay(),
        getSelection: () => this.selection,
        setSelection: (picks) => {
            this.selection = picks;
        },
        selectionReactions: (stop) => this.selectionReactions(stop),
        selectionElementRoots: () => this.selectionElementRoots(),
        resolveGroupId: (handleId) => this.resolveGroupId(handleId),
        groupSelectorOf: (handleId) => this.groupSelectorOf(handleId),
    };

    // Register a placement module. prepare/refine hooks chain in registration order; a second replace
    // for the same event type is rejected (hard error, no silent overwrite).
    register(module: PlacementModule): void {
        for (const [type, hooks] of Object.entries(module.hooks())) {
            if (hooks.prepare) {
                const list = this.prepareHooks.get(type) ?? [];
                list.push(hooks.prepare);
                this.prepareHooks.set(type, list);
            }
            if (hooks.refine) {
                const list = this.refineHooks.get(type) ?? [];
                list.push(hooks.refine);
                this.refineHooks.set(type, list);
            }
            if (hooks.replace) {
                if (this.replaceHooks.has(type)) {
                    console.error(`[DefaultCommandProcessor] a replace hook is already registered for "${type}" — rejecting the second`);
                } else {
                    this.replaceHooks.set(type, hooks.replace);
                }
            }
        }
    }

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

    // prepare → (replace | core default) → refine. A prepare that stops the default short-circuits the
    // middle; refine always runs over whatever reactions were produced.
    handleEvent(event: any): UIReaction[] {
        const type: string = event?.eventTypeName;
        for (const prepare of this.prepareHooks.get(type) ?? []) {
            const result = prepare(event, this.ctx);
            if (result?.stopDefault) return this.refine(type, event, result.reactions ?? []);
        }
        const replace = this.replaceHooks.get(type);
        const reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);
        return this.refine(type, event, reactions);
    }

    private refine(type: string, event: any, reactions: UIReaction[]): UIReaction[] {
        let out = reactions;
        for (const f of this.refineHooks.get(type) ?? []) out = f(event, this.ctx, out);
        return out;
    }

    // The domain-agnostic core: selection, move/rotate, add-product, undo/redo, esc, context menu base.
    private coreHandle(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 "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: [number, number, number] = [this.freeCursorX, 0, 0];
        this.freeCursorX += this.productExtent(event.handles);
        const createEvent = this.groups.buildCreateEvent({ handles: event.handles } as never, position);
        this.eventStore.push(createEvent);
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
        ];
    }

    // Default click selects the single element; re-clicking the same spot escalates up the role
    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
    private handleSelectionClickEvent(event: any): UIReaction[] {
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        const additive = event.selectionAction === "add";
        if (!event.target) {
            if (!additive) this.selection = [];
            return this.selectionReactions(stop);
        }
        this.replay();
        const pickedId = event.target.id;
        const chain = this.groups.selectableAncestors(pickedId);
        if (!chain.length) return [stop];

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

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

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

    // The standard selection reactions: stop, the replayed scene, and the resolved selection. Scene
    // decoration (e.g. placement markers) is left to a module's refine hook.
    private selectionReactions(stop: UIReaction): UIReaction[] {
        const scene = this.replay();
        const selectors = this.selectedNodeIds().map((id) => "#" + id);
        return [
            stop,
            { type: "sendCommand", cmd: "setScene", args: [scene] },
            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
        ];
    }

    private handleInteractionMode(event: any): UIReaction[] {
        switch (event.modeName) {
            case "rotation":
                return this.handleRotateEnd(event);
            case "movement":
                return this.handleMoveEnd(event);
            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 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: [] }];

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

        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" } }],
                    },
                ],
            },
        ];
    }

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

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

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

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

+import { IDENTITY_QUAT } from "../model/transformMath";

+import type { CommandContext, PlacementModule, Pick } from "./PlacementModule";

-// 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 selection: { pickedId: string; level: number }[] = [];

private pendingRegroup: GroupEvent | null = null;

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

-    constructor(private readonly dockingRules?: DockingRules) {

+    // Registered placement modules (geko etc.). Domain-specific placement/docking lives there; the core

+    // is domain-agnostic. Hooks are indexed per event type so dispatch stays O(1).

+    private readonly prepareHooks = new Map<string, Array<(e: any, c: CommandContext) => ReturnType<NonNullable<import("./PlacementModule").EventHooks["prepare"]>>>>();

+    private readonly replaceHooks = new Map<string, (e: any, c: CommandContext) => UIReaction[]>();

+    private readonly refineHooks = new Map<string, Array<(e: any, c: CommandContext, r: UIReaction[]) => UIReaction[]>>();

+    // The handle modules drive the core through. Built once; arrows read live `this` state.

+    private readonly ctx: CommandContext = {

+        record: (event) => this.eventStore.push(event),

+        recordGroup: (events) => this.eventStore.pushGroup(events),

+        getSelection: () => this.selection,

+        selectionReactions: (stop) => this.selectionReactions(stop),

+        selectionElementRoots: () => this.selectionElementRoots(),

+        resolveGroupId: (handleId) => this.resolveGroupId(handleId),

+        groupSelectorOf: (handleId) => this.groupSelectorOf(handleId),

+    // Register a placement module. prepare/refine hooks chain in registration order; a second replace

+    // for the same event type is rejected (hard error, no silent overwrite).

+    register(module: PlacementModule): void {

+        for (const [type, hooks] of Object.entries(module.hooks())) {

+                const list = this.prepareHooks.get(type) ?? [];

+                list.push(hooks.prepare);

+                this.prepareHooks.set(type, list);

+                const list = this.refineHooks.get(type) ?? [];

+                list.push(hooks.refine);

+                this.refineHooks.set(type, list);

+                if (this.replaceHooks.has(type)) {

+                    console.error(`[DefaultCommandProcessor] a replace hook is already registered for "${type}" — rejecting the second`);

+                    this.replaceHooks.set(type, hooks.replace);

+    // prepare → (replace | core default) → refine. A prepare that stops the default short-circuits the

+    // middle; refine always runs over whatever reactions were produced.

+        const type: string = event?.eventTypeName;

+        for (const prepare of this.prepareHooks.get(type) ?? []) {

+            const result = prepare(event, this.ctx);

+            if (result?.stopDefault) return this.refine(type, event, result.reactions ?? []);

+        const replace = this.replaceHooks.get(type);

+        const reactions = replace ? replace(event, this.ctx) : this.coreHandle(event);

+        return this.refine(type, event, reactions);

+    private refine(type: string, event: any, reactions: UIReaction[]): UIReaction[] {

+        for (const f of this.refineHooks.get(type) ?? []) out = f(event, this.ctx, out);

+    // The domain-agnostic core: selection, move/rotate, add-product, undo/redo, esc, context menu base.

+    private coreHandle(event: any): UIReaction[] {

if (event.lifecycleState !== "update") return [];

-                return this.handleKeyboardBuild(event);

-                return this.handleDetachElement(event);

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

+        const position: [number, number, number] = [this.freeCursorX, 0, 0];

-        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);

+        const createEvent = this.groups.buildCreateEvent({ handles: event.handles } as never, position);

-    // 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[] {

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

-            const step = event.direction === "up" ? 100 : -100;

-                position: [t.pos[0], t.pos[1] + step, t.pos[2]],

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

-        const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);

-        this.eventStore.push(createEvent);

-            movingGroupId: createEvent.groupId,

-            joinRootId: createEvent.rootHandleId,

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

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

-    // 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[] {

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

-            movingGroupId: createEvent.groupId,

-            joinRootId: createEvent.rootHandleId,

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

-        // Drop the selection so the markers disappear once the copy is attached.

-        return this.selectionReactions(stop);

// Right-click escalation: only meaningful for a single selected pick; bump it one level.

private handleEscalateSelection(event: any): UIReaction[] {

+    // The standard selection reactions: stop, the replayed scene, and the resolved selection. Scene

+    // decoration (e.g. placement markers) is left to a module's refine hook.

private selectionReactions(stop: UIReaction): UIReaction[] {

-        const scene = this.withSelectionMarkers(this.replay());

const selectors = this.selectedNodeIds().map((id) => "#" + id);

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

-        const occupied = new Set(this.groups.blockedPmis().map((o) => `${o.element} ${o.pmi}`));

-        for (const h of scene.handles ?? []) {

-            if (!roots.has(h.id)) continue;

-            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");

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

-                    id: `sel-marker-${h.id}-${c.id}`,

-                    tags: ["name-select-marker"],

-                        { _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[] {

-                return this.handlePlacementEnd(event);

console.error(`Unknown interaction mode: ${event.modeName}`);

-    private handlePlacementEnd(event: any): UIReaction[] {

-        const data = extractPlacementData(event);

-        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };

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

-        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[] {

-        const detach = this.groups.buildDetachEvent(event.elementId);

-        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[] = [

-            { 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.

-                cmd: "start_placement_mode",

-                args: [{ subject, allowAllDragPoints: true, dragPoints }],

-    // The merge (on snap) or move (free) event for a moving group.

-    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {

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

-                    type: "group-merge",

-                    timestamp: Date.now(),

-                    anchorRootId: anchorRoot,

-                    joinRootId: joinRoot,

-                    anchorPmiId: data.dropPmiId,

-                    joinPmiId: data.dragPmiId,

-                    rot: this.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),

-        // No snap: free reposition of the group to the drop position.

-            rotation: this.groups.transformOf(movingGroupId)?.rot ?? IDENTITY_QUAT,

if (!this.eventStore.canUndo) return [];

const groupSelector = this.groupSelectorOf(pickedId);

-        // The single picked element (for "re-place this element", as opposed to the whole group).

-        const elementRoot = this.groups.elementRootOf(pickedId);

// only shows for a single-pick selection that can still go higher.

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

{ type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },

-                    ...(dragPoints.length > 0

-                                  label: "Platzieren",

-                                  reactions: [

-                                          type: "sendCommand",

-                                          cmd: "start_placement_mode",

-                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],

-                          ] as MenuEntry[])

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

-                                      { type: "dispatchEvent", event: { eventTypeName: "detach-element", elementId: elementRoot } },

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

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

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

const byKey = this.groups.groupByKey(handleId);


Ich glätte den umständlichen inline-Import-Typ im Core.


Edit

import type { CommandContext, PlacementModule, Pick } from "./PlacementModule";

import type { CommandContext, PlacementModule, Pick, PrepareResult } from "./PlacementModule";


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

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

import { IDENTITY_QUAT } from "../model/transformMath";

-import type { CommandContext, PlacementModule, Pick } from "./PlacementModule";

+import type { CommandContext, PlacementModule, Pick, PrepareResult } from "./PlacementModule";

export class DefaultCommandProcessor extends CommandProcessor {


private readonly prepareHooks = new Map<string, Array<(e: any, c: CommandContext) => ReturnType<NonNullable<import("./PlacementModule").EventHooks["prepare"]>>>>();

private readonly prepareHooks = new Map<string, Array<(e: any, c: CommandContext) => PrepareResult>>();


// Registered placement modules (geko etc.). Domain-specific placement/docking lives there; the core

// is domain-agnostic. Hooks are indexed per event type so dispatch stays O(1).

-    private readonly prepareHooks = new Map<string, Array<(e: any, c: CommandContext) => ReturnType<NonNullable<import("./PlacementModule").EventHooks["prepare"]>>>>();

+    private readonly prepareHooks = new Map<string, Array<(e: any, c: CommandContext) => PrepareResult>>();

private readonly replaceHooks = new Map<string, (e: any, c: CommandContext) => UIReaction[]>();

private readonly refineHooks = new Map<string, Array<(e: any, c: CommandContext, r: UIReaction[]) => UIReaction[]>>();


**4. Verdrahtung: index, ProductConfigurator, Test**


export { CommandProcessor } from "./configurator/events/CommandProcessor";
export type { DockingRules } from "./configurator/events/DefaultCommandProcessor";
export type { UIReaction, MenuEntry } from "./configurator/types";

export { CommandProcessor } from "./configurator/events/CommandProcessor";
export { DefaultCommandProcessor } from "./configurator/events/DefaultCommandProcessor";
export { GekoPlacementModule } from "./configurator/modules/GekoPlacementModule";
export type { DockingRules } from "./configurator/modules/GekoPlacementModule";
export type { PlacementModule, CommandContext } from "./configurator/events/PlacementModule";
export type { UIReaction, MenuEntry } from "./configurator/types";


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

export { CommandProcessor } from "./configurator/events/CommandProcessor";
export { DefaultCommandProcessor } from "./configurator/events/DefaultCommandProcessor";
export { GekoPlacementModule } from "./configurator/modules/GekoPlacementModule";
export type { DockingRules } from "./configurator/modules/GekoPlacementModule";
export type { PlacementModule, CommandContext } from "./configurator/events/PlacementModule";
export type { UIReaction, MenuEntry } from "./configurator/types";

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

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

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

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

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

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

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

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

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

+export { GekoPlacementModule } from "./configurator/modules/GekoPlacementModule";

+export type { DockingRules } from "./configurator/modules/GekoPlacementModule";

+export type { PlacementModule, CommandContext } from "./configurator/events/PlacementModule";


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

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


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

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

<template>

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

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

</template>

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

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

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

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

+import { GekoPlacementModule, type DockingRules } from "../../configurator/modules/GekoPlacementModule";


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

const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
// The geko client wires its placement/docking behaviour in as a module; the core stays domain-agnostic.
const processor = new DefaultCommandProcessor();
processor.register(new GekoPlacementModule(props.dockingRules));


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

const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
// The geko client wires its placement/docking behaviour in as a module; the core stays domain-agnostic.
const processor = new DefaultCommandProcessor();
processor.register(new GekoPlacementModule(props.dockingRules));

const fire = ref<InstanceType<typeof Fire3D> | null>(null);

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

+// The geko client wires its placement/docking behaviour in as a module; the core stays domain-agnostic.

+const processor = new DefaultCommandProcessor();

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

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


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

import { DefaultCommandProcessor } from "./DefaultCommandProcessor";
import { GekoPlacementModule, type DockingRules } from "../modules/GekoPlacementModule";