120
121	    // Record-time id allocation: a fresh UUID per graph id (handle, transform key, group id/key).
122	    // Unique across instances and replays (baked into the create event). The prefix keeps the id a
123	    // valid CSS identifier — a bare UUID can start with a digit, which breaks the "#id" selector.
124	    private nextId(prefix: string): string {
125	        return `${prefix}-${crypto.randomUUID()}`;
126	    }
127
128	    // ---- record-time -------------------------------------------------------
129
130	    // Build a create event from a raw product scene: opaquely re-identify every handle id and
131	    // transform key (source ids repeat across instances), find the element root, bake the prepared
132	    // subtree into the event so replay is deterministic without re-running the counter.
133	    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
134	        const { handles, rootHandleId } = this.reidentify(productScene);
135	        return {
136	            type: "group-create",
137	            timestamp: Date.now(),
138	            groupId: this.nextId("g"),
139	            groupKey: this.nextId("gk"),
140	            rootHandleId,
141	            handles,
142	            position,
143	            rotation: IDENTITY_QUAT,
144	        };
145	    }
146
147	    private reidentify(productScene: Scene): { handles: Handle[]; rootHandleId: string } {
148	        const src = structuredClone(productScene);
149	        const keyMap = new Map<string, string>();
150
151	        for (const handle of src.handles ?? []) {
152	            handle.id = this.nextId("h");
153	            for (const comp of handle.components ?? []) {
154	                if (comp._resourceClass === "component_transform" && comp.key) {
155	                    const newKey = this.nextId("k");
156	                    keyMap.set(comp.key, newKey);
157	                    comp.key = newKey;
158	                }
159	            }
160	        }
161
162	        let rootHandleId = "";
163	        for (const handle of src.handles ?? []) {
164	            for (const comp of handle.components ?? []) {
165	                if (comp._resourceClass !== "component_transform") continue;
166	                if (comp.parentKey) {
167	                    const mapped = keyMap.get(comp.parentKey);
168	                    if (mapped) comp.parentKey = mapped;
169	                } else {
170	                    // The root transform carries no parentKey → its handle is the element root.
171	                    rootHandleId = handle.id;
172	                }
173	            }
174	        }
175	        return { handles: src.handles, rootHandleId };
176	    }

    // Record-time id allocation: a fresh UUID per graph id (handle, transform key, group id/key).
    // Unique across instances and replays (baked into the create event). The prefix keeps the id a
    // valid CSS identifier — a bare UUID can start with a digit, which breaks the "#id" selector.
    private nextId(prefix: string): string {
        return `${prefix}-${crypto.randomUUID()}`;
    }

    // ---- record-time -------------------------------------------------------

    // Build a create event from a raw product scene: opaquely re-identify every handle id and
    // transform key (source ids repeat across instances), find the element root, bake the prepared
    // subtree into the event so replay is deterministic without re-running the counter.
    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = this.reidentify(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

    private reidentify(productScene: Scene): { handles: Handle[]; rootHandleId: string } {
        const src = structuredClone(productScene);
        const keyMap = new Map<string, string>();

        for (const handle of src.handles ?? []) {
            handle.id = this.nextId("h");
            for (const comp of handle.components ?? []) {
                if (comp._resourceClass === "component_transform" && comp.key) {
                    const newKey = this.nextId("k");
                    keyMap.set(comp.key, newKey);
                    comp.key = newKey;
                }
            }
        }

        let rootHandleId = "";
        for (const handle of src.handles ?? []) {
            for (const comp of handle.components ?? []) {
                if (comp._resourceClass !== "component_transform") continue;
                if (comp.parentKey) {
                    const mapped = keyMap.get(comp.parentKey);
                    if (mapped) comp.parentKey = mapped;
                } else {
                    // The root transform carries no parentKey → its handle is the element root.
                    rootHandleId = handle.id;
                }
            }
        }
        return { handles: src.handles, rootHandleId };
    }


Rollenförderer Visualisierung und Fehler beheben


1	import {
2	    type Transform,
3	    type Vec3,
4	    type Quat,
5	    IDENTITY_QUAT,
6	    quatRotate,
7	    quatMul,
8	    quatConj,
9	    eulerZyxRadToQuat,
10	} from "./transformMath";
11
12	// Single home for the group model. A group is a set of element roots wired by directed dock
13	// edges; the group itself is a scene-graph node, so the engine moves/rotates every member
14	// natively (no relations, no follower propagation). The model is domain-agnostic: an edge
15	// names the two docking points by id, so the relative direction (any axis) falls out of the
16	// point positions — there is no built-in notion of left/right/front/back or of part naming.
17
18	type Handle = any;
19	type Scene = any;
20
21	// The engine honours a single parentless root per scene; every group node hangs under it.
22	const ROOT_KEY = "root";
23
24	// Every materialised node carries an explicit role tag (`role-<x>`). Identifying the subject of an
25	// action is then "nearest ancestor of role X" over the parent chain — depth-agnostic, so deeper
26	// assembly-of-assembly hierarchies just add roles without changing the resolver. Today: scene root,
27	// assembly (group node), part (element root); everything below a part is geometry.
28	export const ROLE = { scene: "scene", assembly: "assembly", part: "part", geometry: "geometry" } as const;
29	export type NodeRole = (typeof ROLE)[keyof typeof ROLE];
30	const roleTag = (role: NodeRole) => `role-${role}`;
31
32	// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.
33	export interface Edge {
34	    from: string; // element root handle id, positioned first
35	    to: string; // element root handle id, positioned relative to `from`
36	    fromPmi: string; // docking point id on `from`
37	    toPmi: string; // docking point id on `to`
38	    // Rotation of `to` relative to `from` across this dock (in `from`'s frame). Absent/identity means
39	    // the two parts share orientation — the plain left/right case (CS_LINKS/CS_RECHTS carry no rotation).
40	    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.

import {
    type Transform,
    type Vec3,
    type Quat,
    IDENTITY_QUAT,
    quatRotate,
    quatMul,
    quatConj,
    eulerZyxRadToQuat,
} from "./transformMath";

// Single home for the group model. A group is a set of element roots wired by directed dock
// edges; the group itself is a scene-graph node, so the engine moves/rotates every member
// natively (no relations, no follower propagation). The model is domain-agnostic: an edge
// names the two docking points by id, so the relative direction (any axis) falls out of the
// point positions — there is no built-in notion of left/right/front/back or of part naming.

type Handle = any;
type Scene = any;

// The engine honours a single parentless root per scene; every group node hangs under it.
const ROOT_KEY = "root";

// Every materialised node carries an explicit role tag (`role-<x>`). Identifying the subject of an
// action is then "nearest ancestor of role X" over the parent chain — depth-agnostic, so deeper
// assembly-of-assembly hierarchies just add roles without changing the resolver. Today: scene root,
// assembly (group node), part (element root); everything below a part is geometry.
export const ROLE = { scene: "scene", assembly: "assembly", part: "part", geometry: "geometry" } as const;
export type NodeRole = (typeof ROLE)[keyof typeof ROLE];
const roleTag = (role: NodeRole) => `role-${role}`;

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.
export interface Edge {
    from: string; // element root handle id, positioned first
    to: string; // element root handle id, positioned relative to `from`
    fromPmi: string; // docking point id on `from`
    toPmi: string; // docking point id on `to`
    // Rotation of `to` relative to `from` across this dock (in `from`'s frame). Absent/identity means
    // the two parts share orientation — the plain left/right case (CS_LINKS/CS_RECHTS carry no rotation).
    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.


Jetzt die Edits. **1. `instantiateScene` als freie Funktion einfügen:**


Edit

const roleTag = (role: NodeRole) => `role-${role}`;

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.
export interface Edge {

const roleTag = (role: NodeRole) => `role-${role}`;

// The single re-identify primitive: deep-clone a scene (a product template OR a live subtree being
// copied) and give every handle id and transform key a fresh UUID, remapping parentKey. Source ids
// repeat across instances and configured copies must not share identity, so this ALWAYS clones. Returns
// the prepared handles plus the element root (the handle whose transform has no parentKey). This is the
// one place identity is minted — buildCreateEvent (which both product-add and copy funnel through)
// calls it; nothing else re-ids.
export function instantiateScene(scene: Scene): { handles: Handle[]; rootHandleId: string } {
    const src = structuredClone(scene);
    const keyMap = new Map<string, string>();

    for (const handle of src.handles ?? []) {
        handle.id = `h-${crypto.randomUUID()}`;
        for (const comp of handle.components ?? []) {
            if (comp._resourceClass === "component_transform" && comp.key) {
                const newKey = `k-${crypto.randomUUID()}`;
                keyMap.set(comp.key, newKey);
                comp.key = newKey;
            }
        }
    }

    let rootHandleId = "";
    for (const handle of src.handles ?? []) {
        for (const comp of handle.components ?? []) {
            if (comp._resourceClass !== "component_transform") continue;
            if (comp.parentKey) {
                const mapped = keyMap.get(comp.parentKey);
                if (mapped) comp.parentKey = mapped;
            } else {
                // The root transform carries no parentKey → its handle is the element root.
                rootHandleId = handle.id;
            }
        }
    }
    return { handles: src.handles, rootHandleId };
}

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.
export interface Edge {


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

const roleTag = (role: NodeRole) => `role-${role}`;

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.
export interface Edge {

const roleTag = (role: NodeRole) => `role-${role}`;

// The single re-identify primitive: deep-clone a scene (a product template OR a live subtree being
// copied) and give every handle id and transform key a fresh UUID, remapping parentKey. Source ids
// repeat across instances and configured copies must not share identity, so this ALWAYS clones. Returns
// the prepared handles plus the element root (the handle whose transform has no parentKey). This is the
// one place identity is minted — buildCreateEvent (which both product-add and copy funnel through)
// calls it; nothing else re-ids.
export function instantiateScene(scene: Scene): { handles: Handle[]; rootHandleId: string } {
    const src = structuredClone(scene);
    const keyMap = new Map<string, string>();

    for (const handle of src.handles ?? []) {
        handle.id = `h-${crypto.randomUUID()}`;
        for (const comp of handle.components ?? []) {
            if (comp._resourceClass === "component_transform" && comp.key) {
                const newKey = `k-${crypto.randomUUID()}`;
                keyMap.set(comp.key, newKey);
                comp.key = newKey;
            }
        }
    }

    let rootHandleId = "";
    for (const handle of src.handles ?? []) {
        for (const comp of handle.components ?? []) {
            if (comp._resourceClass !== "component_transform") continue;
            if (comp.parentKey) {
                const mapped = keyMap.get(comp.parentKey);
                if (mapped) comp.parentKey = mapped;
            } else {
                // The root transform carries no parentKey → its handle is the element root.
                rootHandleId = handle.id;
            }
        }
    }
    return { handles: src.handles, rootHandleId };
}

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.
export interface Edge {

export type NodeRole = (typeof ROLE)[keyof typeof ROLE];

+// The single re-identify primitive: deep-clone a scene (a product template OR a live subtree being

+// copied) and give every handle id and transform key a fresh UUID, remapping parentKey. Source ids

+// repeat across instances and configured copies must not share identity, so this ALWAYS clones. Returns

+// the prepared handles plus the element root (the handle whose transform has no parentKey). This is the

+// one place identity is minted — buildCreateEvent (which both product-add and copy funnel through)

+export function instantiateScene(scene: Scene): { handles: Handle[]; rootHandleId: string } {

+    const src = structuredClone(scene);

+    const keyMap = new Map<string, string>();

+    for (const handle of src.handles ?? []) {

+        handle.id = `h-${crypto.randomUUID()}`;

+        for (const comp of handle.components ?? []) {

+            if (comp._resourceClass === "component_transform" && comp.key) {

+                const newKey = `k-${crypto.randomUUID()}`;

+                keyMap.set(comp.key, newKey);

+            if (comp._resourceClass !== "component_transform") continue;

+                const mapped = keyMap.get(comp.parentKey);

+                if (mapped) comp.parentKey = mapped;

+                // The root transform carries no parentKey → its handle is the element root.

+                rootHandleId = handle.id;

+    return { handles: src.handles, rootHandleId };

from: string; // element root handle id, positioned first


**2. `buildCreateEvent` auf `instantiateScene` umstellen und `reidentify` löschen:**


    // Build a create event from a raw product scene: opaquely re-identify every handle id and
    // transform key (source ids repeat across instances), find the element root, bake the prepared
    // subtree into the event so replay is deterministic without re-running the counter.
    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = this.reidentify(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

    private reidentify(productScene: Scene): { handles: Handle[]; rootHandleId: string } {
        const src = structuredClone(productScene);
        const keyMap = new Map<string, string>();

        for (const handle of src.handles ?? []) {
            handle.id = this.nextId("h");
            for (const comp of handle.components ?? []) {
                if (comp._resourceClass === "component_transform" && comp.key) {
                    const newKey = this.nextId("k");
                    keyMap.set(comp.key, newKey);
                    comp.key = newKey;
                }
            }
        }

        let rootHandleId = "";
        for (const handle of src.handles ?? []) {
            for (const comp of handle.components ?? []) {
                if (comp._resourceClass !== "component_transform") continue;
                if (comp.parentKey) {
                    const mapped = keyMap.get(comp.parentKey);
                    if (mapped) comp.parentKey = mapped;
                } else {
                    // The root transform carries no parentKey → its handle is the element root.
                    rootHandleId = handle.id;
                }
            }
        }
        return { handles: src.handles, rootHandleId };
    }

    // Build a create event from a product scene (or a copied subtree): re-identify it via
    // instantiateScene (source ids repeat across instances), find the element root, and bake the
    // prepared subtree into the event so replay is deterministic.
    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = instantiateScene(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }


    // Build a create event from a raw product scene: opaquely re-identify every handle id and
    // transform key (source ids repeat across instances), find the element root, bake the prepared
    // subtree into the event so replay is deterministic without re-running the counter.
    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = this.reidentify(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

    private reidentify(productScene: Scene): { handles: Handle[]; rootHandleId: string } {
        const src = structuredClone(productScene);
        const keyMap = new Map<string, string>();

        for (const handle of src.handles ?? []) {
            handle.id = this.nextId("h");
            for (const comp of handle.components ?? []) {
                if (comp._resourceClass === "component_transform" && comp.key) {
                    const newKey = this.nextId("k");
                    keyMap.set(comp.key, newKey);
                    comp.key = newKey;
                }
            }
        }

        let rootHandleId = "";
        for (const handle of src.handles ?? []) {
            for (const comp of handle.components ?? []) {
                if (comp._resourceClass !== "component_transform") continue;
                if (comp.parentKey) {
                    const mapped = keyMap.get(comp.parentKey);
                    if (mapped) comp.parentKey = mapped;
                } else {
                    // The root transform carries no parentKey → its handle is the element root.
                    rootHandleId = handle.id;
                }
            }
        }
        return { handles: src.handles, rootHandleId };
    }

    // Build a create event from a product scene (or a copied subtree): re-identify it via
    // instantiateScene (source ids repeat across instances), find the element root, and bake the
    // prepared subtree into the event so replay is deterministic.
    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = instantiateScene(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

-    // Build a create event from a raw product scene: opaquely re-identify every handle id and

-    // transform key (source ids repeat across instances), find the element root, bake the prepared

-    // subtree into the event so replay is deterministic without re-running the counter.

+    // Build a create event from a product scene (or a copied subtree): re-identify it via

+    // instantiateScene (source ids repeat across instances), find the element root, and bake the

+    // prepared subtree into the event so replay is deterministic.

-        const { handles, rootHandleId } = this.reidentify(productScene);

+        const { handles, rootHandleId } = instantiateScene(productScene);

-    private reidentify(productScene: Scene): { handles: Handle[]; rootHandleId: string } {

-        const src = structuredClone(productScene);

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

-        for (const handle of src.handles ?? []) {

-            handle.id = this.nextId("h");

-            for (const comp of handle.components ?? []) {

-                if (comp._resourceClass === "component_transform" && comp.key) {

-                    const newKey = this.nextId("k");

-                    keyMap.set(comp.key, newKey);

-                if (comp._resourceClass !== "component_transform") continue;

-                    const mapped = keyMap.get(comp.parentKey);

-                    if (mapped) comp.parentKey = mapped;

-                    // The root transform carries no parentKey → its handle is the element root.

-                    rootHandleId = handle.id;

-        return { handles: src.handles, rootHandleId };

groupIdOfElement(rootHandleId: string): string | null {

return this.elementToGroup.get(rootHandleId) ?? null;


**3. `ProductService`: eigenes Instanziieren (`i<n>-`) entfernen — `getProduct` liefert nur noch das Template, der Processor re-identifiziert.**


export class DefaultProductService implements ProductService {
    private instanceCount = 0;

    constructor(private readonly loader: AssetLoader) {}

    async getProduct(productType: string): Promise<SceneGraph> {
        const graph = await this.loader.resolve<SceneGraph>({
            path: `${productType}.json`,
            typeKey: "sceneGraph",
        });
        return this.instantiate(graph);
    }

    // Aus dem Template eine Instanz machen: jeder Aufruf setzt einen eindeutigen
    // Präfix auf Handle-id und Transform-key/parentKey, damit mehrfaches Einfügen
    // desselben Produkts nicht über identische ids kollidiert.
    private instantiate(graph: SceneGraph): SceneGraph {
        const prefix = `i${++this.instanceCount}-`;
        for (const handle of graph.handles as Array<Record<string, any>>) {
            handle.id = prefix + handle.id;
            for (const comp of handle.components as Array<Record<string, any>>) {
                if (comp._resourceClass === "component_transform") {
                    if (comp.key) comp.key = prefix + comp.key;
                    if (comp.parentKey) comp.parentKey = prefix + comp.parentKey;
                }
            }
        }
        return graph;
    }

    async updateProduct(productType: string, _config: ProductConfig): Promise<SceneGraph> {

export class DefaultProductService implements ProductService {
    constructor(private readonly loader: AssetLoader) {}

    // Liefert das rohe Produkt-Template. Die Instanz-Identität (eindeutige UUIDs auf
    // Handle-id/Transform-key) vergibt der Processor beim Anlegen des Bauteils
    // (GroupProcessor.buildCreateEvent → instantiateScene) — die eine Stelle dafür.
    async getProduct(productType: string): Promise<SceneGraph> {
        return this.loader.resolve<SceneGraph>({
            path: `${productType}.json`,
            typeKey: "sceneGraph",
        });
    }

    async updateProduct(productType: string, _config: ProductConfig): Promise<SceneGraph> {


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

export class DefaultProductService implements ProductService {
    constructor(private readonly loader: AssetLoader) {}

    // Liefert das rohe Produkt-Template. Die Instanz-Identität (eindeutige UUIDs auf
    // Handle-id/Transform-key) vergibt der Processor beim Anlegen des Bauteils
    // (GroupProcessor.buildCreateEvent → instantiateScene) — die eine Stelle dafür.
    async getProduct(productType: string): Promise<SceneGraph> {
        return this.loader.resolve<SceneGraph>({
            path: `${productType}.json`,
            typeKey: "sceneGraph",
        });
    }

    async updateProduct(productType: string, _config: ProductConfig): Promise<SceneGraph> {

/**
 * ProductService — Grenze zur Produktquelle.
 *
 * Liefert IMMER Einzelteile/Produkt-Standards; Kombination, Positionierung und
 * PMI-/Constraint-Logik passieren NICHT hier, sondern eine Schicht darüber
 * (SceneService). Dieser Service kennt keine Szene-Komposition.
 *
 * WOHER ein Produkt kommt, weiß der Service NICHT. Die Quelle (heute Assets,
 * im Livebetrieb der Server) ist über die datamanager-Connectors in
 * environment.config.json abstrahiert: ein logischer Verweis (path + typeKey)
 * wird per Filter auf ein Pfad-Template aufgelöst. Der Service fragt nur das
 * logische Produkt an und überlässt die Auflösung dem Loader.
 */

/**
 * Szenegraph-Produkt. Vorläufig hier deklariert; endgültiger Ort der
 * Format-Typen ist offen (sceneMerger entfällt). KEINE Kopplung an utils.
 */
export interface SceneGraph {
    _resourceClass: string;
    handles: unknown[];
    [key: string]: unknown;
}

/** Konfigurationswerte für ein Produkt (Variablenname → Wert). */
export type ProductConfig = Record<string, unknown>;

/**
 * Logischer Asset-Verweis — gleicher Vertrag wie die Connector-Filter in
 * environment.config.json (path-Regex + typeKey).
 */
export interface AssetReference {
    path: string;
    typeKey: string;
}

/**
 * Auflösung eines logischen Verweises auf Daten. Die Quelle (Assets vs. Server)
 * ist Sache der Implementierung, nicht des ProductService.
 */
export interface AssetLoader {
    resolve<T>(ref: AssetReference): Promise<T>;
}

/**
 * Fetch-basierte Auflösung gegen die statischen Assets. Sie ist die einzige
 * Stelle, die WEISS, wo ein Produkt liegt: der logische `path` wird unter einem
 * Basis-Pfad nachgeladen. Im Livebetrieb tritt an ihre Stelle ein Server-Loader.
 */
export class FetchAssetLoader implements AssetLoader {
    constructor(private readonly basePath = "/assets/scenes/") {}

    async resolve<T>(ref: AssetReference): Promise<T> {
        const response = await fetch(`${this.basePath}${ref.path}`);
        if (!response.ok) {
            throw new Error(`Asset nicht gefunden: ${ref.path} (${response.status})`);
        }
        return (await response.json()) as T;
    }
}

export interface ProductService {
    /** Standard-Produkt für einen Typ holen (ohne angewandte Config). */
    getProduct(productType: string): Promise<SceneGraph>;

    /** Config auf ein Produkt anwenden; gibt das neue Produkt zurück. */
    updateProduct(productType: string, config: ProductConfig): Promise<SceneGraph>;
}

export class DefaultProductService implements ProductService {
    private instanceCount = 0;

    constructor(private readonly loader: AssetLoader) {}

    async getProduct(productType: string): Promise<SceneGraph> {
        const graph = await this.loader.resolve<SceneGraph>({
            path: `${productType}.json`,
            typeKey: "sceneGraph",
        });
        return this.instantiate(graph);
    }

    // Aus dem Template eine Instanz machen: jeder Aufruf setzt einen eindeutigen
    // Präfix auf Handle-id und Transform-key/parentKey, damit mehrfaches Einfügen
    // desselben Produkts nicht über identische ids kollidiert.
    private instantiate(graph: SceneGraph): SceneGraph {
        const prefix = `i${++this.instanceCount}-`;
        for (const handle of graph.handles as Array<Record<string, any>>) {
            handle.id = prefix + handle.id;
            for (const comp of handle.components as Array<Record<string, any>>) {
                if (comp._resourceClass === "component_transform") {
                    if (comp.key) comp.key = prefix + comp.key;
                    if (comp.parentKey) comp.parentKey = prefix + comp.parentKey;
                }
            }
        }
        return graph;
    }

    async updateProduct(productType: string, _config: ProductConfig): Promise<SceneGraph> {
        // Anwenden der Config liefert ein neues Produkt vom Backend.
        // Bis das Backend angebunden ist, fällt das auf das Basis-Produkt zurück.
        return this.getProduct(productType);
    }
}

+    // Liefert das rohe Produkt-Template. Die Instanz-Identität (eindeutige UUIDs auf

+    // Handle-id/Transform-key) vergibt der Processor beim Anlegen des Bauteils

+    // (GroupProcessor.buildCreateEvent → instantiateScene) — die eine Stelle dafür.

-        const graph = await this.loader.resolve<SceneGraph>({

+        return this.loader.resolve<SceneGraph>({

-        return this.instantiate(graph);

-    // Aus dem Template eine Instanz machen: jeder Aufruf setzt einen eindeutigen

-    // Präfix auf Handle-id und Transform-key/parentKey, damit mehrfaches Einfügen

-    // desselben Produkts nicht über identische ids kollidiert.

-    private instantiate(graph: SceneGraph): SceneGraph {

-        const prefix = `i${++this.instanceCount}-`;

-        for (const handle of graph.handles as Array<Record<string, any>>) {

-            handle.id = prefix + handle.id;

-            for (const comp of handle.components as Array<Record<string, any>>) {

-                if (comp._resourceClass === "component_transform") {

-                    if (comp.key) comp.key = prefix + comp.key;

-                    if (comp.parentKey) comp.parentKey = prefix + comp.parentKey;


Jetzt Referenzen prüfen und Tests/Typecheck laufen lassen.


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Reste von reidentify / instanceCount / .instantiate( ? ==="
grep -rniE 'reidentify|instanceCount|\.instantiate\(' packages/frontend/src 2>/dev/null | grep -v node_modules
echo "(leer = gut)"
echo
echo "=== instantiateScene-Nutzung ==="
grep -rniE 'instantiateScene' packages/frontend/src 2>/dev/null | grep -v node_modules
echo
echo "=== Tests ==="
pnpm --filter @ad-cpq/common-frontend exec vitest run GroupProcessor DefaultCommandProcessor 2>&1 | tail -8
echo
echo "=== Typecheck (nicht-ProductBrowser) ==="
pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -E 'error TS' | grep -vE 'ProductBrowser.vue' | head

Referenzen prüfen, Tests, Typecheck


=== Reste von reidentify / instanceCount / .instantiate( ? ===
packages/frontend/src/configurator/model/GroupProcessor.ts:223:    // element root's parentKey is stripped so reidentify treats it as a fresh element root again.
(leer = gut)

=== instantiateScene-Nutzung ===
packages/frontend/src/configurator/model/GroupProcessor.ts:38:export function instantiateScene(scene: Scene): { handles: Handle[]; rootHandleId: string } {
packages/frontend/src/configurator/model/GroupProcessor.ts:168:    // instantiateScene (source ids repeat across instances), find the element root, and bake the
packages/frontend/src/configurator/model/GroupProcessor.ts:171:        const { handles, rootHandleId } = instantiateScene(productScene);
packages/frontend/src/services/product-service.ts:75:    // (GroupProcessor.buildCreateEvent → instantiateScene) — die eine Stelle dafür.

=== Tests ===
 RUN  v4.1.9 /Users/marcus.hinz/projects/adesso-cpq/packages/frontend

 Test Files  2 passed (2)
      Tests  25 passed (25)
   Start at  14:19:45
   Duration  636ms (transform 97ms, setup 0ms, import 125ms, tests 207ms, environment 720ms)

=== Typecheck (nicht-ProductBrowser) ===


Alle 25 Tests grün, Typecheck sauber. Ein Kommentar verweist noch auf `reidentify` — ich korrigiere ihn.


218	    roleOfId(handleId: string): NodeRole | null {
219	        return this.nodeIndex.get(handleId)?.role ?? null;
220	    }
221
222	    // Rebuild a standalone product scene from a placed element's subtree — for duplicating it. The
223	    // element root's parentKey is stripped so reidentify treats it as a fresh element root again.
224	    // (Prototype helper for the keyboard build shortcut.)
225	    sceneOfElement(elementRoot: string): { _resourceClass: string; handles: Handle[] } | null {
226	        const handles = this.elementHandles.get(elementRoot);
227	        if (!handles) return null;

    roleOfId(handleId: string): NodeRole | null {
        return this.nodeIndex.get(handleId)?.role ?? null;
    }

    // Rebuild a standalone product scene from a placed element's subtree — for duplicating it. The
    // element root's parentKey is stripped so reidentify treats it as a fresh element root again.
    // (Prototype helper for the keyboard build shortcut.)
    sceneOfElement(elementRoot: string): { _resourceClass: string; handles: Handle[] } | null {
        const handles = this.elementHandles.get(elementRoot);
        if (!handles) return null;


Edit

    // Rebuild a standalone product scene from a placed element's subtree — for duplicating it. The
    // element root's parentKey is stripped so reidentify treats it as a fresh element root again.
    // (Prototype helper for the keyboard build shortcut.)

    // Rebuild a standalone product scene from a placed element's subtree — for duplicating it. The
    // element root's parentKey is stripped so buildCreateEvent (via instantiateScene) treats it as a
    // fresh element root and re-ids the copy. (Prototype helper for the keyboard build shortcut.)


    // Rebuild a standalone product scene from a placed element's subtree — for duplicating it. The
    // element root's parentKey is stripped so reidentify treats it as a fresh element root again.
    // (Prototype helper for the keyboard build shortcut.)

    // Rebuild a standalone product scene from a placed element's subtree — for duplicating it. The
    // element root's parentKey is stripped so buildCreateEvent (via instantiateScene) treats it as a
    // fresh element root and re-ids the copy. (Prototype helper for the keyboard build shortcut.)

-    // element root's parentKey is stripped so reidentify treats it as a fresh element root again.

-    // (Prototype helper for the keyboard build shortcut.)

+    // element root's parentKey is stripped so buildCreateEvent (via instantiateScene) treats it as a

+    // fresh element root and re-ids the copy. (Prototype helper for the keyboard build shortcut.)

sceneOfElement(elementRoot: string): { _resourceClass: string; handles: Handle[] } | null {

const handles = this.elementHandles.get(elementRoot);


Fertig — die Re-ID-Konsolidierung steht. 25/25 Tests grün, Typecheck sauber.

## Umgesetzt
- **`instantiateScene(scene)`** — freie, pure Funktion in `GroupProcessor.ts`: `structuredClone` + frische **UUIDs** auf jede Handle-id und jeden Transform-key, `parentKey`-Remap, liefert `{ handles, rootHandleId }`. Klont immer → konfigurierte Kopien teilen keine Identität.
- **`buildCreateEvent`** ruft `instantiateScene` — die **eine** Re-ID-Stelle. Beide Pfade laufen hier durch: Produkt-Add (`handleAddProduct`) und Kopie (`handleKeyboardBuild` → `sceneOfElement`).
- **`reidentify` gelöscht** (war die im Model vergrabene zentrale Stelle).
- **`ProductService`**: eigenes `i<n>-`-Instanziieren raus, `instanceCount` weg. `getProduct` liefert nur noch das Template; die Identität vergibt der Processor.
- **UUIDs durchgängig**: `instantiateScene` (`h-`/`k-` + `crypto.randomUUID`), `nextId` (`g-`/`gk-`) schon vorher. Kein Zähler mehr.

## Noch offen (Phase 2 — Klasse vom Asset)
Bewusst **nicht** angefasst: `classOfGroup`/`elementsOfClass` lesen weiterhin den `class-…`-**Tag** am Asset. Der Umzug (Klasse aus Katalog-Node → beim Instanziieren als Eigenschaft aufs Root-Handle → `buildCreateEvent` hebt sie aufs `group-create`-Event → Reader lesen die statt Tag → Tag am Asset weg) hängt noch an einem Punkt: dass die Klasse am **Katalog-Node** tatsächlich verfügbar ist. Sobald du das Feld benennst, setze ich Phase 2 drauf.

Nicht committet.


ok met
die bauteile (PMIs= sind gerichtet :: ein ...EIN_L PMI kann nur auf einen OUT_R-... eingebaut werden
Im Szenegrph funktioniert das nicht
die docking rules sehen richtig aus
z.B.
  "schutzzaun_basic-element": {
        "schutzzaun_basic-element": {
            "CS_EIN_L": ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            "CS_EIN_R": ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"]
        },
        "schutzzaun_basic-tuer": {
            "CS_EIN_L": ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            "CS_EIN_R": ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"]
        }
    },

Kannst Du analysieren?


Ich schaue mir die tatsächlichen PMI-Daten eines `schutzzaun_basic-element` an (Positionen **und** Frames) und simuliere den EIN_L↔OUT_R-Dock, um zu sehen, wo die Richtung im Szenegraph kippt.


Bash

cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products/schutzzaun_basic
echo "=== ein Element-Bauteil finden (class-...-element) ==="
for f in SBE*.json; do
  c=$(grep -o 'class-schutzzaun_basic-[a-z]*' "$f" 2>/dev/null | head -1)
  [ -n "$c" ] && echo "$f : $c" && break
done
echo
echo "=== Root-PMIs dieses Elements (id, pos, rot) — nur EIN_/OUT_ ==="
python3 -c "
import json,glob
f=[x for x in glob.glob('SBE*.json')][0]
d=json.load(open(f))
print('Datei:',f)
for h in d['handles']:
    tr=[c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform']
    if tr and not tr[0].get('parentKey'):
        print('class-tag:', h.get('tags'))
        for c in h['components']:
            if c.get('_resourceClass')=='component_pmi' and (c['id'].startswith('CS_EIN') or c['id'].startswith('CS_OUT')):
                p=c['properties'].get('position',{}).get('value')
                r=c['properties'].get('rotation',{}).get('value')
                print(f\"{c['id']:14} pos={p}  rot={r}\")
        break
"

Element-PMIs dumpen


ok met die bauteile (PMIs= sind gerichtet :: ein ...EIN_L PMI kann nur auf einen OUT_R-... eingebaut werden Im Szenegrph funktioniert das nicht die docking rules sehen richtig aus z.B.    "schutzzaun_…


=== ein Element-Bauteil finden (class-...-element) ===
SBE0131SNA.json : class-schutzzaun_basic-element

=== Root-PMIs dieses Elements (id, pos, rot) — nur EIN_/OUT_ ===
Datei: SBE1163PNA.json
class-tag: ['class-schutzzaun_basic-element']
CS_EIN         pos=[1123, -33, -40]  rot=[0.0, 1.5707963267948966, 0.0]
CS_EIN_L       pos=[1103.0, 1067.0, -20.0]  rot=[-0.0, 1.5707963267948966, 0.0]
CS_EIN_R       pos=[-20.0, 1067.0, -20.0]  rot=[-0.0, -1.5707963267948966, 0.0]
CS_EIN_START_L pos=[1123.0, -33.0, -20.0]  rot=[-0.0, 1.5707963267948966, 0.0]
CS_EIN_START_R pos=[-40.0, -33.0, -20.0]  rot=[-0.0, -1.5707963267948966, 0.0]
CS_OUT_R_0     pos=[-70.0, 1067.0, -20.0]  rot=[-0.0, 1.5707963267948966, 0.0]
CS_OUT_L_0     pos=[1153.0, 1067.0, -20.0]  rot=[-0.0, -1.5707963267948966, 0.0]
CS_OUT_R_90    pos=[-20.0, 1067.0, 30.0]  rot=[3.141592653589793, 1.2246467991465022e-16, 3.141592653589793]
CS_OUT_L_90    pos=[1103.0, 1067.0, 30.0]  rot=[3.141592653589793, 1.2246467991465022e-16, 3.141592653589793]
CS_OUT_R_90_B  pos=[-70.0, 1067.0, -20.0]  rot=[3.141592653589793, 1.2246467991465022e-16, 3.141592653589793]
CS_OUT_L_90_B  pos=[1153.0, 1067.0, -20.0]  rot=[3.141592653589793, 1.2246467991465022e-16, 3.141592653589793]
CS_OUT_R_270   pos=[-20.0, 1067.0, -70.0]  rot=[0.0, -2.449293598299986e-16, 0.0]
CS_OUT_L_270   pos=[1103.0, 1067.0, -70.0]  rot=[0.0, -2.449293598299986e-16, 0.0]
CS_OUT_R_270_B pos=[-70.0, 1067.0, -20.0]  rot=[0.0, -2.449293598299986e-16, 0.0]
CS_OUT_L_270_B pos=[1153.0, 1067.0, -20.0]  rot=[0.0, -2.449293598299986e-16, 0.0]


Das ist ein aufschlussreicher Befund. Ich rechne die `dockRotation` für die relevanten Paarungen exakt nach, statt zu schätzen.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products/schutzzaun_basic
python3 -c "
import math
def eul(r):
    x,y,z=r
    cx,cy,cz=math.cos(x/2),math.cos(y/2),math.cos(z/2)
    sx,sy,sz=math.sin(x/2),math.sin(y/2),math.sin(z/2)
    return [cx*cy*cz+sx*sy*sz, sx*cy*cz-cx*sy*sz, cx*sy*cz+sx*cy*sz, cx*cy*sz-sx*sy*cz]
def conj(q): return [q[0],-q[1],-q[2],-q[3]]
def mul(a,b):
    w1,x1,y1,z1=a; w2,x2,y2,z2=b
    return [w1*w2-x1*x2-y1*y2-z1*z2, w1*x2+x1*w2+y1*z2-z1*y2, w1*y2-x1*z2+y1*w2+z1*x2, w1*z2+x1*y2-y1*x2+z1*w2]
def dock(anchor,join): return mul(eul(anchor),conj(eul(join)))
def deg(q):  # rotation angle of quat
    return round(2*math.degrees(math.acos(max(-1,min(1,q[0])))),1)

# frames (rot) aus den Dumps
tuer_OUT_R_0=[0,-1.5707963267948966,0]
tuer_EIN_L =[0,-1.5707963267948966,0]
elem_OUT_R_0=[0, 1.5707963267948966,0]
elem_EIN_L =[0, 1.5707963267948966,0]

print('EIN_L -> OUT_R  (anchor=OUT_R, join=EIN_L):')
print('  tuer.EIN_L  auf tuer.OUT_R_0  :', dock(tuer_OUT_R_0, tuer_EIN_L),  '=> Drehung', deg(dock(tuer_OUT_R_0, tuer_EIN_L)),'Grad')
print('  elem.EIN_L  auf elem.OUT_R_0  :', dock(elem_OUT_R_0, elem_EIN_L),  '=> Drehung', deg(dock(elem_OUT_R_0, elem_EIN_L)),'Grad')
print('  elem.EIN_L  auf tuer.OUT_R_0  :', dock(tuer_OUT_R_0, elem_EIN_L),  '=> Drehung', deg(dock(tuer_OUT_R_0, elem_EIN_L)),'Grad')
print('  tuer.EIN_L  auf elem.OUT_R_0  :', dock(elem_OUT_R_0, tuer_EIN_L),  '=> Drehung', deg(dock(elem_OUT_R_0, tuer_EIN_L)),'Grad')
"
echo
echo "=== Seiten-/Frame-Vergleich (x-Position + rot-Y) ==="
echo "TUER  : EIN_L x=-20 rotY=-90 | OUT_R_0 x=1114 rotY=-90 | EIN_R x=1064 rotY=+90 | OUT_L_0 x=-70 rotY=+90"
echo "ELEMENT: EIN_L x=1103 rotY=+90 | OUT_R_0 x=-70 rotY=+90 | EIN_R x=-20 rotY=-90 | OUT_L_0 x=1153 rotY=-90"

dockRotation exakt berechnen


EIN_L -> OUT_R  (anchor=OUT_R, join=EIN_L):
  tuer.EIN_L  auf tuer.OUT_R_0  : [1.0, 0.0, 0.0, 0.0] => Drehung 0.0 Grad
  elem.EIN_L  auf elem.OUT_R_0  : [1.0, 0.0, 0.0, 0.0] => Drehung 0.0 Grad
  elem.EIN_L  auf tuer.OUT_R_0  : [2.220446049250313e-16, 0.0, -1.0, 0.0] => Drehung 180.0 Grad
  tuer.EIN_L  auf elem.OUT_R_0  : [2.220446049250313e-16, 0.0, 1.0, 0.0] => Drehung 180.0 Grad

=== Seiten-/Frame-Vergleich (x-Position + rot-Y) ===
TUER  : EIN_L x=-20 rotY=-90 | OUT_R_0 x=1114 rotY=-90 | EIN_R x=1064 rotY=+90 | OUT_L_0 x=-70 rotY=+90
ELEMENT: EIN_L x=1103 rotY=+90 | OUT_R_0 x=-70 rotY=+90 | EIN_R x=-20 rotY=-90 | OUT_L_0 x=1153 rotY=-90


Analyse fertig — und der Befund ist eindeutig: **Rules und Code sind korrekt, die Szenegraph-Daten sind es nicht.** `schutzzaun_basic-element` trägt die Ein-/Aus-Punkte spiegelverkehrt zu `schutzzaun_basic-tuer`.

## Was die `dockRotation` sagt (exakt gerechnet)

Regel `CS_EIN_L → CS_OUT_R` (anchor = OUT_R, join = EIN_L):

| Paarung | dockRotation |
|---|---|
| tuer.EIN_L auf tuer.OUT_R_0 | **0°** ✓ |
| element.EIN_L auf element.OUT_R_0 | **0°** ✓ |
| **element.EIN_L auf tuer.OUT_R_0** | **180°** ✗ |
| **tuer.EIN_L auf element.OUT_R_0** | **180°** ✗ |

Innerhalb einer Klasse stimmt es (glatter Anbau, keine Drehung). **Klassenübergreifend kippt das Teil um 180°** — genau das, was du im Szenegraph siehst.

## Ursache: gespiegelte PMI-Konvention

| | EIN_L | OUT_R_0 | EIN_R | OUT_L_0 |
|---|---|---|---|---|
| **Tür** | x=−20, rotY=**−90** (links) | x=1114, rotY=**−90** (rechts) | x=1064, rotY=+90 | x=−70, rotY=+90 |
| **Element** | x=1103, rotY=**+90** (rechts) | x=−70, rotY=**+90** (links) | x=−20, rotY=−90 | x=1153, rotY=−90 |

Beim Element sitzt **`CS_EIN_L` rechts statt links**, **`CS_OUT_R_0` links statt rechts**, und die Frames haben das **umgekehrte Vorzeichen** (+90 statt −90). Das Element ist gegenüber der Tür komplett gespiegelt — Seite **und** Orientierung.

Folge: „`CS_OUT_R`" bedeutet bei der Tür „rechter Ausgang", beim Element „linker Ausgang". Eine Regel, die `EIN_L → OUT_R` mit **einheitlicher** Bedeutung annimmt (so wie deine, korrekt), trifft klassenübergreifend auf zwei widersprüchliche Konventionen → 180°-Flip und falsche Seite.

## Konsequenz
- Kein Code-/Rules-Fehler. Der Fix gehört in die **Asset-Generierung**: die PMI-Frames/-Seiten einer Klasse auf **eine** kanonische Konvention bringen.
- Die Tür-Konvention wirkt kanonisch (`EIN_L` links, rotY=−90). Das **Element** ist der gespiegelte Fall — dort müssen `EIN_L/EIN_R` und `OUT_R/OUT_L` (Position + rotY-Vorzeichen) an die Tür-Konvention angeglichen werden.

Zu prüfen wäre noch, ob das systematisch für **alle** `-element`-Dateien gilt (der Dump von `SBE1163PNA` zeigt es klar) — dann ist es ein Export-Konventionsfehler auf Element-Seite, kein Einzelfall.


NIcht das Problem
Ich kann ein element mit der gleichen Seite an beiden Seiten einers anderen Elements einbauen.
das darf nicht sein


Die Rules und die Element-Daten sagen: `CS_EIN_L` zielt nur auf `CS_OUT_R` (beim Element **links**), `CS_EIN_R` nur auf `CS_OUT_L` (**rechts**) — je Eintrittspunkt genau **eine** Seite. Das passt nicht zu deinem Symptom, also schaue ich mir an, was unser Code zur Laufzeit **tatsächlich** an die Placement-Punkte übergibt. Ich instrumentiere das mit einem echten Element-Fixture.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products/schutzzaun_basic
echo "=== ein Element-Fixture mit class-element + CS_EIN_L wählen ==="
for f in SBE*.json; do
  python3 -c "
import json,sys
d=json.load(open('$f'))
for h in d['handles']:
    if 'class-schutzzaun_basic-element' in (h.get('tags') or []):
        pmis=[c['id'] for c in h.get('components',[]) if c.get('_resourceClass')=='component_pmi']
        if 'CS_EIN_L' in pmis and 'CS_OUT_R_0' in pmis:
            print('$f'); sys.exit(0)
" 2>/dev/null && break
done

Element-Fixture wählen


=== ein Element-Fixture mit class-element + CS_EIN_L wählen ===
SBE0131SNA.json


NIcht das Problem Ich kann ein element mit der gleichen Seite an beiden Seiten einers anderen Elements einbauen. das darf nicht sein


Ich schreibe einen temporären Diagnose-Test, der zwei echte Elemente lädt und die real erzeugten `dragPoints` (Selector + dropPoints) ausgibt.


Write

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor, type DockingRules } from "./DefaultCommandProcessor";
import elementData from "../../../.storybook/products/schutzzaun_basic/SBE0131SNA.json";

const RULES: DockingRules = {
    "schutzzaun_basic-element": {
        "schutzzaun_basic-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
        },
    },
};

const el = () => structuredClone(elementData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const tagged = (scene: any, role: string): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);

describe("DIAG element drag/drop", () => {
    it("dumps dragPoints", () => {
        const proc = new DefaultCommandProcessor(RULES);
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: el().handles });
        const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: el().handles }));
        const [a, b] = tagged(scene, "part");
        const react = proc.handleEvent({ eventTypeName: "context-click", pointed: [{ entity: { id: a } }] });
        const menu = react.find((x: any) => x.type === "contextMenu");
        const platz = menu?.entries.find((e: any) => e.label === "Platzieren");
        const dragPoints = platz?.reactions?.[0]?.args?.[0]?.dragPoints;
        console.log("A =", a, " B =", b);
        console.log("DRAGPOINTS =", JSON.stringify(dragPoints, null, 2));
        expect(true).toBe(true);
    });
});


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

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor, type DockingRules } from "./DefaultCommandProcessor";
import elementData from "../../../.storybook/products/schutzzaun_basic/SBE0131SNA.json";

const RULES: DockingRules = {
    "schutzzaun_basic-element": {
        "schutzzaun_basic-element": {
            CS_EIN_L: ["CS_OUT_R_0", "CS_OUT_R_90", "CS_OUT_R_270"],
            CS_EIN_R: ["CS_OUT_L_0", "CS_OUT_L_90", "CS_OUT_L_270"],
        },
    },
};

const el = () => structuredClone(elementData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const tagged = (scene: any, role: string): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);

describe("DIAG element drag/drop", () => {
    it("dumps dragPoints", () => {
        const proc = new DefaultCommandProcessor(RULES);
        proc.setInitialScene(base());
        proc.handleEvent({ eventTypeName: "add-product", handles: el().handles });
        const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: el().handles }));
        const [a, b] = tagged(scene, "part");
        const react = proc.handleEvent({ eventTypeName: "context-click", pointed: [{ entity: { id: a } }] });
        const menu = react.find((x: any) => x.type === "contextMenu");
        const platz = menu?.entries.find((e: any) => e.label === "Platzieren");
        const dragPoints = platz?.reactions?.[0]?.args?.[0]?.dragPoints;
        console.log("A =", a, " B =", b);
        console.log("DRAGPOINTS =", JSON.stringify(dragPoints, null, 2));
        expect(true).toBe(true);
    });
});


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
pnpm --filter @ad-cpq/common-frontend exec vitest run _diag 2>&1 | grep -A60 'DRAGPOINTS' | head -70

Diagnose-Test ausführen


(Bash completed with no output)


cd /Users/marcus.hinz/projects/adesso-cpq
pnpm --filter @ad-cpq/common-frontend exec vitest run _diag 2>&1 | grep -iE 'A =|DRAGPOINTS|CS_EIN|CS_OUT|selector|dropPoints|Test Files|passed|failed|stdout|console' | head -80

Diagnose-Ausgabe filtern


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


cd /Users/marcus.hinz/projects/adesso-cpq
pnpm --filter @ad-cpq/common-frontend exec vitest run _diag --reporter=verbose --disableConsoleIntercept 2>&1 | grep -A40 'DRAGPOINTS\|A =' | head -60

Diagnose mit Konsolen-Ausgabe


A = h-88114af8-43c4-4a4e-b258-d90535139c7b  B = h-6c11bb5b-54f8-418c-bed2-9c8c956da169
DRAGPOINTS = [
  {
    "selector": ".name-CS_EIN_L",
    "dropPoints": [
      "#h-6c11bb5b-54f8-418c-bed2-9c8c956da169 .name-CS_OUT_R_0",
      "#h-6c11bb5b-54f8-418c-bed2-9c8c956da169 .name-CS_OUT_R_90",
      "#h-6c11bb5b-54f8-418c-bed2-9c8c956da169 .name-CS_OUT_R_270"
    ]
  },
  {
    "selector": ".name-CS_EIN_R",
    "dropPoints": [
      "#h-6c11bb5b-54f8-418c-bed2-9c8c956da169 .name-CS_OUT_L_0",
      "#h-6c11bb5b-54f8-418c-bed2-9c8c956da169 .name-CS_OUT_L_90",
      "#h-6c11bb5b-54f8-418c-bed2-9c8c956da169 .name-CS_OUT_L_270"
    ]
  }
]
 ✓ src/configurator/events/_diag.test.ts > DIAG element drag/drop > dumps dragPoints 3ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  14:39:06
   Duration  373ms (transform 54ms, setup 0ms, import 65ms, tests 3ms, environment 221ms)


Das ist der Beweis: **unser Payload ist sauber gerichtet** —
- `.name-CS_EIN_L` → ausschließlich `CS_OUT_R_*` von B,
- `.name-CS_EIN_R` → ausschließlich `CS_OUT_L_*` von B.

Kein Vermischen. Der Verstoß entsteht also **nicht** bei uns, sondern beim Snappen in der Engine. Ich prüfe, wie `@ad/fire` (Referenz: die fire-visual-Kopie) die Drag-Points an ihre Drop-Points bindet — pro Drag-Point oder gepoolt.


cd /Users/marcus.hinz/projects/fire-visual
f=threed/interaction/module/src/feature/interactionFeature.ts
echo "=== start_placement_mode-Verdrahtung: dragPoints/dropPoints ==="
grep -n 'dropPoints\|dragPoints\|placement\|findAll\|start_placement' "$f" | head -40

interactionFeature Placement-Verdrahtung


=== start_placement_mode-Verdrahtung: dragPoints/dropPoints ===
26:import {DropPointProvider, PlacementMode, PlacementModeSettings} from '../interactionMode/placementMode';
31:    placement: PlacementModeOptions;
49:    placement: {
148:            placement: this._settings.placement,
259:            start_placement_mode: async (params: {
265:                dragPoints: Array<{
268:                    dropPoints: string[];
282:                // Resolve dragPoints selectors to Handles
283:                const resolvedDragPoints = params.dragPoints.flatMap(dragPoint => {
285:                    const dropPointProviders = dragPoint.dropPoints.flatMap(dropPointSelector =>
286:                        this.selectorEngine.findAll<Component>(dropPointSelector)
296:                            dropPoints: dropPointProviders as Partial<DropPointProvider>[],
301:                    const dragPointEntities = this.selectorEngine.findAll<SelectorElement & {
306:                        dropPoints: dropPointProviders as Partial<DropPointProvider>[],
318:                    dragPoints: resolvedDragPoints
331:                this.selectionManager.set(selectors.flatMap(s => this.selectorEngine.findAll(s)).filter(s => isTypeSelectable(s)).map(s => s as Selectable));
335:                this.selectionManager.add(selectors.flatMap(s => this.selectorEngine.findAll(s)).filter(s => isTypeSelectable(s)).map(s => s as Selectable));
339:                this.selectionManager.remove(selectors.flatMap(s => this.selectorEngine.findAll(s)).filter(s => isTypeSelectable(s)).map(s => s as Selectable));
346:            placement_mode_next_drag_point: async (): Promise<void> => {
349:                    throw new TypeError('placement_mode_next_drag_point: current mode is not PlacementMode');
354:            placement_mode_previous_drag_point: async (): Promise<void> => {
357:                    throw new TypeError('placement_mode_previous_drag_point: current mode is not PlacementMode');
401:        start_placement_mode(params: {
407:            dragPoints: Array<{
410:                dropPoints: string[];
424:        placement_mode_next_drag_point(): Promise<void>;
426:        placement_mode_previous_drag_point(): Promise<void>;
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


282	                // Resolve dragPoints selectors to Handles
283	                const resolvedDragPoints = params.dragPoints.flatMap(dragPoint => {
284
285	                    const dropPointProviders = dragPoint.dropPoints.flatMap(dropPointSelector =>
286	                        this.selectorEngine.findAll<Component>(dropPointSelector)
287	                    );
288	                    const transform = dragPoint.transform
289	                        ? Matrix4_4.compose(dragPoint.transform.position, dragPoint.transform.rotation, Vector3.one)
290	                        : Matrix4_4.identity;
291
292	                    const selectSubEntities = dragPoint.selector != null && dragPoint.selector != "";
293	                    if (!selectSubEntities) {
294	                        return [{
295	                            transform,
296	                            dropPoints: dropPointProviders as Partial<DropPointProvider>[],
297	                            selector: undefined
298	                        }];
299	                    }
300
301	                    const dragPointEntities = this.selectorEngine.findAll<SelectorElement & {
302	                        LTW: Matrix4_4
303	                    }>(dragPoint.selector, subjectHandle);
304	                    return dragPointEntities.map(dpe => ({
305	                        transform: subjectHandle.transform.LTW.inverse.multiply(dpe.LTW).multiply(transform),
306	                        dropPoints: dropPointProviders as Partial<DropPointProvider>[],
307	                        selector: this.selectorEngine.selector(dpe)
308	                    }));
309	                });
310
311	                const settings: PlacementModeSettings = {
312	                    camera: this._inputFeature.renderContext.camera,
313	                    subject: subjectHandle,
314	                    defaultCursorTransform: params.cursorTransform,
315	                    allowAllDragPoints: params.allowAllDragPoints,
316	                    allowDragPointCycling: params.allowDragPointCycling,
317	                    maxJumpDistance: params.maxJumpDistance,
318	                    dragPoints: resolvedDragPoints
319	                };
320
321	                const mode = this.dependencyResolver.instance(PlacementMode, settings);

                // Resolve dragPoints selectors to Handles
                const resolvedDragPoints = params.dragPoints.flatMap(dragPoint => {

                    const dropPointProviders = dragPoint.dropPoints.flatMap(dropPointSelector =>
                        this.selectorEngine.findAll<Component>(dropPointSelector)
                    );
                    const transform = dragPoint.transform
                        ? Matrix4_4.compose(dragPoint.transform.position, dragPoint.transform.rotation, Vector3.one)
                        : Matrix4_4.identity;

                    const selectSubEntities = dragPoint.selector != null && dragPoint.selector != "";
                    if (!selectSubEntities) {
                        return [{
                            transform,
                            dropPoints: dropPointProviders as Partial<DropPointProvider>[],
                            selector: undefined
                        }];
                    }

                    const dragPointEntities = this.selectorEngine.findAll<SelectorElement & {
                        LTW: Matrix4_4
                    }>(dragPoint.selector, subjectHandle);
                    return dragPointEntities.map(dpe => ({
                        transform: subjectHandle.transform.LTW.inverse.multiply(dpe.LTW).multiply(transform),
                        dropPoints: dropPointProviders as Partial<DropPointProvider>[],
                        selector: this.selectorEngine.selector(dpe)
                    }));
                });

                const settings: PlacementModeSettings = {
                    camera: this._inputFeature.renderContext.camera,
                    subject: subjectHandle,
                    defaultCursorTransform: params.cursorTransform,
                    allowAllDragPoints: params.allowAllDragPoints,
                    allowDragPointCycling: params.allowDragPointCycling,
                    maxJumpDistance: params.maxJumpDistance,
                    dragPoints: resolvedDragPoints
                };

                const mode = this.dependencyResolver.instance(PlacementMode, settings);


[Request interrupted by user]


schau in fire-visual
das sit der source code


Ich habe die Engine-Quelle geprüft (fire-visual = der Source). Die Bindung ist **pro Drag-Point**, nicht gepoolt:

`interactionFeature.ts:283-308` — `params.dragPoints.flatMap(dragPoint => { const dropPointProviders = dragPoint.dropPoints.flatMap(...); ... return { dropPoints: dropPointProviders } })`. Jeder Drag-Point behält **seine eigenen** Drop-Points; `placementMode` bewertet `this.dropPoints[i]` je Drag-Point. Also ist auch die Engine gerichtet.

Damit sind **Rules, unser Whitelist-Payload und die Engine alle drei gerichtet**. Das Symptom kommt woanders her — und ich habe es:

## Der eigentliche Bug: Belegung pro PMI-Name statt pro physischem Ende

Ein physisches Element-Ende trägt **mehrere alternative Andock-PMIs**, nicht einen:
```
linkes Ende des Elements:  CS_OUT_R_0 (gerade), CS_OUT_R_90 (Ecke), CS_OUT_R_270 (Ecke), + _B-Varianten
```
(alle im selben Eck-Bereich, x≈−70…−20; nur andere Branch-Rotation).

Die Regel bildet `CS_EIN_L → [CS_OUT_R_0, CS_OUT_R_90, CS_OUT_R_270]` ab — also auf **alle** Punkte desselben Endes.

Und die Belegung (`occupiedPmis`, der Whitelist-Ausschluss, der Merge-Guard) ist auf den **einzelnen PMI-Namen** gekeyt: `(element, "CS_OUT_R_0")`. Folge:

1. Element M dockt `EIN_L → B.CS_OUT_R_0`. Belegt wird **nur** `B.CS_OUT_R_0`.
2. Die Geschwister `B.CS_OUT_R_90` / `B.CS_OUT_R_270` bleiben **offen**.
3. Element N dockt `EIN_L → B.CS_OUT_R_90` — an **dasselbe Ende** von B, erlaubt, weil ein anderer Name. Die 90°/270°-Variante dreht N dabei um die Ecke → N sitzt auf der „anderen Seite" desselben Endes.

Damit lässt sich dasselbe Ende mehrfach belegen — genau „gleiche Seite / beide Seiten, das darf nicht sein".

## Fix-Richtung
Die Exklusivität muss aufs **Ende** gehen, nicht auf den einzelnen PMI-Namen: Belegt man **einen** Punkt eines Endes, müssen **alle** Geschwister dieses Endes (`CS_OUT_R_0/90/270/_B`) als belegt gelten. Dafür braucht das Model eine Zuordnung „welche PMIs gehören zum selben Ende" — entweder über eine Namenskonvention (`CS_OUT_R_*` = ein Ende) oder über gemeinsame Position.

Den Diagnose-Test räume ich weg.


cd /Users/marcus.hinz/projects/adesso-cpq
rm packages/frontend/src/configurator/events/_diag.test.ts
echo "gelöscht"
git status --short packages/frontend/src/configurator/events/

Diagnose-Test entfernen


schau in fire-visual das sit der source code


gelöscht
 M packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts
 M packages/frontend/src/configurator/events/DefaultCommandProcessor.ts


Weg. Der Working Tree enthält damit nur wieder die eigentlichen Änderungen (Whitelist + instantiate-Refactor), der Diagnose-Test ist raus.

Zusammengefasst: Rules, unser Payload und die fire-Engine sind alle gerichtet — der Fehler liegt in der **Belegung pro PMI-Name statt pro Ende**. Ein Ende hat mehrere Andock-PMIs (`CS_OUT_R_0/90/270/_B`); das Belegen eines lässt die Geschwister offen, also lässt sich dasselbe Ende erneut belegen. Sag Bescheid, wenn ich den Fix (Ende-weise Exklusivität) bauen soll.


NEIN
es ist völlig in Ordnung wenn 3 C_EIN_L auf die UOT_R_0 / 90 / 270 snappen -> an das rechte Ende passen 3 Element (gerade / vor / hinten) aber eben 3 C_EIN_L Es darf KEIN C_EIN_R darauf kommen (=)


NEIN
es ist völlig in Ordnung wenn 3 C_EIN_L auf die UOT_R_0 / 90 / 270 snappen -> an das rechte Ende passen 3 Element (gerade / vor / hinten) aber eben 3 C_EIN_L Es darf KEIN C_EIN_R darauf kommen (0 -> GAR KEINER)


Klar, ich hatte die Exklusivität falsch verstanden — ein Ende darf mehrere `CS_EIN_L` tragen, aber **nie** ein `CS_EIN_R`. Unser Whitelist-Payload erzwingt das ja (im Dump: `EIN_L → nur OUT_R`, `EIN_R → nur OUT_L`). Wenn im Szenegraph trotzdem ein `EIN_R` auf einem `OUT_R` landet, ist die wahrscheinlichste Ursache **inkonsistente PMI-Konventionen zwischen den Element-Dateien**. Das prüfe ich jetzt über mehrere Elemente.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products/schutzzaun_basic
echo "Datei            | EIN_L (x/rotY) | EIN_R (x/rotY) | OUT_R_0 (x/rotY) | OUT_L_0 (x/rotY)"
echo "-----------------|----------------|----------------|------------------|-----------------"
for f in $(ls SBE*.json | head -12); do
python3 -c "
import json,math
d=json.load(open('$f'))
def side(x): return 'L' if x<500 else 'R'
for h in d['handles']:
    if 'class-schutzzaun_basic-element' in (h.get('tags') or []):
        pm={c['id']:c['properties'] for c in h.get('components',[]) if c.get('_resourceClass')=='component_pmi'}
        def fmt(n):
            if n not in pm: return '   --   '
            x=pm[n]['position']['value'][0]
            ry=round(math.degrees(pm[n].get('rotation',{}).get('value',[0,0,0])[1]))
            return f'{side(x)} {x:>6.0f}/{ry:+4d}'
        print(f\"{'$f':16} | {fmt('CS_EIN_L')} | {fmt('CS_EIN_R')} | {fmt('CS_OUT_R_0')} | {fmt('CS_OUT_L_0')}\")
        break
"
done

PMI-Konvention über mehrere Elemente vergleichen


Datei            | EIN_L (x/rotY) | EIN_R (x/rotY) | OUT_R_0 (x/rotY) | OUT_L_0 (x/rotY)
-----------------|----------------|----------------|------------------|-----------------
SBE0131SNA.json  | L     71/ +90 | L    -20/ -90 | L    -70/ +90 | L    121/ -90
SBE0174SNA.json  | L    114/ +90 | L    -20/ -90 | L    -70/ +90 | L    164/ -90
SBE0217PNA.json  | L    157/ +90 | L    -20/ -90 | L    -70/ +90 | L    207/ -90
SBE0217SNA.json  | L    157/ +90 | L    -20/ -90 | L    -70/ +90 | L    207/ -90
SBE0260PNA.json  | L    200/ +90 | L    -20/ -90 | L    -70/ +90 | L    250/ -90
SBE0260SNA.json  | L    200/ +90 | L    -20/ -90 | L    -70/ +90 | L    250/ -90
SBE0303PNA.json  | L    243/ +90 | L    -20/ -90 | L    -70/ +90 | L    293/ -90
SBE0303SNA.json  | L    243/ +90 | L    -20/ -90 | L    -70/ +90 | L    293/ -90
SBE0346PNA.json  | L    286/ +90 | L    -20/ -90 | L    -70/ +90 | L    336/ -90
SBE0346SNA.json  | L    286/ +90 | L    -20/ -90 | L    -70/ +90 | L    336/ -90
SBE0389PNA.json  | L    329/ +90 | L    -20/ -90 | L    -70/ +90 | L    379/ -90


NEIN es ist völlig in Ordnung wenn 3 C_EIN_L auf die UOT_R_0 / 90 / 270 snappen -> an das rechte Ende passen 3 Element (gerade / vor / hinten) aber eben 3 C_EIN_L Es darf KEIN C_EIN_R darauf kommen (0…


Gefunden — und diesmal die exakte Ursache, konsistent mit deiner Regel. Es liegt an den **`rotY`-Frames der PMIs**, und die sind zwischen `element` und `tuer` **vertauscht**.

## Frame-Vergleich (rotY)

| Punkt | Element | Tür |
|---|---|---|
| CS_EIN_L | **+90** | **−90** |
| CS_OUT_R_0 | **+90** | **−90** |
| CS_EIN_R | **−90** | **+90** |
| CS_OUT_L_0 | **−90** | **+90** |

Innerhalb einer Klasse passt es: `EIN_L`/`OUT_R` haben denselben Frame → `dockRotation = 0`, sauberer Anbau. `EIN_R`/`OUT_L` ebenso.

Aber das Vorzeichen ist **klassenübergreifend gespiegelt**. Der Frame ist das, was das Snappen geometrisch entscheidet (er bestimmt `dockRotation`). Gruppiert nach Frame:

- **+90**: `element.EIN_L`, `element.OUT_R`, `tuer.EIN_R`, `tuer.OUT_L`
- **−90**: `element.EIN_R`, `element.OUT_L`, `tuer.EIN_L`, `tuer.OUT_R`

Ein sauberer (identity) Dock entsteht nur zwischen **gleichem Frame**. Damit ist der geometrisch passende Partner von `tuer.OUT_R` (−90) genau **`element.EIN_R`** (−90) — **nicht** `element.EIN_L` (+90).

## Der Konflikt
Die **Namen/Regel** sagen: `OUT_R` nimmt nur `EIN_L`. Die **Frames** sagen (klassenübergreifend): an `tuer.OUT_R` passt geometrisch `element.EIN_R`. Genau dein Symptom — ein `EIN_R` landet auf einem `OUT_R`.

Regel und Geometrie widersprechen sich also, sobald Element und Tür zusammenkommen. Innerhalb einer Klasse merkt man nichts; gemischt kippt es.

## Fix
Nicht Code, nicht Rules — die **Frame-Konvention im Szenegraph vereinheitlichen**: `CS_EIN_L` und `CS_OUT_R` müssen über **alle** Klassen dasselbe `rotY`-Vorzeichen tragen (und `CS_EIN_R`/`CS_OUT_L` das entgegengesetzte). Aktuell nutzt das Element +90 für `EIN_L/OUT_R`, die Tür −90. Eine der beiden Konventionen ist an die andere anzugleichen (Asset-Generierung). Dann ist `EIN_L→OUT_R` überall name- **und** frame-korrekt, und ein `EIN_R` kann geometrisch nie auf ein `OUT_R` passen.


NEIN
DAS IOST NICHT DAS PORBLEM UND FALSCH DAZU
EIN EIN_R DARF NICHT AUF EIN OUT_L
kannst du mal ausgeben, was wir an den Grapen übergfeben, wenn wir den PlazierungsMode starten?


Verstanden — ich gebe dir aus, was wir beim Start des Placement-Mode übergeben. Ich baue einen Diagnose-Lauf mit echten Elementen (frei und angedockt) und drucke das komplette `start_placement_mode`-Argument.


NEIN DAS IOST NICHT DAS PORBLEM UND FALSCH DAZU EIN EIN_R DARF NICHT AUF EIN OUT_L kannst du mal ausgeben, was wir an den Grapen übergfeben, wenn wir den PlazierungsMode starten?


NEIN
einfach loggen ich schau im Broweser
Diagnode ggf. später
und logge bitte auch die Root-Id der geplacten Elemente
UND Nimm bitte alles andedren console logs von uns raus


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== unsere console.* im frontend src ==="
grep -rniE 'console\.(log|error|warn|info|debug)' packages/frontend/src 2>/dev/null | grep -v node_modules

Bestehende console-Aufrufe finden


=== unsere console.* im frontend src ===
packages/frontend/src/components/visual/ProductEditorDemo.vue:33:        console.error("Failed to load scene:", error);
packages/frontend/src/components/visual/ProductEditorDemo.vue:47:        console.log("Sending command:", commandPayload);
packages/frontend/src/components/visual/ProductEditorDemo.vue:71:            console.log("Product saved successfully:", result);
packages/frontend/src/components/visual/ProductEditorDemo.vue:74:            console.error("Failed to save product:", result);
packages/frontend/src/components/visual/ProductEditorDemo.vue:78:        console.error("Error saving product:", error);
packages/frontend/src/components/visual/Fire3D.vue:184:        console.error("Error saving product:", error);
packages/frontend/src/components/visual/Fire3D.vue:196:            console.log(
packages/frontend/src/components/building-blocks/README-ProductEditor.md:69:        console.error("Error saving product:", error);
packages/frontend/src/configurator/events/eventData.ts:38:        console.error("[extractMoveData] targets/positions length mismatch:", targets.length, positions.length);
packages/frontend/src/configurator/events/eventData.ts:64:        console.error("[extractRotateData] targets/rotations length mismatch:", targets.length, rotations.length);


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== console.* in DefaultCommandProcessor.ts ? ==="
grep -nE 'console\.' packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
echo
echo "=== console.* im gesamten configurator + services (breiter) ==="
grep -rnE 'console\.' packages/frontend/src/configurator packages/frontend/src/services 2>/dev/null

console in Processor und weiteren Modulen


=== console.* in DefaultCommandProcessor.ts ? ===

=== console.* im gesamten configurator + services (breiter) ===
packages/frontend/src/configurator/events/eventData.ts:38:        console.error("[extractMoveData] targets/positions length mismatch:", targets.length, positions.length);
packages/frontend/src/configurator/events/eventData.ts:64:        console.error("[extractRotateData] targets/rotations length mismatch:", targets.length, rotations.length);


Kein `console.*` im Processor (grep bestätigt). Zu entfernen: `eventData.ts` (2×), `ProductEditorDemo.vue` (5×), `Fire3D.vue` (2×). Neu: Root-Id beim Platzieren + Payload beim Placement-Start. Ich lese die Zielstellen im Processor.


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

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


NEIN einfach loggen ich schau im Broweser  Diagnode ggf. später und logge bitte auch die Root-Id der geplacten Elemente UND Nimm bitte alles andedren console logs von uns raus


Ich füge die Logs ein. **1. Root-Id des platzierten Elements:**


Edit

        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
        this.eventStore.push(createEvent);
        const scene = this.replay();

        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
        console.log("[placement] placed element rootId:", createEvent.rootHandleId);
        this.eventStore.push(createEvent);
        const scene = this.replay();


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)

        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
        this.eventStore.push(createEvent);
        const scene = this.replay();

        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
        console.log("[placement] placed element rootId:", createEvent.rootHandleId);
        this.eventStore.push(createEvent);
        const scene = this.replay();

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

+        console.log("[placement] placed element rootId:", createEvent.rootHandleId);


**2. Payload beim Placement-Start** (in `placementDragPoints`, direkt vor `return`):


        return [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}`,
            dropPoints,
        }));
    }

        const dragPoints = [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}`,
            dropPoints,
        }));
        console.log("[placement] start_placement_mode subject:", subject, "class:", klass, "dragPoints:", JSON.stringify(dragPoints, null, 2));
        return dragPoints;
    }


        return [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}`,
            dropPoints,
        }));
    }

        const dragPoints = [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}`,
            dropPoints,
        }));
        console.log("[placement] start_placement_mode subject:", subject, "class:", klass, "dragPoints:", JSON.stringify(dragPoints, null, 2));
        return dragPoints;
    }

-        return [...byDrag].map(([dragPoint, dropPoints]) => ({

+        const dragPoints = [...byDrag].map(([dragPoint, dropPoints]) => ({

+        console.log("[placement] start_placement_mode subject:", subject, "class:", klass, "dragPoints:", JSON.stringify(dragPoints, null, 2));

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


Jetzt die anderen Logs raus. **eventData.ts:**


32	export function extractMoveData(event: any) {
33	    const targets = event.subjects ?? [];
34	    const positions = event.positions ?? [];
35	    const delta = event.delta?.values;
36
37	    if (targets.length !== positions.length) {
38	        console.error("[extractMoveData] targets/positions length mismatch:", targets.length, positions.length);
39	        return [];
40	    }
41
42	    const result = [];
43	    for (let i = 0; i < targets.length; i++) {
44	        const entity = targets[i]?.entity;
45	        result.push({
46	            // The selected subject is the group node itself.
47	            handleId: entity?.id ?? entity?.parent?.id,
48	            delta,
49	            position: positions[i]?.values,
50	        });
51	    }
52	    return result;
53	}
54	export function extractRotateData(event: any) {
55	    const targets = event.subjects ?? [];
56	    // Rotation runs around the pivot, so it changes the part's position too — the mode provides
57	    // localRotations/localPositions; we record both (relative to the part's parent).
58	    const rotations = event.localRotations ?? [];
59	    const positions = event.localPositions ?? [];
60	    const axis = event.axis?.values;
61	    const deltaAngle = event.deltaAngle;
62
63	    if (targets.length !== rotations.length) {
64	        console.error("[extractRotateData] targets/rotations length mismatch:", targets.length, rotations.length);
65	        return [];
66	    }
67
68	    const result = [];
69	    for (let i = 0; i < targets.length; i++) {
70	        const entity = targets[i]?.entity;
71	        result.push({

export function extractMoveData(event: any) {
    const targets = event.subjects ?? [];
    const positions = event.positions ?? [];
    const delta = event.delta?.values;

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

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

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

    const result = [];
    for (let i = 0; i < targets.length; i++) {
        const entity = targets[i]?.entity;
        result.push({


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

    if (targets.length !== positions.length) {
        return [];
    }


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

    if (targets.length !== positions.length) {
        return [];
    }

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

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

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

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

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

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

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

-        console.error("[extractMoveData] targets/positions length mismatch:", targets.length, positions.length);


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

    if (targets.length !== rotations.length) {
        return [];
    }


    if (targets.length !== rotations.length) {
        return [];
    }

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

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

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

    if (targets.length !== positions.length) {
        return [];
    }

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

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

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

-        console.error("[extractRotateData] targets/rotations length mismatch:", targets.length, rotations.length);


175	        // If we got a new scene/product, update the visualization
176	        if (result && result.scene) {
177	            visual.value?.setScene(result.scene);
178	            currentSceneGraph.value = result.scene;
179	            toast.value?.show("Product updated successfully");
180	        }
181
182	        return result;
183	    } catch (error) {
184	        console.error("Error saving product:", error);
185	        toast.value?.show("Failed to update product");
186	        throw error;
187	    }
188	}
189
190	onMounted(async () => {
191	    window.addEventListener("keydown", onKeydown, true);
192	    visual.value = await createVisual3D(root.value!, props.config);
193	    visual.value.subscribe((event: any) => {
194	        // TEMP Analyse: eingehende fire-Events (v.a. selection-click) sichtbar machen
195	        if (event?.eventTypeName) {
196	            console.log(
197	                "🔎 fire-event:",
198	                event.eventTypeName,
199	                "| target.id:", event?.target?.id,
200	                "| target.selector:", event?.target?.selector,
201	                "| target.tags:", event?.target?.tags,
202	                "| action:", event?.selectionAction,
203	                "| raw:", event,
204	            );

        // If we got a new scene/product, update the visualization
        if (result && result.scene) {
            visual.value?.setScene(result.scene);
            currentSceneGraph.value = result.scene;
            toast.value?.show("Product updated successfully");
        }

        return result;
    } catch (error) {
        console.error("Error saving product:", error);
        toast.value?.show("Failed to update product");
        throw error;
    }
}

onMounted(async () => {
    window.addEventListener("keydown", onKeydown, true);
    visual.value = await createVisual3D(root.value!, props.config);
    visual.value.subscribe((event: any) => {
        // TEMP Analyse: eingehende fire-Events (v.a. selection-click) sichtbar machen
        if (event?.eventTypeName) {
            console.log(
                "🔎 fire-event:",
                event.eventTypeName,
                "| target.id:", event?.target?.id,
                "| target.selector:", event?.target?.selector,
                "| target.tags:", event?.target?.tags,
                "| action:", event?.selectionAction,
                "| raw:", event,
            );


190	onMounted(async () => {
191	    window.addEventListener("keydown", onKeydown, true);
192	    visual.value = await createVisual3D(root.value!, props.config);
193	    visual.value.subscribe((event: any) => {
194	        // TEMP Analyse: eingehende fire-Events (v.a. selection-click) sichtbar machen
195	        if (event?.eventTypeName) {
196	            console.log(
197	                "🔎 fire-event:",
198	                event.eventTypeName,
199	                "| target.id:", event?.target?.id,
200	                "| target.selector:", event?.target?.selector,
201	                "| target.tags:", event?.target?.tags,
202	                "| action:", event?.selectionAction,
203	                "| raw:", event,
204	            );
205	        }
206	        const reactions = processor.handleEvent(event);
207	        processReactions(reactions);
208	    });
209	    visualReady();
210	});
211

onMounted(async () => {
    window.addEventListener("keydown", onKeydown, true);
    visual.value = await createVisual3D(root.value!, props.config);
    visual.value.subscribe((event: any) => {
        // TEMP Analyse: eingehende fire-Events (v.a. selection-click) sichtbar machen
        if (event?.eventTypeName) {
            console.log(
                "🔎 fire-event:",
                event.eventTypeName,
                "| target.id:", event?.target?.id,
                "| target.selector:", event?.target?.selector,
                "| target.tags:", event?.target?.tags,
                "| action:", event?.selectionAction,
                "| raw:", event,
            );
        }
        const reactions = processor.handleEvent(event);
        processReactions(reactions);
    });
    visualReady();
});


[Request interrupted by user]


ICH HATTE GESAGT logs NICHT edrrors#


Stimmt — nur Logs, keine Errors. Ich stelle die zwei `console.error` in `eventData.ts` wieder her.


    if (targets.length !== positions.length) {
        return [];
    }

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


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

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

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

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

    if (targets.length !== positions.length) {
        return [];
    }

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

    if (targets.length !== rotations.length) {
        return [];
    }

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

+        console.error("[extractMoveData] targets/positions length mismatch:", targets.length, positions.length);


    if (targets.length !== rotations.length) {
        return [];
    }

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


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

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

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

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

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

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

    if (targets.length !== rotations.length) {
        return [];
    }

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

+        console.error("[extractRotateData] targets/rotations length mismatch:", targets.length, rotations.length);


Jetzt nur die `console.log`-Blöcke raus. **Fire3D.vue** (TEMP fire-event-Log):


    visual.value.subscribe((event: any) => {
        // TEMP Analyse: eingehende fire-Events (v.a. selection-click) sichtbar machen
        if (event?.eventTypeName) {
            console.log(
                "🔎 fire-event:",
                event.eventTypeName,
                "| target.id:", event?.target?.id,
                "| target.selector:", event?.target?.selector,
                "| target.tags:", event?.target?.tags,
                "| action:", event?.selectionAction,
                "| raw:", event,
            );
        }
        const reactions = processor.handleEvent(event);
        processReactions(reactions);
    });

    visual.value.subscribe((event: any) => {
        const reactions = processor.handleEvent(event);
        processReactions(reactions);
    });