            case "group-regroup": {
                for (const sid of event.sourceGroupIds) this.groups.delete(sid);
                for (const ng of event.newGroups) {
                    this.groups.set(ng.groupId, {
                        id: ng.groupId,
                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                break;
            }
        }
    }

            case "group-regroup": {
                for (const sid of event.sourceGroupIds) this.groups.delete(sid);
                for (const ng of event.newGroups) {
                    this.groups.set(ng.groupId, {
                        id: ng.groupId,
                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                break;
            }
            case "module-event": {
                let slice = this.moduleState.get(event.moduleId);
                if (!slice) {
                    slice = new Map();
                    this.moduleState.set(event.moduleId, slice);
                }
                if (event.payload === null) slice.delete(event.key);
                else slice.set(event.key, event.payload);
                break;
            }
        }
    }

+                let slice = this.moduleState.get(event.moduleId);

+                    this.moduleState.set(event.moduleId, slice);

+                if (event.payload === null) slice.delete(event.key);

+                else slice.set(event.key, event.payload);


**Part 2 — `derive`-Hook im `PlacementModule`** (mutiert das Modell direkt, ephemer)


Edit

// A registered extension. `hooks()` maps an event type name to its phase hooks.
export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
}

// A registered extension. `hooks()` maps an event type name to its phase hooks. `derive()` is the
// separate replay-time capability: it runs inside replay(), after the stored events assembled the
// model, and injects *derived* geometry (e.g. auto-inserted Stehers) directly into the model so
// docking and materialisation see it. It must be a pure function of the model + the module's own
// override state (model.moduleStateOf) — recomputed every replay, never recorded.
export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
    derive?(model: GroupProcessor): void;
}


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

// A registered extension. `hooks()` maps an event type name to its phase hooks. `derive()` is the
// separate replay-time capability: it runs inside replay(), after the stored events assembled the
// model, and injects *derived* geometry (e.g. auto-inserted Stehers) directly into the model so
// docking and materialisation see it. It must be a pure function of the model + the module's own
// override state (model.moduleStateOf) — recomputed every replay, never recorded.
export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
    derive?(model: GroupProcessor): void;
}

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

// A selection pick: picked geometry plus its escalation level (part → group → …).
export type Pick = { pickedId: string; level: number };

// The handle a registered module drives the core through. The core owns the event store, the group
// model and the selection; the module reads and records through this context instead of re-owning any
// of it. `groups` is the shared GroupProcessor — passed whole so a module never mirrors its queries.
export interface CommandContext {
    readonly groups: GroupProcessor;
    // Append to the event store. Undo/redo/init stay on the core.
    record(event: GroupEvent): void;
    recordGroup(events: GroupEvent[]): void;
    // Recompute the model from the recorded events → scene.
    replay(): any;
    // Selection state.
    getSelection(): Pick[];
    setSelection(picks: Pick[]): void;
    // Core selection resolution/presentation, reused so a module never re-implements it:
    // the standard [stop, setScene, set_selection] WITHOUT decoration (markers are a refine).
    selectionReactions(stop: UIReaction): UIReaction[];
    // The element roots covered by the current selection (a group expands to its members).
    selectionElementRoots(): string[];
    resolveGroupId(handleId: string): string | null;
    groupSelectorOf(handleId: string): string | null;
}

// A prepare hook may stop the default handling and/or contribute its own reactions. Returning a result
// with `stopDefault` short-circuits: no replace, no core default — the returned reactions stand.
export type PrepareResult = { stopDefault?: boolean; reactions?: UIReaction[] } | null;

// The three phases a module can hook per event type.
//  - prepare: runs before the default, may veto it (stopDefault) and supply the alternative reactions.
//  - replace: supplants the default entirely (at most one per event type — a second is rejected).
//  - refine:  post-processes the produced reactions (e.g. decorate the setScene with markers).
export interface EventHooks {
    prepare?(event: any, ctx: CommandContext): PrepareResult;
    replace?(event: any, ctx: CommandContext): UIReaction[];
    refine?(event: any, ctx: CommandContext, reactions: UIReaction[]): UIReaction[];
}

// A registered extension. `hooks()` maps an event type name to its phase hooks.
export interface PlacementModule {
    hooks(): Record<string, EventHooks>;
}

-// A registered extension. `hooks()` maps an event type name to its phase hooks.

+// A registered extension. `hooks()` maps an event type name to its phase hooks. `derive()` is the

+// separate replay-time capability: it runs inside replay(), after the stored events assembled the

+// model, and injects *derived* geometry (e.g. auto-inserted Stehers) directly into the model so

+// docking and materialisation see it. It must be a pure function of the model + the module's own

+// override state (model.moduleStateOf) — recomputed every replay, never recorded.

+    derive?(model: GroupProcessor): void;


**Part 3 — `DefaultCommandProcessor`: Module-Liste + Drei-Phasen-Replay**


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

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


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)

    private readonly modules: PlacementModule[] = [];
    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 modules: PlacementModule[] = [];

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[]>>();


    register(module: PlacementModule): void {
        for (const [type, hooks] of Object.entries(module.hooks())) {

    register(module: PlacementModule): void {
        this.modules.push(module);
        for (const [type, hooks] of Object.entries(module.hooks())) {


    register(module: PlacementModule): void {
        for (const [type, hooks] of Object.entries(module.hooks())) {

    register(module: PlacementModule): void {
        this.modules.push(module);
        for (const [type, hooks] of Object.entries(module.hooks())) {

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

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


        this.cachedScene = perf.span("proc.replay", () => {
            const events = this.eventStore.getEventsToReplay();
            return this.groups.replay(
                this.eventStore.getInitialState(),
                this.pendingRegroup ? [...events, this.pendingRegroup] : events,
            );
        });
        this.replayDirty = false;
        return this.cachedScene;

        this.cachedScene = perf.span("proc.replay", () => {
            const events = this.eventStore.getEventsToReplay();
            const stored = this.pendingRegroup ? [...events, this.pendingRegroup] : events;
            this.groups.reset();
            this.groups.applyEvents(stored);
            // Replay-time derivation: registered modules inject ephemeral geometry (e.g. auto Stehers)
            // on top of the assembled model, so docking and materialisation see it. Never stored —
            // recomputed here on every replay, which is exactly what keeps it coupled to the base.
            for (const m of this.modules) m.derive?.(this.groups);
            return this.groups.materializeScene(this.eventStore.getInitialState());
        });
        this.replayDirty = false;
        return this.cachedScene;


        this.cachedScene = perf.span("proc.replay", () => {
            const events = this.eventStore.getEventsToReplay();
            return this.groups.replay(
                this.eventStore.getInitialState(),
                this.pendingRegroup ? [...events, this.pendingRegroup] : events,
            );
        });
        this.replayDirty = false;
        return this.cachedScene;

        this.cachedScene = perf.span("proc.replay", () => {
            const events = this.eventStore.getEventsToReplay();
            const stored = this.pendingRegroup ? [...events, this.pendingRegroup] : events;
            this.groups.reset();
            this.groups.applyEvents(stored);
            // Replay-time derivation: registered modules inject ephemeral geometry (e.g. auto Stehers)
            // on top of the assembled model, so docking and materialisation see it. Never stored —
            // recomputed here on every replay, which is exactly what keeps it coupled to the base.
            for (const m of this.modules) m.derive?.(this.groups);
            return this.groups.materializeScene(this.eventStore.getInitialState());
        });
        this.replayDirty = false;
        return this.cachedScene;

if (!this.replayDirty && this.cachedScene) return this.cachedScene;

-                this.eventStore.getInitialState(),

-                this.pendingRegroup ? [...events, this.pendingRegroup] : events,

+            const stored = this.pendingRegroup ? [...events, this.pendingRegroup] : events;

+            this.groups.applyEvents(stored);

+            // Replay-time derivation: registered modules inject ephemeral geometry (e.g. auto Stehers)

+            // on top of the assembled model, so docking and materialisation see it. Never stored —

+            // recomputed here on every replay, which is exactly what keeps it coupled to the base.

+            for (const m of this.modules) m.derive?.(this.groups);

+            return this.groups.materializeScene(this.eventStore.getInitialState());


GEKO-Teil. Ich prüfe kurz, woher der `Scene`-Typ kommt, um ihn im Modul zu importieren.


Bash

sed -n '1,27p' packages/frontend/src/configurator/model/GroupProcessor.ts | grep -nE 'import|Scene|Handle|export type Scene'

Find Scene type origin


1:import {
18:type Handle = any;
19:type Scene = any;


`Scene`/`Handle` sind lokal `any` (nicht exportiert) — ich nutze im Modul `any` für Szenen. Jetzt die GEKO-Ableitung + Override-Hook.


Edit

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

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


The file /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/modules/GekoPlacementModule.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 type { GroupEvent } from "../model/GroupProcessor";

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

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

import { extractPlacementData } from "../events/eventData";

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


export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;

export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;

// A rule that induces auto-Stehers: on every element carrying `anchorClass`, a Steher is derived at
// each `anchorPmis` point and docked via `joinPmi`. `defaultPart` is the derived Steher's product
// scene; a manual override may swap it for one of `parts` (keyed by an override id, e.g. a length) or
// suppress the slot. Rules are client-supplied data (geko), like dockingRules — cpq stays agnostic.
export interface SteherRule {
    anchorClass: string;
    anchorPmis: string[];
    joinPmi: string;
    defaultPart: any;
    parts?: Record<string, any>;
}
export type SteherRules = SteherRule[];

// The override payload filed per slot via a module-event (undo-able). `part` selects a `parts` entry;
// `suppressed` removes the derived Steher entirely.
type SteherOverride = { part?: string; suppressed?: boolean };

const STEHER_MODULE_ID = "geko-steher";


export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;

// A rule that induces auto-Stehers: on every element carrying `anchorClass`, a Steher is derived at
// each `anchorPmis` point and docked via `joinPmi`. `defaultPart` is the derived Steher's product
// scene; a manual override may swap it for one of `parts` (keyed by an override id, e.g. a length) or
// suppress the slot. Rules are client-supplied data (geko), like dockingRules — cpq stays agnostic.
export interface SteherRule {
    anchorClass: string;
    anchorPmis: string[];
    joinPmi: string;
    defaultPart: any;
    parts?: Record<string, any>;
}
export type SteherRules = SteherRule[];

// The override payload filed per slot via a module-event (undo-able). `part` selects a `parts` entry;
// `suppressed` removes the derived Steher entirely.
type SteherOverride = { part?: string; suppressed?: boolean };

const STEHER_MODULE_ID = "geko-steher";

// Classes match the `class-…` tags on part root handles (e.g. "schutzzaun_allround-steher").

+// A rule that induces auto-Stehers: on every element carrying `anchorClass`, a Steher is derived at

+// each `anchorPmis` point and docked via `joinPmi`. `defaultPart` is the derived Steher's product

+// scene; a manual override may swap it for one of `parts` (keyed by an override id, e.g. a length) or

+// suppress the slot. Rules are client-supplied data (geko), like dockingRules — cpq stays agnostic.

+export type SteherRules = SteherRule[];

+// The override payload filed per slot via a module-event (undo-able). `part` selects a `parts` entry;

+// `suppressed` removes the derived Steher entirely.

+type SteherOverride = { part?: string; suppressed?: boolean };

+const STEHER_MODULE_ID = "geko-steher";

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


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

    // dockingRules and steherRules come from the cpq client (geko) — cpq itself is domain-agnostic.
    // No steherRules → derive() is inert.
    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
    ) {}


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

    // dockingRules and steherRules come from the cpq client (geko) — cpq itself is domain-agnostic.
    // No steherRules → derive() is inert.
    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
    ) {}

export class GekoPlacementModule implements PlacementModule {

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

-    constructor(private readonly dockingRules: DockingRules = {}) {}

+    // dockingRules and steherRules come from the cpq client (geko) — cpq itself is domain-agnostic.

+    // No steherRules → derive() is inert.

+        private readonly dockingRules: DockingRules = {},

+        private readonly steherRules: SteherRules = [],


            "keyboard-build": {
                replace: (event, ctx) => this.keyboardBuild(event, ctx),
            },
        };
    }

            "keyboard-build": {
                replace: (event, ctx) => this.keyboardBuild(event, ctx),
            },
            // A manual override on a derived Steher (e.g. swap the 2.10 part for a 3.80 one, or suppress
            // it). Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-set-part": {
                replace: (event, ctx) => this.setSteherPart(event, ctx),
            },
        };
    }

    // ---- Steher derivation (replay-time) -----------------------------------------------------------

    // Runs inside replay(), after the stored events assembled `model`. For each anchor element it docks
    // a derived Steher at each rule PMI, honouring per-slot overrides. Applies create+merge directly to
    // the model (ephemeral, never stored) so docking/materialisation see the Stehers and they stay
    // coupled to their anchor group (a group-move on the base carries them along).
    derive(model: GroupProcessor): void {
        if (!this.steherRules.length) return;
        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        for (const rule of this.steherRules) {
            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;
                for (const pmi of rule.anchorPmis) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === pmi)) continue;
                    const slotId = `steher@${anchor}:${pmi}`;
                    const ov = overrides.get(slotId) as SteherOverride | undefined;
                    if (ov?.suppressed) continue;
                    const part = ov?.part ? (rule.parts?.[ov.part] ?? rule.defaultPart) : rule.defaultPart;
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: pmi,
                            joinPmiId: rule.joinPmi,
                            rot: model.dockRotation(anchor, pmi, createEv.rootHandleId, rule.joinPmi),
                        } as GroupEvent,
                    ]);
                }
            }
        }
    }

    // Record a manual Steher override for a slot (part swap or suppress). `part`/`suppressed` both empty
    // clears the override (back to the derived default). Undo-able; re-renders from the fresh model.
    private setSteherPart(event: any, ctx: CommandContext): UIReaction[] {
        const payload: SteherOverride | null =
            event.part == null && !event.suppressed ? null : { part: event.part, suppressed: event.suppressed };
        ctx.record({
            type: "module-event",
            timestamp: Date.now(),
            moduleId: STEHER_MODULE_ID,
            key: event.slotId,
            payload,
        } as GroupEvent);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }


            "keyboard-build": {
                replace: (event, ctx) => this.keyboardBuild(event, ctx),
            },
        };
    }

            "keyboard-build": {
                replace: (event, ctx) => this.keyboardBuild(event, ctx),
            },
            // A manual override on a derived Steher (e.g. swap the 2.10 part for a 3.80 one, or suppress
            // it). Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-set-part": {
                replace: (event, ctx) => this.setSteherPart(event, ctx),
            },
        };
    }

    // ---- Steher derivation (replay-time) -----------------------------------------------------------

    // Runs inside replay(), after the stored events assembled `model`. For each anchor element it docks
    // a derived Steher at each rule PMI, honouring per-slot overrides. Applies create+merge directly to
    // the model (ephemeral, never stored) so docking/materialisation see the Stehers and they stay
    // coupled to their anchor group (a group-move on the base carries them along).
    derive(model: GroupProcessor): void {
        if (!this.steherRules.length) return;
        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        for (const rule of this.steherRules) {
            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;
                for (const pmi of rule.anchorPmis) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === pmi)) continue;
                    const slotId = `steher@${anchor}:${pmi}`;
                    const ov = overrides.get(slotId) as SteherOverride | undefined;
                    if (ov?.suppressed) continue;
                    const part = ov?.part ? (rule.parts?.[ov.part] ?? rule.defaultPart) : rule.defaultPart;
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: pmi,
                            joinPmiId: rule.joinPmi,
                            rot: model.dockRotation(anchor, pmi, createEv.rootHandleId, rule.joinPmi),
                        } as GroupEvent,
                    ]);
                }
            }
        }
    }

    // Record a manual Steher override for a slot (part swap or suppress). `part`/`suppressed` both empty
    // clears the override (back to the derived default). Undo-able; re-renders from the fresh model.
    private setSteherPart(event: any, ctx: CommandContext): UIReaction[] {
        const payload: SteherOverride | null =
            event.part == null && !event.suppressed ? null : { part: event.part, suppressed: event.suppressed };
        ctx.record({
            type: "module-event",
            timestamp: Date.now(),
            moduleId: STEHER_MODULE_ID,
            key: event.slotId,
            payload,
        } as GroupEvent);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }

+            // A manual override on a derived Steher (e.g. swap the 2.10 part for a 3.80 one, or suppress

+            // it). Recorded as an undo-able module-event; the derived geometry itself is never stored.

+                replace: (event, ctx) => this.setSteherPart(event, ctx),

+    // Runs inside replay(), after the stored events assembled `model`. For each anchor element it docks

+    // a derived Steher at each rule PMI, honouring per-slot overrides. Applies create+merge directly to

+    // the model (ephemeral, never stored) so docking/materialisation see the Stehers and they stay

+    // coupled to their anchor group (a group-move on the base carries them along).

+    derive(model: GroupProcessor): void {

+        if (!this.steherRules.length) return;

+        const overrides = model.moduleStateOf(STEHER_MODULE_ID);

+        for (const rule of this.steherRules) {

+            for (const anchor of model.elementsOfClass(rule.anchorClass)) {

+                const anchorGroup = model.groupIdOfElement(anchor);

+                if (!anchorGroup) continue;

+                for (const pmi of rule.anchorPmis) {

+                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →

+                    // no derived Steher.

+                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === pmi)) continue;

+                    const slotId = `steher@${anchor}:${pmi}`;

+                    const ov = overrides.get(slotId) as SteherOverride | undefined;

+                    if (ov?.suppressed) continue;

+                    const part = ov?.part ? (rule.parts?.[ov.part] ?? rule.defaultPart) : rule.defaultPart;

+                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,

+                    // override binding). Apply the create first so the Steher's PMI frames are in the

+                    // model before dockRotation reads them, then merge it into the anchor's group.

+                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);

+                    model.applyEvents([createEv]);

+                    model.applyEvents([

+                            type: "group-merge",

+                            timestamp: 0,

+                            targetGroupId: anchorGroup,

+                            movingGroupId: createEv.groupId,

+                            anchorRootId: anchor,

+                            joinRootId: createEv.rootHandleId,

+                            anchorPmiId: pmi,

+                            joinPmiId: rule.joinPmi,

+                            rot: model.dockRotation(anchor, pmi, createEv.rootHandleId, rule.joinPmi),

+                        } as GroupEvent,

+    // Record a manual Steher override for a slot (part swap or suppress). `part`/`suppressed` both empty

+    // clears the override (back to the derived default). Undo-able; re-renders from the fresh model.

+    private setSteherPart(event: any, ctx: CommandContext): UIReaction[] {

+        const payload: SteherOverride | null =

+            event.part == null && !event.suppressed ? null : { part: event.part, suppressed: event.suppressed };

+            moduleId: STEHER_MODULE_ID,

+        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];

// Intercept a click on one of our select-PMI marker spheres — dock a copy of the element there and


Export für geko und Zwischenprüfung (Typecheck + bestehende Tests, sollte inert sein).


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

export { GekoPlacementModule } from "./configurator/modules/GekoPlacementModule";
export type { DockingRules, SteherRule, SteherRules } from "./configurator/modules/GekoPlacementModule";


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 { GekoPlacementModule } from "./configurator/modules/GekoPlacementModule";
export type { DockingRules, SteherRule, SteherRules } from "./configurator/modules/GekoPlacementModule";

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

// 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/modules/GekoPlacementModule";

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


Bash

pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Typecheck and existing tests


 Test Files  8 passed (8)
      Tests  112 passed (112)
---done---


Kein Regress, inert wie geplant. Jetzt ein fokussierter Test, der die Ableitung beweist (Kopplung, Override/Suppress, Undo, deterministische Ids).


Write

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules } from "./GekoPlacementModule";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (reusing the same
// scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open CS_OUT_R_0 end via
// the part's CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_basic-tuer", anchorPmis: ["CS_OUT_R_0"], joinPmi: "CS_EIN_L", defaultPart: productData },
];

const product = () => structuredClone(productData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const steherOf = (scene: any) => parts(scene).find((id) => id.startsWith("h-steher"));
const anchorOf = (scene: any) => parts(scene).find((id) => !id.startsWith("h-steher"));

function setup() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    const anchor = anchorOf(scene)!;
    const slotId = `steher@${anchor}:CS_OUT_R_0`;
    return { proc, scene, anchor, slotId };
}

describe("Steher derivation", () => {
    it("derives a Steher coupled to the anchor's group", () => {
        const { scene, anchor } = setup();
        const p = parts(scene);
        expect(p).toHaveLength(2); // anchor + derived Steher
        const steher = steherOf(scene)!;
        // Coupled: the Steher sits in the anchor's group node → a group-move on the base carries it along.
        expect(groupNodeOf(scene, steher)).toBe(groupNodeOf(scene, anchor));
    });

    it("is never recorded — no undo step exists for the derived Steher", () => {
        const { proc, scene } = setup();
        expect(parts(scene)).toHaveLength(2);
        // Only the add-product is on the stack; undo removes the anchor (and with it the derived Steher).
        const back = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(back)).toHaveLength(0);
    });

    it("a suppress override removes the Steher and is undo-able", () => {
        const { proc, slotId } = setup();
        const suppressed = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true }));
        expect(parts(suppressed)).toHaveLength(1); // Steher gone, anchor stays
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(undone)).toHaveLength(2); // override reverted → derived again
    });

    it("clearing the override brings the Steher back with the SAME id (deterministic across replays)", () => {
        const { proc, scene, slotId } = setup();
        const steherId = steherOf(scene)!;
        proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true });
        const cleared = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId })); // payload → clear
        expect(parts(cleared)).toHaveLength(2);
        expect(steherOf(cleared)).toBe(steherId); // re-derived with identical id, not churned
    });
});


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

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules } from "./GekoPlacementModule";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (reusing the same
// scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open CS_OUT_R_0 end via
// the part's CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_basic-tuer", anchorPmis: ["CS_OUT_R_0"], joinPmi: "CS_EIN_L", defaultPart: productData },
];

const product = () => structuredClone(productData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const steherOf = (scene: any) => parts(scene).find((id) => id.startsWith("h-steher"));
const anchorOf = (scene: any) => parts(scene).find((id) => !id.startsWith("h-steher"));

function setup() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    const anchor = anchorOf(scene)!;
    const slotId = `steher@${anchor}:CS_OUT_R_0`;
    return { proc, scene, anchor, slotId };
}

describe("Steher derivation", () => {
    it("derives a Steher coupled to the anchor's group", () => {
        const { scene, anchor } = setup();
        const p = parts(scene);
        expect(p).toHaveLength(2); // anchor + derived Steher
        const steher = steherOf(scene)!;
        // Coupled: the Steher sits in the anchor's group node → a group-move on the base carries it along.
        expect(groupNodeOf(scene, steher)).toBe(groupNodeOf(scene, anchor));
    });

    it("is never recorded — no undo step exists for the derived Steher", () => {
        const { proc, scene } = setup();
        expect(parts(scene)).toHaveLength(2);
        // Only the add-product is on the stack; undo removes the anchor (and with it the derived Steher).
        const back = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(back)).toHaveLength(0);
    });

    it("a suppress override removes the Steher and is undo-able", () => {
        const { proc, slotId } = setup();
        const suppressed = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true }));
        expect(parts(suppressed)).toHaveLength(1); // Steher gone, anchor stays
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(undone)).toHaveLength(2); // override reverted → derived again
    });

    it("clearing the override brings the Steher back with the SAME id (deterministic across replays)", () => {
        const { proc, scene, slotId } = setup();
        const steherId = steherOf(scene)!;
        proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true });
        const cleared = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId })); // payload → clear
        expect(parts(cleared)).toHaveLength(2);
        expect(steherOf(cleared)).toBe(steherId); // re-derived with identical id, not churned
    });
});


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/GekoSteher.test.ts 2>&1 | grep -E '✓|✗|×|passed|failed|Error|Expected|toHaveLength|AssertionError' | head -30

Run Steher derivation test


 Test Files  1 passed (1)
      Tests  4 passed (4)


Alle 4 Steher-Tests grün. Volle Suite + Typecheck zur Absicherung.


pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Full suite and typecheck


 Test Files  9 passed (9)
      Tests  116 passed (116)
---done---


Gebaut und getestet — 116 Tests grün (4 neue), Typecheck sauber.

**Was jetzt existiert**

*Infrastruktur (domänen-agnostisch, Core):*
- `GroupProcessor`: Replay in `reset` → `applyEvents` → `materializeScene` aufgetrennt; `instantiateSceneDeterministic` + `buildCreateEventDeterministic` (Ids aus Slot-Seed, stabil über Replays); `module-event`-Typ + `moduleState`/`moduleStateOf` (undo-bare Override-Ablage, die der Core nicht interpretiert).
- `PlacementModule.derive?(model)` — die vierte, replay-zeitige Fähigkeit.
- `DefaultCommandProcessor.replay()`: drei Phasen — stored anwenden → Module `derive()` → materialisieren. Hängt am vorhandenen Cache; Override-Events invalidieren ihn.

*GEKO (`GekoPlacementModule`, config-getrieben, per Default inert):*
- `derive()`: pro Anker-Element (Klasse) an jedem Regel-PMI einen Steher deterministisch instanziieren, in die **Anker-Gruppe** mergen → gekoppelt (Move der Basis trägt ihn mit). Belegte Anker werden übersprungen.
- Override via `steher-set-part`-Event → `module-event` (undo-bar): **Part-Swap** (`parts[id]` = abweichendes GLB, weiterhin slot-gekoppelt) oder **suppress**. Genau dein 2,10→3,80-Fall.

*Bewiesen durch Tests:* Ableitung + Kopplung (gleicher Gruppenknoten), keine Undo-Spur für den abgeleiteten Steher, Suppress + Undo, deterministische Id nach Override-Clear.

**Was zum Scharfschalten fehlt (Daten/Wiring, kein Code):**
1. Die realen `SteherRules`: welche `anchorClass`, welche `anchorPmis`, `joinPmi`, `defaultPart` (2,10-GLB) und `parts` (z. B. `"3800"` → 3,80-GLB). Kommt vom geko-Client wie `dockingRules`. Die brauche ich von dir.
2. Das UI, das die manuelle Änderung als `{ eventTypeName: "steher-set-part", slotId, part }` dispatcht.

**Bewusst noch offen:** *An* einen abgeleiteten Steher bauen (nicht nur resizen) — das braucht die Promotion-Regel aus unserer Diskussion. Der Resize/Swap-Fall läuft; das Anbauen an einen abgeleiteten Steher ist der nächste Schritt, falls gebraucht.

Commit auf dein Wort.


ok -- der hook läuft nicht los


Wahrscheinlich erreichen die `SteherRules` das Modul gar nicht — ich habe den Konstruktor um das zweite Argument erweitert, aber die Stellen, die das Modul bauen (`ProductConfigurator.vue`, Stories), übergeben nur `dockingRules`. Ich prüfe, wie es aktuell verdrahtet ist.


echo "=== wie wird GekoPlacementModule konstruiert? ===" && grep -rn 'new GekoPlacementModule' packages/frontend/src; echo "=== steher/SteherRules irgendwo verdrahtet? ==="; grep -rniE 'steher|steherRules|steher-set-part' packages/frontend/src packages/frontend/.storybook 2>/dev/null | grep -viE 'GekoSteher.test|GekoPlacementModule.ts' | head

Check how the module is constructed and whether steher rules are wired


=== wie wird GekoPlacementModule konstruiert? ===
packages/frontend/src/components/visual/ProductConfigurator.vue:20:processor.register(new GekoPlacementModule(props.dockingRules));
packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts:52:    proc.register(new GekoPlacementModule(DOCKING_RULES));
packages/frontend/src/configurator/modules/GekoSteher.test.ts:26:    proc.register(new GekoPlacementModule({}, RULES));
=== steher/SteherRules irgendwo verdrahtet? ===
packages/frontend/src/index.ts:20:export type { DockingRules, SteherRule, SteherRules } from "./configurator/modules/GekoPlacementModule";
packages/frontend/src/configurator/model/GroupProcessor.ts:70:// A derivation (e.g. auto-inserted Stehers) recomputes the same element on every replay; random ids
packages/frontend/src/configurator/model/GroupProcessor.ts:177:// An opaque, undo-able state entry owned by a module (e.g. a manual Steher override). The core stores
packages/frontend/src/configurator/events/PlacementModule.ts:45:// model, and injects *derived* geometry (e.g. auto-inserted Stehers) directly into the model so
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:99:            // Replay-time derivation: registered modules inject ephemeral geometry (e.g. auto Stehers)
packages/frontend/.storybook/products/catalog.json:2921:                    "name": "Steher",
packages/frontend/.storybook/products/catalog.json:2936:                                    "stammdatenBild": "allround_steher.png",
packages/frontend/.storybook/products/catalog.json:2951:                                    "stammdatenBild": "allround_steher.png",
packages/frontend/.storybook/products/catalog.json:2966:                                    "stammdatenBild": "allround_steher.png",
packages/frontend/.storybook/products/catalog.json:2981:                                    "stammdatenBild": "allround_steher.png",


Gefunden: `ProductConfigurator.vue` baut das Modul mit **nur** `dockingRules` — es gibt keinen Pfad, über den `steherRules` das Modul erreichen. Deshalb ist `derive()` inert („läuft nicht los"). Ich lege den Prop-Durchgriff an.


1	<script setup lang="ts">
2	import { onMounted, ref, watch } from "vue";
3	import Fire3D from "./Fire3D.vue";
4	import { DefaultProductService, FetchAssetLoader, type ProductService } from "../../services/product-service";
5	import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
6	import { GekoPlacementModule, type DockingRules } from "../../configurator/modules/GekoPlacementModule";
7
8	const props = defineProps<{
9	    productService?: ProductService;
10	    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).
11	    dockingRules?: DockingRules;
12	}>();
13
14	const config = ref<Record<string, unknown> | null>(null);
15	const fire = ref<InstanceType<typeof Fire3D> | null>(null);
16
17	const productService = props.productService ?? new DefaultProductService(new FetchAssetLoader("/products/"));
18	// The geko client wires its placement/docking behaviour in as a module; the core stays domain-agnostic.
19	const processor = new DefaultCommandProcessor();
20	processor.register(new GekoPlacementModule(props.dockingRules));
21
22	// Leerer Graph als Replay-Basis. Produkte kommen als add-product-Events obendrauf.
23	const EMPTY = { _resourceClass: "sceneGraph_entity", handles: [] };
24

<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import Fire3D from "./Fire3D.vue";
import { DefaultProductService, FetchAssetLoader, type ProductService } from "../../services/product-service";
import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
import { GekoPlacementModule, type DockingRules } from "../../configurator/modules/GekoPlacementModule";

const props = defineProps<{
    productService?: ProductService;
    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).
    dockingRules?: DockingRules;
}>();

const config = ref<Record<string, unknown> | null>(null);
const fire = ref<InstanceType<typeof Fire3D> | null>(null);

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

// Leerer Graph als Replay-Basis. Produkte kommen als add-product-Events obendrauf.
const EMPTY = { _resourceClass: "sceneGraph_entity", handles: [] };


Edit

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

const props = defineProps<{
    productService?: ProductService;
    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).
    dockingRules?: DockingRules;
}>();

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

const props = defineProps<{
    productService?: ProductService;
    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).
    dockingRules?: DockingRules;
    // Steher-derivation rules (auto-inserted Stehers). Empty/absent → no derivation.
    steherRules?: SteherRules;
}>();


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 { GekoPlacementModule, type DockingRules } from "../../configurator/modules/GekoPlacementModule";

const props = defineProps<{
    productService?: ProductService;
    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).
    dockingRules?: DockingRules;
}>();

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

const props = defineProps<{
    productService?: ProductService;
    // Placement/docking rules supplied by the cpq client (domain knowledge lives there, not in cpq).
    dockingRules?: DockingRules;
    // Steher-derivation rules (auto-inserted Stehers). Empty/absent → no derivation.
    steherRules?: SteherRules;
}>();

<template>

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

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

</template>

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

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

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

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

+    // Steher-derivation rules (auto-inserted Stehers). Empty/absent → no derivation.

const config = ref<Record<string, unknown> | null>(null);


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

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

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


Prop-Pfad steht. Typecheck zur Sicherheit.


Bash

pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Typecheck after prop wiring


---done---


Ursache: Es gab keinen Prop-Pfad — `steherRules` erreichte das Modul nie, also lief `derive()` leer. Jetzt hat `ProductConfigurator` ein `:steher-rules`-Prop, das ans Modul durchgereicht wird. Typecheck sauber.

Damit die Ableitung tatsächlich Steher zieht, fehlen noch zwei Dinge:

1. **Die Story (bzw. geko) muss `:steher-rules` übergeben.** Analog zu `dockingRules` — geladen aus JSON und als Prop rein. Ohne das bleibt es korrekt inert.

2. **Die konkreten Regelwerte**, die ich nicht raten will:
   - `anchorClass` — welches Element induziert einen Steher (z. B. `schutzzaun_allround-...`)?
   - `anchorPmis` — an welchen PMIs (z. B. `CS_OUT_R_0`, `CS_OUT_L_0`)?
   - `joinPmi` — mit welchem eigenen PMI dockt der Steher an?
   - `defaultPart` — welches Katalog-Produkt ist der Default-Steher (im Katalog gibt es eine „Steher"-Gruppe mit `allround_steher`)?
   - `parts` — optional die Override-Varianten (2,10 → 3,80).

**Ein Caveat, das hier greift:** `derive()` sieht nur Elemente, die als **Events** ins Modell kamen (`add-product`), nicht solche aus einer per `setScene` geladenen Basis-Szene. In der Configurator-Story kommen Produkte über `add-product` → passt. Falls du in einer Story mit vorgeladener Szene testest, sieht die Ableitung dort nichts.

Gib mir die fünf Regelwerte (oder sag „nimm den `allround_steher` aus dem Katalog auf `CS_OUT_*` von Klasse X"), dann verdrahte ich die Story und du siehst die Steher.


schutzzaun-allroaund/Element
gute Frage -> standard rechts / links (da kommt noch mehr logik)
der passende ein_l ein_r
der Steher ist immer der Sther, der dei passende Anzahl an slots für die anliegenden Zäune verfügt
parts --> DAs SOLLST du nicht wissen


Klar. Die Kernaussage: **die Part-Wahl (Slot-Anzahl passend zu den anliegenden Zäunen) ist geko-Sache — „das sollst du nicht wissen".** Also darf mein Modul keine Parts kennen. Ich drehe die Konfiguration um: statt fester `defaultPart`/`parts` bekommt das Modul einen **von geko injizierten Resolver**, der pro Slot das passende Steher-Produkt liefert. Join pro Seite (R→`CS_EIN_L`, L→`CS_EIN_R`).

Ich passe das Interface an.


Edit

// A rule that induces auto-Stehers: on every element carrying `anchorClass`, a Steher is derived at
// each `anchorPmis` point and docked via `joinPmi`. `defaultPart` is the derived Steher's product
// scene; a manual override may swap it for one of `parts` (keyed by an override id, e.g. a length) or
// suppress the slot. Rules are client-supplied data (geko), like dockingRules — cpq stays agnostic.
export interface SteherRule {
    anchorClass: string;
    anchorPmis: string[];
    joinPmi: string;
    defaultPart: any;
    parts?: Record<string, any>;
}
export type SteherRules = SteherRule[];

// The override payload filed per slot via a module-event (undo-able). `part` selects a `parts` entry;
// `suppressed` removes the derived Steher entirely.
type SteherOverride = { part?: string; suppressed?: boolean };

const STEHER_MODULE_ID = "geko-steher";

// A rule that induces auto-Stehers: on every element carrying `anchorClass`, a Steher is derived at
// each open anchor PMI in `joins`, docked via that PMI's mapped join PMI (side-dependent, e.g.
// CS_OUT_R_0 → CS_EIN_L, CS_OUT_L_0 → CS_EIN_R). Rules are client data (geko) like dockingRules.
export interface SteherRule {
    anchorClass: string;
    joins: Record<string, string>; // anchorPmi → the Steher's own join PMI for that side
}
export type SteherRules = SteherRule[];

// What the module hands geko to pick the part. cpq stays part-agnostic on purpose: which concrete
// Steher (its slot count matching the adjacent fences) belongs at a slot is geko's decision.
export interface SteherContext {
    model: GroupProcessor;
    anchor: string; // anchor element root id
    anchorPmi: string; // the open anchor PMI (side)
    slotId: string; // stable id of this Steher slot
    override: unknown; // the module-event payload filed for this slot (geko-defined), or undefined
}

// geko supplies this: given the slot, return the Steher's product scene — or null to place none. The
// module never names or selects parts; it only derives the slot and docks whatever geko returns.
export type SteherPartResolver = (ctx: SteherContext) => any | null;

const STEHER_MODULE_ID = "geko-steher";


// A rule that induces auto-Stehers: on every element carrying `anchorClass`, a Steher is derived at
// each `anchorPmis` point and docked via `joinPmi`. `defaultPart` is the derived Steher's product
// scene; a manual override may swap it for one of `parts` (keyed by an override id, e.g. a length) or
// suppress the slot. Rules are client-supplied data (geko), like dockingRules — cpq stays agnostic.
export interface SteherRule {
    anchorClass: string;
    anchorPmis: string[];
    joinPmi: string;
    defaultPart: any;
    parts?: Record<string, any>;
}
export type SteherRules = SteherRule[];

// The override payload filed per slot via a module-event (undo-able). `part` selects a `parts` entry;
// `suppressed` removes the derived Steher entirely.
type SteherOverride = { part?: string; suppressed?: boolean };

const STEHER_MODULE_ID = "geko-steher";

// A rule that induces auto-Stehers: on every element carrying `anchorClass`, a Steher is derived at
// each open anchor PMI in `joins`, docked via that PMI's mapped join PMI (side-dependent, e.g.
// CS_OUT_R_0 → CS_EIN_L, CS_OUT_L_0 → CS_EIN_R). Rules are client data (geko) like dockingRules.
export interface SteherRule {
    anchorClass: string;
    joins: Record<string, string>; // anchorPmi → the Steher's own join PMI for that side
}
export type SteherRules = SteherRule[];

// What the module hands geko to pick the part. cpq stays part-agnostic on purpose: which concrete
// Steher (its slot count matching the adjacent fences) belongs at a slot is geko's decision.
export interface SteherContext {
    model: GroupProcessor;
    anchor: string; // anchor element root id
    anchorPmi: string; // the open anchor PMI (side)
    slotId: string; // stable id of this Steher slot
    override: unknown; // the module-event payload filed for this slot (geko-defined), or undefined
}

// geko supplies this: given the slot, return the Steher's product scene — or null to place none. The
// module never names or selects parts; it only derives the slot and docks whatever geko returns.
export type SteherPartResolver = (ctx: SteherContext) => any | null;

const STEHER_MODULE_ID = "geko-steher";

export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;

-// each `anchorPmis` point and docked via `joinPmi`. `defaultPart` is the derived Steher's product

-// scene; a manual override may swap it for one of `parts` (keyed by an override id, e.g. a length) or

-// suppress the slot. Rules are client-supplied data (geko), like dockingRules — cpq stays agnostic.

+// each open anchor PMI in `joins`, docked via that PMI's mapped join PMI (side-dependent, e.g.

+// CS_OUT_R_0 → CS_EIN_L, CS_OUT_L_0 → CS_EIN_R). Rules are client data (geko) like dockingRules.

+    joins: Record<string, string>; // anchorPmi → the Steher's own join PMI for that side

-// The override payload filed per slot via a module-event (undo-able). `part` selects a `parts` entry;

-// `suppressed` removes the derived Steher entirely.

-type SteherOverride = { part?: string; suppressed?: boolean };

+// What the module hands geko to pick the part. cpq stays part-agnostic on purpose: which concrete

+// Steher (its slot count matching the adjacent fences) belongs at a slot is geko's decision.

+    anchor: string; // anchor element root id

+    anchorPmi: string; // the open anchor PMI (side)

+    slotId: string; // stable id of this Steher slot

+    override: unknown; // the module-event payload filed for this slot (geko-defined), or undefined

+// geko supplies this: given the slot, return the Steher's product scene — or null to place none. The

+// module never names or selects parts; it only derives the slot and docks whatever geko returns.

+export type SteherPartResolver = (ctx: SteherContext) => any | null;

// The geko-specific placement/docking behaviour, lifted out of the domain-agnostic core: three-point


schutzzaun-allroaund/Element gute Frage -> standard rechts / links (da kommt noch mehr logik) der passende ein_l ein_r der Steher ist immer der Sther, der dei passende Anzahl an slots für die anliegen…


    // dockingRules and steherRules come from the cpq client (geko) — cpq itself is domain-agnostic.
    // No steherRules → derive() is inert.
    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
    ) {}

    // dockingRules, steherRules and the part resolver come from the cpq client (geko) — cpq itself is
    // domain-agnostic. No steherRules or no resolver → derive() is inert.
    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
        private readonly resolveSteherPart?: SteherPartResolver,
    ) {}


    // dockingRules and steherRules come from the cpq client (geko) — cpq itself is domain-agnostic.
    // No steherRules → derive() is inert.
    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
    ) {}

    // dockingRules, steherRules and the part resolver come from the cpq client (geko) — cpq itself is
    // domain-agnostic. No steherRules or no resolver → derive() is inert.
    constructor(
        private readonly dockingRules: DockingRules = {},
        private readonly steherRules: SteherRules = [],
        private readonly resolveSteherPart?: SteherPartResolver,
    ) {}

// 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 and steherRules come from the cpq client (geko) — cpq itself is domain-agnostic.

-    // No steherRules → derive() is inert.

+    // dockingRules, steherRules and the part resolver come from the cpq client (geko) — cpq itself is

+    // domain-agnostic. No steherRules or no resolver → derive() is inert.

+        private readonly resolveSteherPart?: SteherPartResolver,


    derive(model: GroupProcessor): void {
        if (!this.steherRules.length) return;
        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        for (const rule of this.steherRules) {
            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;
                for (const pmi of rule.anchorPmis) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === pmi)) continue;
                    const slotId = `steher@${anchor}:${pmi}`;
                    const ov = overrides.get(slotId) as SteherOverride | undefined;
                    if (ov?.suppressed) continue;
                    const part = ov?.part ? (rule.parts?.[ov.part] ?? rule.defaultPart) : rule.defaultPart;
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: pmi,
                            joinPmiId: rule.joinPmi,
                            rot: model.dockRotation(anchor, pmi, createEv.rootHandleId, rule.joinPmi),
                        } as GroupEvent,
                    ]);
                }
            }
        }
    }

    derive(model: GroupProcessor): void {
        if (!this.steherRules.length || !this.resolveSteherPart) return;
        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        for (const rule of this.steherRules) {
            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;
                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === anchorPmi)) continue;
                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // geko picks the part (its slot count matching the adjacent fences) — or null to skip.
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId) });
                    if (!part) continue;
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: anchorPmi,
                            joinPmiId: joinPmi,
                            rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),
                        } as GroupEvent,
                    ]);
                }
            }
        }
    }


    derive(model: GroupProcessor): void {
        if (!this.steherRules.length) return;
        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        for (const rule of this.steherRules) {
            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;
                for (const pmi of rule.anchorPmis) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === pmi)) continue;
                    const slotId = `steher@${anchor}:${pmi}`;
                    const ov = overrides.get(slotId) as SteherOverride | undefined;
                    if (ov?.suppressed) continue;
                    const part = ov?.part ? (rule.parts?.[ov.part] ?? rule.defaultPart) : rule.defaultPart;
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: pmi,
                            joinPmiId: rule.joinPmi,
                            rot: model.dockRotation(anchor, pmi, createEv.rootHandleId, rule.joinPmi),
                        } as GroupEvent,
                    ]);
                }
            }
        }
    }

    derive(model: GroupProcessor): void {
        if (!this.steherRules.length || !this.resolveSteherPart) return;
        const overrides = model.moduleStateOf(STEHER_MODULE_ID);
        for (const rule of this.steherRules) {
            for (const anchor of model.elementsOfClass(rule.anchorClass)) {
                const anchorGroup = model.groupIdOfElement(anchor);
                if (!anchorGroup) continue;
                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {
                    // Occupied anchor (something already docked there, incl. a promoted concrete part) →
                    // no derived Steher.
                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === anchorPmi)) continue;
                    const slotId = `steher@${anchor}:${anchorPmi}`;
                    // geko picks the part (its slot count matching the adjacent fences) — or null to skip.
                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId) });
                    if (!part) continue;
                    // Deterministic ids keyed on the slot → stable across replays (selection, docking,
                    // override binding). Apply the create first so the Steher's PMI frames are in the
                    // model before dockRotation reads them, then merge it into the anchor's group.
                    const createEv = model.buildCreateEventDeterministic(part, [0, 0, 0], slotId);
                    model.applyEvents([createEv]);
                    model.applyEvents([
                        {
                            type: "group-merge",
                            timestamp: 0,
                            targetGroupId: anchorGroup,
                            movingGroupId: createEv.groupId,
                            anchorRootId: anchor,
                            joinRootId: createEv.rootHandleId,
                            anchorPmiId: anchorPmi,
                            joinPmiId: joinPmi,
                            rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),
                        } as GroupEvent,
                    ]);
                }
            }
        }
    }

// the model (ephemeral, never stored) so docking/materialisation see the Stehers and they stay

// coupled to their anchor group (a group-move on the base carries them along).

-        if (!this.steherRules.length) return;

+        if (!this.steherRules.length || !this.resolveSteherPart) return;

-                for (const pmi of rule.anchorPmis) {

+                for (const [anchorPmi, joinPmi] of Object.entries(rule.joins)) {

-                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === pmi)) continue;

-                    const slotId = `steher@${anchor}:${pmi}`;

-                    const ov = overrides.get(slotId) as SteherOverride | undefined;

-                    if (ov?.suppressed) continue;

-                    const part = ov?.part ? (rule.parts?.[ov.part] ?? rule.defaultPart) : rule.defaultPart;

+                    if (model.occupiedPmis().some((o) => o.element === anchor && o.pmi === anchorPmi)) continue;

+                    const slotId = `steher@${anchor}:${anchorPmi}`;

+                    // geko picks the part (its slot count matching the adjacent fences) — or null to skip.

+                    const part = this.resolveSteherPart({ model, anchor, anchorPmi, slotId, override: overrides.get(slotId) });

+                    if (!part) continue;

-                            anchorPmiId: pmi,

-                            joinPmiId: rule.joinPmi,

-                            rot: model.dockRotation(anchor, pmi, createEv.rootHandleId, rule.joinPmi),

+                            anchorPmiId: anchorPmi,

+                            joinPmiId: joinPmi,

+                            rot: model.dockRotation(anchor, anchorPmi, createEv.rootHandleId, joinPmi),


Jetzt den Override-Command auf ein opakes, geko-definiertes Payload umstellen.


            // A manual override on a derived Steher (e.g. swap the 2.10 part for a 3.80 one, or suppress
            // it). Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-set-part": {
                replace: (event, ctx) => this.setSteherPart(event, ctx),
            },

            // A manual override on a derived Steher slot (geko-defined payload — e.g. a chosen length).
            // Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-override": {
                replace: (event, ctx) => this.setSteherOverride(event, ctx),
            },


            // A manual override on a derived Steher (e.g. swap the 2.10 part for a 3.80 one, or suppress
            // it). Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-set-part": {
                replace: (event, ctx) => this.setSteherPart(event, ctx),
            },

            // A manual override on a derived Steher slot (geko-defined payload — e.g. a chosen length).
            // Recorded as an undo-able module-event; the derived geometry itself is never stored.
            "steher-override": {
                replace: (event, ctx) => this.setSteherOverride(event, ctx),
            },

replace: (event, ctx) => this.keyboardBuild(event, ctx),

-            // A manual override on a derived Steher (e.g. swap the 2.10 part for a 3.80 one, or suppress

-            // it). Recorded as an undo-able module-event; the derived geometry itself is never stored.

-                replace: (event, ctx) => this.setSteherPart(event, ctx),

+            // A manual override on a derived Steher slot (geko-defined payload — e.g. a chosen length).

+            // Recorded as an undo-able module-event; the derived geometry itself is never stored.

+                replace: (event, ctx) => this.setSteherOverride(event, ctx),


    // Record a manual Steher override for a slot (part swap or suppress). `part`/`suppressed` both empty
    // clears the override (back to the derived default). Undo-able; re-renders from the fresh model.
    private setSteherPart(event: any, ctx: CommandContext): UIReaction[] {
        const payload: SteherOverride | null =
            event.part == null && !event.suppressed ? null : { part: event.part, suppressed: event.suppressed };
        ctx.record({
            type: "module-event",
            timestamp: Date.now(),
            moduleId: STEHER_MODULE_ID,
            key: event.slotId,
            payload,
        } as GroupEvent);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }

    // Record a manual override for a Steher slot. `event.payload` is geko-defined (the module does not
    // interpret it — the resolver does); null/absent clears the override back to the derived default.
    // Undo-able; re-renders from the fresh model.
    private setSteherOverride(event: any, ctx: CommandContext): UIReaction[] {
        ctx.record({
            type: "module-event",
            timestamp: Date.now(),
            moduleId: STEHER_MODULE_ID,
            key: event.slotId,
            payload: event.payload ?? null,
        } as GroupEvent);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }


    // Record a manual Steher override for a slot (part swap or suppress). `part`/`suppressed` both empty
    // clears the override (back to the derived default). Undo-able; re-renders from the fresh model.
    private setSteherPart(event: any, ctx: CommandContext): UIReaction[] {
        const payload: SteherOverride | null =
            event.part == null && !event.suppressed ? null : { part: event.part, suppressed: event.suppressed };
        ctx.record({
            type: "module-event",
            timestamp: Date.now(),
            moduleId: STEHER_MODULE_ID,
            key: event.slotId,
            payload,
        } as GroupEvent);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }

    // Record a manual override for a Steher slot. `event.payload` is geko-defined (the module does not
    // interpret it — the resolver does); null/absent clears the override back to the derived default.
    // Undo-able; re-renders from the fresh model.
    private setSteherOverride(event: any, ctx: CommandContext): UIReaction[] {
        ctx.record({
            type: "module-event",
            timestamp: Date.now(),
            moduleId: STEHER_MODULE_ID,
            key: event.slotId,
            payload: event.payload ?? null,
        } as GroupEvent);
        return [{ type: "sendCommand", cmd: "setScene", args: [ctx.replay()] }];
    }

-    // Record a manual Steher override for a slot (part swap or suppress). `part`/`suppressed` both empty

-    // clears the override (back to the derived default). Undo-able; re-renders from the fresh model.

-    private setSteherPart(event: any, ctx: CommandContext): UIReaction[] {

-        const payload: SteherOverride | null =

-            event.part == null && !event.suppressed ? null : { part: event.part, suppressed: event.suppressed };

+    // Record a manual override for a Steher slot. `event.payload` is geko-defined (the module does not

+    // interpret it — the resolver does); null/absent clears the override back to the derived default.

+    // Undo-able; re-renders from the fresh model.

+    private setSteherOverride(event: any, ctx: CommandContext): UIReaction[] {

+            payload: event.payload ?? null,


Export ergänzen und den Test auf das neue Interface (joins + Resolver + `steher-override`) umstellen.


export type { DockingRules, SteherRule, SteherRules } from "./configurator/modules/GekoPlacementModule";

export type { DockingRules, SteherRule, SteherRules, SteherContext, SteherPartResolver } from "./configurator/modules/GekoPlacementModule";


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 { DefaultCommandProcessor } from "./configurator/events/DefaultCommandProcessor";
export { GekoPlacementModule } from "./configurator/modules/GekoPlacementModule";
export type { DockingRules, SteherRule, SteherRules } from "./configurator/modules/GekoPlacementModule";
export type { PlacementModule, CommandContext } from "./configurator/events/PlacementModule";
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, SteherRule, SteherRules } from "./configurator/modules/GekoPlacementModule";

+export type { DockingRules, SteherRule, SteherRules, SteherContext, SteherPartResolver } from "./configurator/modules/GekoPlacementModule";


import { GekoPlacementModule, type SteherRules } from "./GekoPlacementModule";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (reusing the same
// scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open CS_OUT_R_0 end via
// the part's CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_basic-tuer", anchorPmis: ["CS_OUT_R_0"], joinPmi: "CS_EIN_L", defaultPart: productData },
];

import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (via the resolver,
// reusing the same scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open
// CS_OUT_R_0 end via CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [{ anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L" } }];

// Stand-in resolver: geko owns part choice. Here it returns the fixture scene, or null when the slot's
// override says "suppress".
const resolver: SteherPartResolver = (ctx) => (ctx.override === "suppress" ? null : productData);


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

import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (via the resolver,
// reusing the same scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open
// CS_OUT_R_0 end via CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [{ anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L" } }];

// Stand-in resolver: geko owns part choice. Here it returns the fixture scene, or null when the slot's
// override says "suppress".
const resolver: SteherPartResolver = (ctx) => (ctx.override === "suppress" ? null : productData);

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules } from "./GekoPlacementModule";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (reusing the same
// scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open CS_OUT_R_0 end via
// the part's CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [
    { anchorClass: "schutzzaun_basic-tuer", anchorPmis: ["CS_OUT_R_0"], joinPmi: "CS_EIN_L", defaultPart: productData },
];

const product = () => structuredClone(productData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const steherOf = (scene: any) => parts(scene).find((id) => id.startsWith("h-steher"));
const anchorOf = (scene: any) => parts(scene).find((id) => !id.startsWith("h-steher"));

function setup() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    const anchor = anchorOf(scene)!;
    const slotId = `steher@${anchor}:CS_OUT_R_0`;
    return { proc, scene, anchor, slotId };
}

describe("Steher derivation", () => {
    it("derives a Steher coupled to the anchor's group", () => {
        const { scene, anchor } = setup();
        const p = parts(scene);
        expect(p).toHaveLength(2); // anchor + derived Steher
        const steher = steherOf(scene)!;
        // Coupled: the Steher sits in the anchor's group node → a group-move on the base carries it along.
        expect(groupNodeOf(scene, steher)).toBe(groupNodeOf(scene, anchor));
    });

    it("is never recorded — no undo step exists for the derived Steher", () => {
        const { proc, scene } = setup();
        expect(parts(scene)).toHaveLength(2);
        // Only the add-product is on the stack; undo removes the anchor (and with it the derived Steher).
        const back = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(back)).toHaveLength(0);
    });

    it("a suppress override removes the Steher and is undo-able", () => {
        const { proc, slotId } = setup();
        const suppressed = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true }));
        expect(parts(suppressed)).toHaveLength(1); // Steher gone, anchor stays
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(undone)).toHaveLength(2); // override reverted → derived again
    });

    it("clearing the override brings the Steher back with the SAME id (deterministic across replays)", () => {
        const { proc, scene, slotId } = setup();
        const steherId = steherOf(scene)!;
        proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true });
        const cleared = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId })); // payload → clear
        expect(parts(cleared)).toHaveLength(2);
        expect(steherOf(cleared)).toBe(steherId); // re-derived with identical id, not churned
    });
});

-import { GekoPlacementModule, type SteherRules } from "./GekoPlacementModule";

+import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";

-// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (reusing the same

-// scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open CS_OUT_R_0 end via

-// the part's CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.

-    { anchorClass: "schutzzaun_basic-tuer", anchorPmis: ["CS_OUT_R_0"], joinPmi: "CS_EIN_L", defaultPart: productData },

+// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (via the resolver,

+// reusing the same scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open

+// CS_OUT_R_0 end via CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.

+const RULES: SteherRules = [{ anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L" } }];

+// Stand-in resolver: geko owns part choice. Here it returns the fixture scene, or null when the slot's

+const resolver: SteherPartResolver = (ctx) => (ctx.override === "suppress" ? null : productData);


proc.register(new GekoPlacementModule({}, RULES, resolver));


import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (via the resolver,
// reusing the same scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open
// CS_OUT_R_0 end via CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [{ anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L" } }];

// Stand-in resolver: geko owns part choice. Here it returns the fixture scene, or null when the slot's
// override says "suppress".
const resolver: SteherPartResolver = (ctx) => (ctx.override === "suppress" ? null : productData);

const product = () => structuredClone(productData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const steherOf = (scene: any) => parts(scene).find((id) => id.startsWith("h-steher"));
const anchorOf = (scene: any) => parts(scene).find((id) => !id.startsWith("h-steher"));

function setup() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    const anchor = anchorOf(scene)!;
    const slotId = `steher@${anchor}:CS_OUT_R_0`;
    return { proc, scene, anchor, slotId };
}

describe("Steher derivation", () => {
    it("derives a Steher coupled to the anchor's group", () => {
        const { scene, anchor } = setup();
        const p = parts(scene);
        expect(p).toHaveLength(2); // anchor + derived Steher
        const steher = steherOf(scene)!;
        // Coupled: the Steher sits in the anchor's group node → a group-move on the base carries it along.
        expect(groupNodeOf(scene, steher)).toBe(groupNodeOf(scene, anchor));
    });

    it("is never recorded — no undo step exists for the derived Steher", () => {
        const { proc, scene } = setup();
        expect(parts(scene)).toHaveLength(2);
        // Only the add-product is on the stack; undo removes the anchor (and with it the derived Steher).
        const back = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(back)).toHaveLength(0);
    });

    it("a suppress override removes the Steher and is undo-able", () => {
        const { proc, slotId } = setup();
        const suppressed = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true }));
        expect(parts(suppressed)).toHaveLength(1); // Steher gone, anchor stays
        const undone = sceneOf(proc.handleEvent({ eventTypeName: "undo" }));
        expect(parts(undone)).toHaveLength(2); // override reverted → derived again
    });

    it("clearing the override brings the Steher back with the SAME id (deterministic across replays)", () => {
        const { proc, scene, slotId } = setup();
        const steherId = steherOf(scene)!;
        proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true });
        const cleared = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId })); // payload → clear
        expect(parts(cleared)).toHaveLength(2);
        expect(steherOf(cleared)).toBe(steherId); // re-derived with identical id, not churned
    });
});

-    proc.register(new GekoPlacementModule({}, RULES));

+    proc.register(new GekoPlacementModule({}, RULES, resolver));


const suppressed = sceneOf(proc.handleEvent({ eventTypeName: "steher-set-part", slotId, suppressed: true }));

const suppressed = sceneOf(proc.handleEvent({ eventTypeName: "steher-override", slotId, payload: "suppress" }));