=== TypedPropertyType color + Color value-Format ===
threed/pmi/module/tests/pmiComponent.test.ts:307:    it(functionTestName(PMIComponent, 'color'), () => {
threed/pmi/module/tests/pmiComponent.test.ts:311:                    type: TypedPropertyType.color,
threed/pmi/module/tests/pmiComponent.test.ts:313:                    usage: PMIUsage.color
threed/pmi/module/tests/render.test.ts:149:                    type: TypedPropertyType.color,
threed/pmi/module/tests/render.test.ts:151:                    usage: PMIUsage.color
threed/pmi/module/tests/render.test.ts:182:                    type: TypedPropertyType.color,
threed/pmi/module/tests/render.test.ts:184:                    usage: PMIUsage.color
threed/pmi/module/tests/render.test.ts:211:                    type: TypedPropertyType.color,
threed/pmi/module/tests/render.test.ts:213:                    usage: PMIUsage.color
threed/pmi/module/tests/render.test.ts:235:                    type: TypedPropertyType.color,
=== color property in tests (Beispiel) ===
threed/pmi/module/tests/pmiComponent.test.ts-312-                    value: Color.red,
threed/pmi/module/tests/pmiComponent.test.ts:313:                    usage: PMIUsage.color
threed/pmi/module/tests/pmiComponent.test.ts-314-                }
threed/pmi/module/tests/pmiComponent.test.ts-315-            }
threed/pmi/module/tests/render.test.ts-150-                    value: Color.red,
threed/pmi/module/tests/render.test.ts:151:                    usage: PMIUsage.color
threed/pmi/module/tests/render.test.ts-152-                }
threed/pmi/module/tests/render.test.ts-153-            }
--
threed/pmi/module/tests/render.test.ts-183-                    value: Color.red,
threed/pmi/module/tests/render.test.ts:184:                    usage: PMIUsage.color
threed/pmi/module/tests/render.test.ts-185-                }
threed/pmi/module/tests/render.test.ts-186-            }
--
threed/pmi/module/tests/render.test.ts-212-                    value: Color.red,
threed/pmi/module/tests/render.test.ts:213:                    usage: PMIUsage.color
threed/pmi/module/tests/render.test.ts-214-                }
threed/pmi/module/tests/render.test.ts-215-            }
--
threed/pmi/module/tests/render.test.ts-236-                    value: Color.red,
=== Color Typ: RGB oder RGBA? Color.green Definition ===
threed/math/dist/browser/index.js:4584:     * Shorthand for a instance representing the color green.
threed/math/dist/browser/index.js:4592:    static get green() {
threed/math/dist/browser/index.js:4720:     * Provides access to the green-channel.
Shell cwd was reset to /home/marcuh/claude-projects/robotunits


4584	     * Shorthand for a instance representing the color green.
4585	     * The alpha-channel is 1.
4586	     *
4587	     * @readonly
4588	     * @static
4589	     * @type {Color}
4590	     * @memberof Color
4591	     */
4592	    static get green() {
4593	        return new Color(0, 1, 0, 1);
4594	    }
4595	    /**
4596	     * Calculates the normal divergence between all color-channels of the given Color-instance.
4597	     *

     * Shorthand for a instance representing the color green.
     * The alpha-channel is 1.
     *
     * @readonly
     * @static
     * @type {Color}
     * @memberof Color
     */
    static get green() {
        return new Color(0, 1, 0, 1);
    }
    /**
     * Calculates the normal divergence between all color-channels of the given Color-instance.
     *


`Color` ist RGBA (`Color(0,1,0,1)`). Ich gebe den `CS_OUT_*`-PMIs eine `color`-Property in **Blau** `[0,0,1,1]` als Grundfarbe — dann sind aktive DropPoints blau, nur der gesnappte grün:


Bash

cd /home/marcuh/projects/adesso-cpq
DIR=packages/frontend/.storybook/products/schutzzaun_basic
BK=/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/d39343b7-d69e-4d28-8db5-a5803a4e8931/scratchpad/dropdot-backup
python3 << EOF
import json, glob, os
DIR="$DIR"; BK="$BK"; COLOR=[0,0,1,1]
files=sorted(glob.glob(os.path.join(DIR,"*.json")))
def add_color(node):
    n=0
    if isinstance(node,dict):
        for c in node.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi' and str(c.get('id','')).startswith('CS_OUT_'):
                props=c.setdefault('properties',{})
                if not any(isinstance(p,dict) and p.get('usage')=='color' for p in props.values()):
                    props['color']={"type":"color","value":COLOR,"usage":"color"}; n+=1
        for v in node.values(): n+=add_color(v)
    elif isinstance(node,list):
        for v in node: n+=add_color(v)
    return n
changed=0; tot=0; bad=[]
for f in files:
    raw=open(f,'rb').read()
    try: enc='utf-8'; s=raw.decode('utf-8')
    except UnicodeDecodeError: enc='latin-1'; s=raw.decode('latin-1')
    try: d=json.loads(s)
    except json.JSONDecodeError: bad.append(os.path.basename(f)); continue
    n=add_color(d)
    if n:
        bpath=os.path.join(BK, os.path.basename(f)+".bak")
        if not os.path.exists(bpath): open(bpath,'wb').write(raw)
        open(f,'wb').write(json.dumps(d,ensure_ascii=False,separators=(',',':')).encode(enc))
        changed+=1; tot+=n
print(f"Geänderte Dateien: {changed}, CS_OUT mit color=blau: {tot}, übersprungen(kein JSON): {bad}")
EOF

Add blue base color property to CS_OUT PMIs


Geänderte Dateien: 77, CS_OUT mit color=blau: 770, übersprungen(kein JSON): ['schutzzaun_basic.json']


Die sind jetzt immer grün --> nicht nur bei snap


Erledigt. 770 `CS_OUT_*`-PMIs in 77 Dateien haben jetzt `color: [0,0,1,1]` (blau) als Grundfarbe.

Wirkung über `createMaterial` (`pmiComponent.uiGizmo.ts:82`): `diffuse = colorOverride ?? color-Property`:
- kein Snap → `colorOverride` null → **blau**
- Snap-Treffer → `colorOverride` = `highlightColor` → **grün**

Also: aktive DropPoints blau, nur der gesnappte grün. Backup der Originale liegt weiter in `…/scratchpad/dropdot-backup/` (die `.bak` sind die unveränderten Ausgangsdateien, vor `diameter` und `color`). Uncommittet. Farbe/Größe bei Bedarf über `value` bzw. `diameter` justierbar.


die eigenschaft für drag war der poinr....Radius?


Ja. Der DragPoint-Ball hat keine PMI-Property, sondern wird global über `dragPointRadius` gesteuert (in `environment.config.json`, `placement`-Sektion) — Radius = `dragPointRadius × interactionGizmoScaleFactor`. Aktuell `0.2 × 400 = 80 mm`.

Im Unterschied zum DropPoint: der ist der PMI selbst und wird über dessen `diameter`-Property (jetzt 40) und `color`-Property (jetzt blau) gesteuert.


sind die Drag und Droppoints gedreht?
im Garph kippt die Tür, wenn sie snapt -> kann das an der Rotation hängen?


Ja, die Points sind gedreht — jeder hat eine `rotation`-Property. Aus den extrahierten Werten:
- DragPoints: `CS_EIN_L` = `[0,-90,0]`, `CS_EIN_R` = `[0,90,0]`
- DropPoints: `CS_OUT_R_0` = `[0,-90,0]`, `CS_OUT_L_0` = `[0,90,0]`, `CS_OUT_*_270` = `[180,~0,180]`

Und „im Graph kippt" = nach dem Verbau, also die cpq-Seite. Der Snap richtet die Tür am Point-Frame aus; die persistente Orientierung kommt aus `dockRotation`. Ich sehe mir an, wie die aus den beiden Point-Rotationen gerechnet wird:


cd /home/marcuh/projects/adesso-cpq
echo "=== dockRotation Definition ==="
grep -rniE 'dockRotation' packages/frontend/src --include='*.ts' | head
f=$(grep -rl 'dockRotation' packages/frontend/src --include='*.ts' | grep -iv test | head -1); echo "Datei: $f"

Locate dockRotation implementation


=== dockRotation Definition ===
packages/frontend/src/configurator/model/GroupProcessor.ts:639:    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
packages/frontend/src/configurator/model/GroupProcessor.test.ts:221:    it("dockRotation of the plain left/right points is identity", () => {
packages/frontend/src/configurator/model/GroupProcessor.test.ts:226:        const r = gp.dockRotation(e1.rootHandleId, "CS_RECHTS", e2.rootHandleId, "CS_LINKS");
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:313:                    rot: this.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),
Datei: packages/frontend/src/configurator/model/GroupProcessor.ts


639	    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
640	        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
641	    }
642
643	    private rootTransform(handle: Handle): any {
644	        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
645	    }
646
647	    private roleOf(handle: Handle): NodeRole {
648	        for (const t of handle?.tags ?? []) {
649	            if (typeof t === "string" && t.startsWith("role-")) return t.slice(5) as NodeRole;
650	        }
651	        return ROLE.geometry;
652	    }
653
654	    // Index every materialised node by handle id → {parent handle id, role}, resolving each
655	    // transform's parentKey to the owning handle. The role resolver walks this.
656	    private buildNodeIndex(handles: Handle[]): void {
657	        const keyToId = new Map<string, string>();
658	        for (const h of handles) {
659	            const tr = this.rootTransform(h);
660	            if (tr?.key) keyToId.set(tr.key, h.id);
661	        }
662	        this.nodeIndex = new Map();
663	        for (const h of handles) {
664	            const tr = this.rootTransform(h);
665	            const parentId = tr?.parentKey ? keyToId.get(tr.parentKey) ?? null : null;
666	            this.nodeIndex.set(h.id, { parentId, role: this.roleOf(h) });
667	        }
668	    }
669	}
670

    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
    }

    private rootTransform(handle: Handle): any {
        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
    }

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

    // Index every materialised node by handle id → {parent handle id, role}, resolving each
    // transform's parentKey to the owning handle. The role resolver walks this.
    private buildNodeIndex(handles: Handle[]): void {
        const keyToId = new Map<string, string>();
        for (const h of handles) {
            const tr = this.rootTransform(h);
            if (tr?.key) keyToId.set(tr.key, h.id);
        }
        this.nodeIndex = new Map();
        for (const h of handles) {
            const tr = this.rootTransform(h);
            const parentId = tr?.parentKey ? keyToId.get(tr.parentKey) ?? null : null;
            this.nodeIndex.set(h.id, { parentId, role: this.roleOf(h) });
        }
    }
}


`dockRotation = anchorPmiRot × conj(joinPmiRot)` (`GroupProcessor.ts:640`) — die persistente Verbau-Rotation ist also direkt das Produkt der beiden Point-Rotationen, und die geht in `Edge.rot`/`computeLayout` ein. Wie die PMI-Rotation gelesen wird (die als „quaternion" deklarierten 3-Euler-Werte sind hier heikel):


cd /home/marcuh/projects/adesso-cpq
echo "=== pmiRotation Definition ==="
grep -niE 'pmiRotation' packages/frontend/src/configurator/model/GroupProcessor.ts | head
sed -n "$(grep -n 'private pmiRotation\|pmiRotation(' packages/frontend/src/configurator/model/GroupProcessor.ts | grep -v dockRotation | head -1 | cut -d: -f1),+20p" packages/frontend/src/configurator/model/GroupProcessor.ts

Show pmiRotation implementation


=== pmiRotation Definition ===
625:    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
640:        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
        const handle = this.handles.get(rootHandleId);
        for (const comp of handle?.components ?? []) {
            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
                const rot = comp.properties?.rotation?.value;
                return rot ? eulerZyxDegToQuat(rot as Vec3) : IDENTITY_QUAT;
            }
        }
        return IDENTITY_QUAT;
    }

    // Relative rotation of the join part w.r.t. the anchor part for a dock: aligning the join point's
    // frame onto the anchor point's turns the join part by R_anchorPmi ∘ R_joinPmi⁻¹. Identity when
    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
    }

    private rootTransform(handle: Handle): any {
        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
    }


sind die Drag und Droppoints gedreht? im Garph kippt die Tür, wenn sie snapt -> kann das an der Rotation hängen?


Das ist der kritische Punkt: `pmiRotation` liest die `rotation`-Value und wandelt sie mit **`eulerZyxDegToQuat`** — also als **Euler-Winkel ZYX in Grad**, obwohl die Property in der JSON als `"type":"quaternion"` deklariert ist. Ob fire dieselbe Konvention nutzt, entscheidet, ob visueller Snap und Graph-Verbau übereinstimmen. Ich prüfe fires Interpretation:


cd /home/marcuh/projects/fire-visual
echo "=== fire: wie wird PMI rotation-property zu Rotation/LTW? ==="
grep -niE 'rotation|euler|quaternion|LTW|localRotation|localMatrix' threed/pmi/module/src/component/pmiComponent.ts | grep -iE 'euler|quaternion|rotation|LTW|localMatrix|localRotation' | head -20

Check how fire interprets PMI rotation property


=== fire: wie wird PMI rotation-property zu Rotation/LTW? ===
11:    QuaternionProperty,
22:import {Bounds, Color, Matrix4_4, Quaternion, Vector2, Vector3,} from '@fire-visual/math';
132:                return new Bounds(Vector3.zero, size.multiply(0.5)).transform(this.localMatrix);
138:                return new Bounds(Vector3.zero, new Vector3(size2d.x / 2, 0, size2d.y / 2)).transform(this.localMatrix);
144:                return Bounds.createFromVertices(Vector3.zero, endpoint).transform(this.localMatrix).expand(radius);
151:                return new Bounds(halfVec, halfVec).transform(this.localMatrix);
159:        return this.localBounds.transform(this.parentLTW);
167:        return Matrix4_4.decompose(this.LTW)[0];
200:    public get hasRotation(): boolean {
201:        return this.existsPropertyByUsage(PMIUsage.rotation);
204:    public get rotation(): Quaternion {
205:        return Matrix4_4.decompose(this.LTW)[1];
208:    public set rotation(value: Quaternion) {
209:        const [, parentRotation] = Matrix4_4.decompose(this.parentLTW);
210:        this.localRotation = parentRotation.inverse().multiply(value);
213:    public get localRotation(): Quaternion {
214:        if (!this.existsPropertyByUsage(PMIUsage.rotation)) {
215:            return Quaternion.identity;
217:        return this.getPropertyValueByUsage<QuaternionProperty>(TypedPropertyType.quaternion, PMIUsage.rotation);
220:    public set localRotation(value: Quaternion) {
Shell cwd was reset to /home/marcuh/claude-projects/robotunits


Diskrepanz gefunden: **fire liest dieselbe `rotation`-Value als echtes Quaternion** (`getPropertyValueByUsage<QuaternionProperty>(TypedPropertyType.quaternion, …)`, `pmiComponent.ts:217`), **cpq liest sie als Euler-ZYX-Grad** (`eulerZyxDegToQuat`). Die Werte sind `[0,-90,0]` etc. — 3 Zahlen. Wie fire ein 3-Werte-Array zu einem Quaternion macht, entscheidet, ob beide dasselbe meinen:


cd /home/marcuh/projects/fire-visual
echo "=== QuaternionProperty: wie wird [x,y,z] (3 Werte) zu Quaternion? ==="
grep -rniE 'QuaternionProperty|class Quaternion|fromEuler|euler|quaternion.*value|parseQuaternion' threed/engine/module/src 2>/dev/null | grep -viE 'test|\.d\.ts' | grep -iE 'QuaternionProperty|fromEuler|euler|quaternion' | head -15
echo "=== Quaternion.fromEuler / Reihenfolge im math-Modul ==="
grep -rniE 'fromEuler|eulerOrder|ZYX|XYZ|zyx|xyz|toRadians' threed/math/module/src 2>/dev/null | grep -viE 'test|\.d\.ts' | head -15

Find how fire parses quaternion property from 3 values


=== QuaternionProperty: wie wird [x,y,z] (3 Werte) zu Quaternion? ===
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/geometryTransformer.ts:3:import {degreeToRad, Euler, EulerOrder, Transformation, Vector, Vector2, Vector3} from '@fire-visual/math';
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/geometryTransformer.ts:190:            preTransform.rotation = Euler.toQuaternion(
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/geometryTransformer.ts:191:                new Euler(x, y, z, EulerOrder.ZYX)
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:2:import {degreeToRad, Euler, EulerOrder, Quaternion, Vector3} from '@fire-visual/math';
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:382:        // http://help.autodesk.com/view/FBX/2017/ENU/?guid=__cpp_ref_class_fbx_euler_html
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:385:                EulerOrder.XYZ, // default
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:386:                EulerOrder.XZY,
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:387:                EulerOrder.YZX,
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:388:                EulerOrder.ZXY,
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:389:                EulerOrder.YXZ,
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:390:                EulerOrder.ZYX
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:398:                // Note: Euler order other than XYZ is currently not supported, so just display a warning for now
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:399:                this.logger.warn(logMessage(this, 'setHandleTransforms', `unsupported Euler Order: ${orderMapping[value]}. Currently only XYZ order is supported. Animations and rotations may be incorrect.`));
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:401:                this.logger.warn(logMessage(this, 'setHandleTransforms', 'unsupported Euler Order: Spherical XYZ. Animations and rotations may be incorrect.'));
threed/engine/module/src/dataManager/parser/fbxParser/worker/componentTransformer/sceneTransformer.ts:418:            settings.rotation = (settings.rotation as Quaternion).multiply(Euler.toQuaternion(
=== Quaternion.fromEuler / Reihenfolge im math-Modul ===
threed/math/module/src/euler.ts:13:export enum EulerOrder {
threed/math/module/src/euler.ts:14:    XYZ = 1,
threed/math/module/src/euler.ts:19:    ZYX
threed/math/module/src/euler.ts:35: * Represents the rotation around the three main-axis in rad in a specific order. The default order is xyz.
threed/math/module/src/euler.ts:41:    public static get defaultOrder(): EulerOrder {
threed/math/module/src/euler.ts:42:        return EulerOrder.XYZ;
threed/math/module/src/euler.ts:47:        [EulerOrder.XYZ]:(m,v) => {
threed/math/module/src/euler.ts:57:        [EulerOrder.YXZ]:(m,v) =>{
threed/math/module/src/euler.ts:67:        [EulerOrder.ZXY]:(m,v) => {
threed/math/module/src/euler.ts:77:        [EulerOrder.ZYX]:(m,v) => {
threed/math/module/src/euler.ts:87:        [EulerOrder.YZX]:(m,v) => {
threed/math/module/src/euler.ts:96:        },[EulerOrder.XZY]:(m,v) => {
threed/math/module/src/euler.ts:113:     * @param {EulerOrder} [order=Euler.defaultOrder]
threed/math/module/src/euler.ts:117:    public static fromMatrix(matrix: Matrix, order: EulerOrder = Euler.defaultOrder): Euler {
threed/math/module/src/euler.ts:143:     * @param {EulerOrder} [order=Euler.defaultOrder]
Shell cwd was reset to /home/marcuh/claude-projects/robotunits


Ja — und die Rotation ist mit hoher Wahrscheinlichkeit die Kipp-Ursache. Die Kette:

**1. Points sind gedreht.** Jeder Drag-/DropPoint trägt eine `rotation`-Property. Die Winkel-Varianten sind nicht harmlos, z. B. `CS_OUT_*_270 = [180, 0, 180]` und `_0 = [0, ±90, 0]`.

**2. Die Rotation geht direkt in den Verbau.** `dockRotation = anchorPmiRot × conj(joinPmiRot)` (`GroupProcessor.ts:640`) füllt `Edge.rot` → `computeLayout`. Die relative Orientierung der beiden Point-Frames wird also 1:1 zur Orientierung der gedockten Tür.

**3. cpq und fire interpretieren dieselbe Property unterschiedlich:**
- **cpq** (`pmiRotation`): `eulerZyxDegToQuat(rot)` — liest `[0,-90,0]` als **Euler ZYX in Grad**.
- **fire** (`pmiComponent.localRotation`): `getPropertyValueByUsage<QuaternionProperty>(TypedPropertyType.quaternion, …)` — liest denselben Wert als **Quaternion** (die JSON deklariert `"type":"quaternion"`).

Das ist der wunde Punkt: Der Wert ist als `quaternion` typisiert, cpq behandelt ihn aber als Euler-ZYX-Grad. Passt die Euler-Reihenfolge/das Vorzeichen nicht zur Exportkonvention der Katalog-Points — oder interpretiert fire beim Snap den Wert anders als cpq beim Verbau — dann rastet es visuell scheinbar richtig ein, aber `Edge.rot` dreht die Tür im Graph weg → sie kippt. Die 180°-Anteile der `_270`-Points sind der offensichtlichste Kandidat.

Was ich **nicht** sicher habe: wie fires `QuaternionProperty` ein 3-Werte-Array konkret in ein Quaternion umsetzt (Euler-Order XYZ vs. cpqs ZYX). Das ist der Abgleich, der die Diskrepanz bestätigt oder ausschließt — dazu müsste man das `QuaternionProperty`-Parsing in `@fire-visual/engine` gegen `eulerZyxDegToQuat` prüfen.


NEINM eher nicht
Drr Graph, der von uns zurückkommt passt
wir haben dfas Problem bei live drag & drop --> wenn sich die Tür dem Snap PÜoint nähertm dreht sie plötzlich weg --> liegt waagerecht in der Luft, bir derr neue Graph gerendered wird


Verstanden — das Problem ist der **Live-Snap in fire**, nicht der cpq-Graph. Sobald der Snap greift, springt die Tür auf eine falsche Ziel-Rotation (waagerecht), bis `setScene` den korrekten cpq-Graph liefert. Ich sehe mir an, welche Rotation der Live-Snap setzt und wie fire die Point-Rotation dafür interpretiert:


cd /home/marcuh/projects/fire-visual
echo "=== pointDropPoint.assessRay: was liefert best.rotation? ==="
cat threed/pmi/module/src/dropPoint/pointDropPoint.ts
echo "=== lineDropPoint (falls CS_OUT ein anderer Typ ist) Kopf ==="
sed -n '1,40p' threed/pmi/module/src/dropPoint/lineDropPoint.ts

Read pointDropPoint assessRay rotation logic


=== pointDropPoint.assessRay: was liefert best.rotation? ===
import {PmiDropPoint} from './pmiDropPoint';
import type {PMIComponent} from '../component';
import {closest, Ray, Segment, Vector3} from '@fire-visual/math';
import type {RayAssessment} from '@fire-visual/interaction';

export class PointDropPoint extends PmiDropPoint {
    constructor(pmiComponent: PMIComponent) {
        super(pmiComponent);
    }

    assessRay(ray: Ray<Vector3>): RayAssessment {
        const position = this.LTW.multiplyPoint(Vector3.zero);
        const rayPoint = closest.pointOnLine(position, Segment.fromRay(ray));
        return {
            position,
            jumpDistance: position.distance(rayPoint),
            rayDistance: rayPoint.distance(ray.origin),
            rotation: this.dragPointRotation
        };
    }
}
=== lineDropPoint (falls CS_OUT ein anderer Typ ist) Kopf ===
import {PmiDropPoint} from './pmiDropPoint';
import {type PMIComponent, PMIUsage, Vector3PMIProperty} from '../component';
import {closest, Ray, Segment, Vector3} from '@fire-visual/math';
import type {RayAssessment} from '@fire-visual/interaction';

export class LineDropPoint extends PmiDropPoint {

    private readonly endpoint: Vector3;

    constructor(pmiComponent: PMIComponent) {
        super(pmiComponent);
        this.endpoint = pmiComponent.getPropertyValueByUsage<Vector3PMIProperty>(PMIUsage.endpoint);
    }

    private get segment(): Segment<Vector3> {
        const start = this.LTW.multiplyPoint(Vector3.zero);
        // endpoint is expressed in parents coordinate system
        const endpoint = this.pmiComponent.parentLTW.multiplyPoint(this.endpoint);
        return new Segment(start, endpoint);
    }

    assessRay(ray: Ray<Vector3>): RayAssessment {
        const [position, rayPoint] = closest.segmentOnLine(this.segment, Segment.fromRay(ray));
        return {
            position,
            jumpDistance: position.distance(rayPoint),
            rayDistance: rayPoint.distance(ray.origin),
            rotation: this.dragPointRotation
        };
    }
}
Shell cwd was reset to /home/marcuh/claude-projects/robotunits


`best.rotation = dragPointRotation = decompose(DropPoint.LTW)` — also die Welt-Rotation des DropPoint-PMI, direkt als Snap-Ziel-Frame. Damit hängt alles daran, wie fire die `rotation`-Property (als `quaternion` typisiert, aber 3 Euler-Grad-Werte) in die LTW einbaut. Das ist der Beweis-Punkt:


cd /home/marcuh/projects/fire-visual
echo "=== wie wird eine 'quaternion'-Property (3 Werte) deserialisiert? ==="
grep -rniE "quaternion|QuaternionProperty|fromEuler|Euler.toQuaternion|new Quaternion" threed/engine/module/src --include='*.ts' 2>/dev/null | grep -viE 'test|\.d\.ts|fbxParser' | grep -iE 'quaternion.*value|QuaternionProperty|parse.*quat|quat.*parse|case .quaternion|toQuaternion|fromArray|Euler' | head -20
echo "=== localMatrix Aufbau in pmiComponent (position+rotation -> LTW) ==="
grep -niE 'localMatrix|localRotation|localPosition|LTW|compose|Matrix4_4' threed/pmi/module/src/component/pmiComponent.ts | grep -iE 'localMatrix|LTW|compose' | head

Find quaternion property deserialization in fire engine


=== wie wird eine 'quaternion'-Property (3 Werte) deserialisiert? ===
threed/engine/module/src/dataManager/parser/glbParser/worker/glbProcessor.ts:147:            rotation: [input.quaternion.w, input.quaternion.x, input.quaternion.y, input.quaternion.z],
threed/engine/module/src/rendering/transpose.ts:89:     * Creates or updates an instance of THREE_JS.Quaternion, with the values of a math.Quaternion
threed/engine/module/src/rendering/utils/serialization/quaternionType.ts:1:import {Euler, EulerOrder, Quaternion, Tupel4} from '@fire-visual/math';
threed/engine/module/src/rendering/utils/serialization/quaternionType.ts:12:                return Euler.toQuaternion(new Euler(input[0], input[1], input[2], EnumType.deserialize(input[3] || 'ZYX', EulerOrder)));
threed/engine/module/src/rendering/utils/typedProperty/typedProperty.ts:62:export interface QuaternionProperty extends TypedProperty {
=== localMatrix Aufbau in pmiComponent (position+rotation -> LTW) ===
132:                return new Bounds(Vector3.zero, size.multiply(0.5)).transform(this.localMatrix);
138:                return new Bounds(Vector3.zero, new Vector3(size2d.x / 2, 0, size2d.y / 2)).transform(this.localMatrix);
144:                return Bounds.createFromVertices(Vector3.zero, endpoint).transform(this.localMatrix).expand(radius);
151:                return new Bounds(halfVec, halfVec).transform(this.localMatrix);
159:        return this.localBounds.transform(this.parentLTW);
167:        return Matrix4_4.decompose(this.LTW)[0];
205:        return Matrix4_4.decompose(this.LTW)[1];
209:        const [, parentRotation] = Matrix4_4.decompose(this.parentLTW);
296:        return this.LTW.inverse;
394:    public get parentLTW(): Matrix4_4 {
Shell cwd was reset to /home/marcuh/claude-projects/robotunits


NEINM eher nicht Drr Graph, der von uns zurückkommt passt wir haben dfas Problem bei live drag & drop --> wenn sich die Tür dem Snap PÜoint nähertm dreht sie plötzlich weg --> liegt waagerecht in der…


Wichtiger Negativbefund — deine Einschätzung stimmt: **fire und cpq interpretieren die Rotation identisch.** `quaternionType.ts:12` deserialisiert eine `quaternion`-Property mit 3 Werten als `Euler.toQuaternion(new Euler(v0,v1,v2, 'ZYX'))` — Default-Order **ZYX**, exakt cpqs `eulerZyxDegToQuat`. Die Interpretation ist also nicht die Ursache.

Der reale Unterschied: cpq wendet die *relative* `dockRotation` an, der Live-Snap setzt die Tür *absolut* auf `best.rotation × dragPointTransform.inverse`. Ob der DragPoint-Transform dabei seine Rotation mitbringt, ist entscheidend — ich prüfe, wie fire den DragPoint-Selektor auflöst:


cd /home/marcuh/projects/fire-visual
echo "=== Auflösung dragPoint-Selektor -> transform (bei start_placement_mode) ==="
grep -rniE 'dragPoints|dragPoint|placement|transform|selector|find|LTW|resolve' threed/interaction/module/src/feature/interactionFeature.ts 2>/dev/null | grep -iE 'dragPoint|placement|transform.*=|selector|LTW|find' | head -20

Find drag point transform resolution from selector


=== Auflösung dragPoint-Selektor -> transform (bei start_placement_mode) ===
threed/interaction/module/src/feature/interactionFeature.ts:12:    SelectorElement,
threed/interaction/module/src/feature/interactionFeature.ts:13:    SelectorEngine,
threed/interaction/module/src/feature/interactionFeature.ts:18:import {CursorInputMap, CursorSelector} from './cursorSelector';
threed/interaction/module/src/feature/interactionFeature.ts:26:import {DropPointProvider, PlacementMode, PlacementModeSettings} from '../interactionMode/placementMode';
threed/interaction/module/src/feature/interactionFeature.ts:27:import {CursorInteractionType, CursorOptions, ModeOptionProvider, PlacementModeOptions} from '../options';
threed/interaction/module/src/feature/interactionFeature.ts:31:    placement: PlacementModeOptions;
threed/interaction/module/src/feature/interactionFeature.ts:49:    placement: {
threed/interaction/module/src/feature/interactionFeature.ts:54:            dragPointActiveColor: Color.yellow,
threed/interaction/module/src/feature/interactionFeature.ts:55:            dragPointInactiveColor: Color.blue,
threed/interaction/module/src/feature/interactionFeature.ts:56:            dragPointRadius: 0.1
threed/interaction/module/src/feature/interactionFeature.ts:61:            dragPointActiveColor: {_type: Color, default: () => Color.yellow},
threed/interaction/module/src/feature/interactionFeature.ts:62:            dragPointInactiveColor: {_type: Color, default: () => Color.blue},
threed/interaction/module/src/feature/interactionFeature.ts:63:            dragPointRadius: {_type: 'number', default: 0.1}
threed/interaction/module/src/feature/interactionFeature.ts:86:    @PropertyResolver({_type: SelectorEngine, isOptional: true})
threed/interaction/module/src/feature/interactionFeature.ts:87:    private readonly selectorEngine?: SelectorEngine;
threed/interaction/module/src/feature/interactionFeature.ts:90:    private _cursorSelector: CursorSelector;
threed/interaction/module/src/feature/interactionFeature.ts:120:     * CursorSelector, which holds a reference to the same object, picks up the change
threed/interaction/module/src/feature/interactionFeature.ts:122:     * update to CursorSelector in case it holds a separate copy.
threed/interaction/module/src/feature/interactionFeature.ts:128:        this._cursorSelector?.updateSettings(cursor);
threed/interaction/module/src/feature/interactionFeature.ts:148:            placement: this._settings.placement,
Shell cwd was reset to /home/marcuh/claude-projects/robotunits


Die selector→transform-Auflösung der DragPoints passiert außerhalb dieser Datei. Ich suche, wo aus dem cpq-`{selector, dropPoints}` ein `{transform, …}` mit Rotation wird:


cd /home/marcuh/projects/fire-visual
grep -rniE "start_placement_mode|dragPoints.*map|transform:.*LTW|\.transform =|selectorEngine.*select|resolveDragPoint|toPlacementSettings" threed/interaction/module/src --include='*.ts' 2>/dev/null | grep -viE 'test|\.d\.ts' | grep -iE 'placement|dragPoint|transform|LTW|selector' | head -20
echo "=== wer konstruiert PlacementMode / setzt dragPoints[].transform ==="
grep -rniE 'new PlacementMode|dragPoints:|transform:' threed/interaction/module/src --include='*.ts' 2>/dev/null | grep -viE 'test|\.d\.ts' | head

Locate PlacementMode construction and dragPoint transform assignment


threed/interaction/module/src/feature/interactionFeature.ts:87:    private readonly selectorEngine?: SelectorEngine;
threed/interaction/module/src/feature/interactionFeature.ts:199:            const position = this.selectorEngine.find<SelectorElement & {
threed/interaction/module/src/feature/interactionFeature.ts:219:            rotation = this.selectorEngine.find<SelectorElement & {rotation:Quaternion}>(options.pivotRotationFromSelection)?.rotation;
threed/interaction/module/src/feature/interactionFeature.ts:259:            start_placement_mode: async (params: {
threed/interaction/module/src/feature/interactionFeature.ts:283:                const resolvedDragPoints = params.dragPoints.flatMap(dragPoint => {
threed/interaction/module/src/feature/interactionFeature.ts:286:                        this.selectorEngine.findAll<Component>(dropPointSelector)
threed/interaction/module/src/feature/interactionFeature.ts:301:                    const dragPointEntities = this.selectorEngine.findAll<SelectorElement & {
threed/interaction/module/src/feature/interactionFeature.ts:305:                        transform: subjectHandle.transform.LTW.inverse.multiply(dpe.LTW).multiply(transform),
threed/interaction/module/src/feature/interactionFeature.ts:307:                        selector: this.selectorEngine.selector(dpe)
threed/interaction/module/src/feature/interactionFeature.ts:331:                this.selectionManager.set(selectors.flatMap(s => this.selectorEngine.findAll(s)).filter(s => isTypeSelectable(s)).map(s => s as Selectable));
threed/interaction/module/src/feature/interactionFeature.ts:335:                this.selectionManager.add(selectors.flatMap(s => this.selectorEngine.findAll(s)).filter(s => isTypeSelectable(s)).map(s => s as Selectable));
threed/interaction/module/src/feature/interactionFeature.ts:339:                this.selectionManager.remove(selectors.flatMap(s => this.selectorEngine.findAll(s)).filter(s => isTypeSelectable(s)).map(s => s as Selectable));
threed/interaction/module/src/feature/interactionFeature.ts:401:        start_placement_mode(params: {
threed/interaction/module/src/interactionMode/placementMode.ts:143:                // after this, we want subject.matrix * dragPoint.transform = best.matrix
threed/interaction/module/src/interactionMode/placementMode.ts:249:            return this._settings.dragPoints.flatMap((x, i) => func(x, i));
threed/interaction/module/src/interactionMode/placementMode.ts:263:            handles: this._settings.dragPoints.map((_, i) => ({
threed/interaction/module/src/interactionMode/placementMode.ts:274:            this.dragPointHandles = this._settings.dragPoints.map((_, i) =>
=== wer konstruiert PlacementMode / setzt dragPoints[].transform ===
threed/interaction/module/src/feature/interactionFeature.ts:265:                dragPoints: Array<{
threed/interaction/module/src/feature/interactionFeature.ts:305:                        transform: subjectHandle.transform.LTW.inverse.multiply(dpe.LTW).multiply(transform),
threed/interaction/module/src/feature/interactionFeature.ts:314:                    defaultCursorTransform: params.cursorTransform,
threed/interaction/module/src/feature/interactionFeature.ts:315:                    allowAllDragPoints: params.allowAllDragPoints,
threed/interaction/module/src/feature/interactionFeature.ts:318:                    dragPoints: resolvedDragPoints
threed/interaction/module/src/feature/interactionFeature.ts:407:            dragPoints: Array<{
threed/interaction/module/src/interactionMode/placementMode.ts:10:    dragPoints: Array<{
threed/interaction/module/src/interactionMode/placementMode.ts:11:        transform: Matrix4_4;
threed/interaction/module/src/interactionMode/placementMode.ts:127:                        dragPointTransform: dragPoint.transform,
threed/interaction/module/src/interactionMode/placementMode.ts:245:    private mapActiveDragPoints<T>(func: (dragPoint: { transform: Matrix4_4 }, index: number) => T[]): T[] {
Shell cwd was reset to /home/marcuh/claude-projects/robotunits


259	            start_placement_mode: async (params: {
260	                subject: string;
261	                cursorTransform?: Matrix4_4;
262	                allowAllDragPoints?: boolean;
263	                allowDragPointCycling?: boolean;
264	                maxJumpDistance?: number;
265	                dragPoints: Array<{
266	                    selector?: string;
267	                    transform?: { position: Vector3, rotation: Quaternion };
268	                    dropPoints: string[];
269	                }>;
270	            }): Promise<number> => {
271
272	                if (!this.selectorEngine) {
273	                    throw new Error("SelectorEngine not available");
274	                }
275
276	                // Resolve subject selector to Handle
277	                const subjectHandle = this.selectorEngine.find<Handle>(params.subject);
278	                if (!subjectHandle) {
279	                    throw new Error(`Subject not found: ${params.subject}`);
280	                }
281
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

            start_placement_mode: async (params: {
                subject: string;
                cursorTransform?: Matrix4_4;
                allowAllDragPoints?: boolean;
                allowDragPointCycling?: boolean;
                maxJumpDistance?: number;
                dragPoints: Array<{
                    selector?: string;
                    transform?: { position: Vector3, rotation: Quaternion };
                    dropPoints: string[];
                }>;
            }): Promise<number> => {

                if (!this.selectorEngine) {
                    throw new Error("SelectorEngine not available");
                }

                // Resolve subject selector to Handle
                const subjectHandle = this.selectorEngine.find<Handle>(params.subject);
                if (!subjectHandle) {
                    throw new Error(`Subject not found: ${params.subject}`);
                }

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


Jetzt ist der strukturelle Unterschied klar. Der DragPoint-Transform wird sauber aufgelöst (`interactionFeature.ts:305`: `subjLTW⁻¹ · dpe.LTW`, volle Rotation dabei). Das Kippen kommt aus **wie** der Live-Snap orientiert — und das ist grundlegend anders als cpq:

**Live-Snap (fire)** — absolutes World-Frame-Matching:
- `best.rotation = decompose(DropPoint.LTW)` (`pointDropPoint`/`pmiDropPoint`) — die **World-Rotation** des DropPoint-PMI, inklusive aller Parent-Node-Rotationen.
- `subject.matrix = compose(best.position, best.rotation, 1) · dragPointTransform⁻¹` (`placementMode.ts:145`) — die Tür wird so gedreht, dass der **komplette DragPoint-Frame deckungsgleich auf den kompletten DropPoint-Frame** fällt (alle drei Achsen).

**Verbau (cpq)** — relative Rotation aus lokalen Werten:
- `dockRotation = eulerZyxDegToQuat(dropPmi.rotation) × conj(eulerZyxDegToQuat(dragPmi.rotation))` — nur die **lokalen** property-Werte, ohne Parent-LTW, und nur die *relative* Drehung, die auf die vorhandene Tür-Orientierung addiert wird.

Das ist der Kern: fire erzwingt volle Frame-Kongruenz aus den **World-Frames** (Parent-Rotationen wirken mit), cpq wendet eine relative Drehung aus den **lokalen** Point-Rotationen an. Sind die Point-Frames nicht in allen drei Achsen komplementär orientiert (z. B. lokale Z statt Y als Andock-Normale), legt das absolute Matching die Tür flach, während die relative dockRotation sie aufrecht lässt. Darum kippt sie live und steht nach `setScene` (cpq-Graph) korrekt.

Nicht belegt habe ich, welche Achse konkret die Kippung erzeugt — dafür müsste man die tatsächlichen Frame-Achsen eines gematchten `CS_EIN`/`CS_OUT`-Paars (World-LTW) gegen die lokalen Werte halten.


das verstehe ich nicht
sind die pmis in unseren graphen jetzt falsch oder nicht?


Nein, die PMIs in euren Graphen sind nicht falsch.

Der Beweis liegt in deiner eigenen Aussage: cpq berechnet den Verbau aus genau diesen PMI-Rotationen (`dockRotation`), und der zurückkommende Graph passt. Wären die PMI-Werte falsch, käme auch cpqs Ergebnis falsch heraus — tut es aber nicht.

Das Kippen ist kein Datenproblem, sondern ein reines Live-Darstellungsproblem in fire: fire rechnet während des Ziehens die Snap-Orientierung anders aus als cpq (absolutes World-Frame-Matching statt relative Rotation). Dieselben korrekten PMIs, zwei verschiedene Rechenwege — fires Live-Weg kippt, cpqs Verbau-Weg nicht.

Also: PMIs korrekt, fire-Live-Snap fehlerhaft.


das verstehe ich nicht sind die pmis in unseren graphen jetzt falsch oder nicht?


ok -- eine PMI hatte eine Rotation von 63 irgendwie und die war korrekt? muss die rotation des PMI nicht mit der des parents übereinstimmen?


Zwei getrennte Punkte:

**Zur „63":** In der Einzeltür-JSON (`SBS1044SLA`) habe ich keine Rotation um 63 gesehen — die Rotationswerte dort sind 0, ±90, 180, 270 (plus numerisches Rauschen wie `2.1e-14`). Wenn du einen bestimmten PMI/eine andere Datei meinst, sag welchen; „korrekt" kann ich nur zu einem konkreten Wert sagen.

**Muss die PMI-Rotation mit der des Parents übereinstimmen? Nein.**

Die `rotation` am PMI ist die **lokale** Orientierung des Andock-Frames **relativ zum Parent-Node** — nicht seine Weltorientierung. Die Weltorientierung ergibt sich erst durch Verkettung:

```
PMI-World-Rotation (LTW) = Parent-World-Rotation × PMI-lokale-Rotation
```

Ein Andockpunkt ist sogar meistens *bewusst anders* gedreht als sein Bauteil — z. B. ±90°, damit sein Frame in die Andock-/Flucht-Richtung zeigt statt in Richtung des Bauteil-Koordinatensystems. Eine PMI-Rotation von `[0,-90,0]` an einem aufrecht stehenden Türnode ist also völlig normal und richtig, keine Abweichung.

Genau hier setzt der fire-vs-cpq-Unterschied an: cpq rechnet mit der **lokalen** PMI-Rotation (nur relativ), fire mit der **verketteten World-Rotation** (Parent wirkt mit). Deshalb darf — und soll — die PMI-Rotation vom Parent abweichen; „übereinstimmen müssen" wäre sogar falsch.


In derr Einzeltür gib mir aml die Rottaionen der OMIS bitte


cd /home/marcuh/projects/adesso-cpq
python3 << 'EOF'
import json
f='packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json'
d=json.load(open(f))
seen={}  # (id, rot-tuple) -> count
def r(v):
    return tuple(round(float(x),3) for x in v)
def walk(o):
    if isinstance(o,dict):
        for c in o.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi':
                rot=(c.get('properties',{}) or {}).get('rotation',{}).get('value')
                if rot is not None:
                    key=(c.get('id'), r(rot))
                    seen[key]=seen.get(key,0)+1
        for v in o.values(): walk(v)
    elif isinstance(o,list):
        for v in o: walk(v)
walk(d)
print(f"Eindeutige (PMI-ID, Rotation)-Kombinationen: {len(seen)}\n")
print(f"{'PMI-ID':24}{'Rotation (lokal, Grad)':30}{'Anzahl'}")
print('-'*62)
for (pid,rot),n in sorted(seen.items()):
    print(f"{pid:24}{str(list(rot)):30}{n}")
EOF

Extract unique PMI id+rotation combinations for Einzeltür


Eindeutige (PMI-ID, Rotation)-Kombinationen: 51

PMI-ID                  Rotation (lokal, Grad)        Anzahl
--------------------------------------------------------------
CS_BASE                 [-90.0, -0.0, 90.0]           1
CS_BASE_PRE             [90.0, -90.0, 0.0]            1
CS_BASIS                [0.0, -0.0, 0.0]              1
CS_EIN                  [-90.0, -0.0, 0.0]            3
CS_EIN                  [0.0, -90.0, -90.0]           1
CS_EIN                  [0.0, 270.0, 0.0]             1
CS_EIN                  [90.0, -0.0, 180.0]           4
CS_EIN_180_0_0          [-90.0, -0.0, 180.0]          4
CS_EIN_270_0_0          [180.0, -0.0, 180.0]          4
CS_EIN_90_0_0           [0.0, -0.0, 180.0]            4
CS_EIN_90_0_180         [0.0, -0.0, 0.0]              4
CS_EIN_C4F_H_LINKS      [0.0, 90.0, 0.0]              4
CS_EIN_C4F_H_RECHTS     [0.0, -90.0, 0.0]             4
CS_EIN_HINTEN           [90.0, -0.0, -90.0]           4
CS_EIN_HINTEN_RECHTS    [0.0, -0.0, 0.0]              1
CS_EIN_L                [-0.0, -90.0, 0.0]            1
CS_EIN_LAENGS           [0.0, -0.0, 0.0]              67
CS_EIN_LINKS            [-90.0, -0.0, 0.0]            1
CS_EIN_LINKS            [180.0, -0.0, -90.0]          1
CS_EIN_QUER             [90.0, 0.0, 90.0]             67
CS_EIN_QUER_LINKS       [0.0, 90.0, 180.0]            4
CS_EIN_QUER_RECHTS      [0.0, -90.0, 180.0]           4
CS_EIN_R                [-0.0, 90.0, 0.0]             1
CS_EIN_RECHTS           [90.0, -0.0, 180.0]           1
CS_EIN_RECHTS           [180.0, -0.0, 90.0]           1
CS_EIN_START_L          [-0.0, -90.0, 0.0]            1
CS_EIN_START_R          [-0.0, 90.0, 0.0]             1
CS_EIN_STEHER_HINTEN    [180.0, -0.0, 180.0]          4
CS_EIN_STEHER_OBEN      [90.0, 0.0, 90.0]             2
CS_EIN_STEHER_OBEN      [90.0, -0.0, 180.0]           4
CS_EIN_STEHER_UNTEN     [-90.0, -0.0, 90.0]           4
CS_EIN_STEHER_VORNE     [0.0, -0.0, 180.0]            4
CS_EIN_UNT_STEHER       [90.0, -0.0, -90.0]           2
CS_EIN_VORNE            [90.0, 0.0, 90.0]             4
CS_EIN_VORNE_LINKS      [180.0, -0.0, 180.0]          1
CS_OUT_L_0              [-0.0, 90.0, 0.0]             1
CS_OUT_L_270            [180.0, 0.0, 180.0]           1
CS_OUT_L_270_B          [180.0, 0.0, 180.0]           1
CS_OUT_L_90             [0.0, -0.0, 0.0]              1
CS_OUT_L_90_B           [0.0, -0.0, 0.0]              1
CS_OUT_R_0              [-0.0, -90.0, 0.0]            1
CS_OUT_R_270            [180.0, 0.0, 180.0]           1
CS_OUT_R_270_B          [180.0, 0.0, 180.0]           1
CS_OUT_R_90             [0.0, -0.0, 0.0]              1
CS_OUT_R_90_B           [0.0, -0.0, 0.0]              1
CS_SELECT_L_0           [-0.0, -90.0, 0.0]            1
CS_SELECT_L_270         [0.0, -0.0, 0.0]              1
CS_SELECT_L_90          [180.0, 0.0, 180.0]           1
CS_SELECT_R_0           [-0.0, 90.0, 0.0]             1
CS_SELECT_R_270         [0.0, -0.0, 0.0]              1
CS_SELECT_R_90          [180.0, 0.0, 180.0]           1


Alle eindeutigen PMI-Rotationen der Einzeltür (`SBS1044SLA`), lokal in Grad — Tabelle oben. Kernpunkte:

- **Keine 63.** Sämtliche Rotationswerte sind Vielfache von 90° (0, ±90, 180, 270). Eine „63er" existiert in dieser Tür nicht.
- Die für den Snap aktiven Andockpunkte:
  - `CS_EIN_L` = `[0,-90,0]`, `CS_EIN_R` = `[0,90,0]`
  - `CS_EIN_START_L` = `[0,-90,0]`, `CS_EIN_START_R` = `[0,90,0]`
  - `CS_OUT_*_0` = `[0,∓90,0]`, `CS_OUT_*_90` = `[0,0,0]`, `CS_OUT_*_270` = `[180,0,180]`

Die `[180,0,180]`-Rotationen der `_270`-Punkte sind die einzigen mit doppelter 180°-Komponente — das sind die Kandidaten, bei denen das absolute World-Frame-Matching in fire kippen kann, während cpqs relative `dockRotation` sauber bleibt.

Wenn die „63" aus einem anderen Produkt/einer anderen Datei stammt, nenn die Artikelnummer, dann hole ich die dort.


wo hatte der die 63 her
wie ist der parent rotiert


Die lokalen PMI-Rotationen sind alle 90°-Vielfache — eine 63 kann also nur aus einer **Parent-Node-Rotation** (`component_transform`) kommen, die in die World-LTW einfließt. Ich schaue mir die Transform-Rotationen der Nodes an:


cd /home/marcuh/projects/adesso-cpq
python3 << 'EOF'
import json
f='packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json'
d=json.load(open(f))
rots={}
def walk(o):
    if isinstance(o,dict):
        for c in o.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_transform':
                r=c.get('rotation')
                if r:
                    t=tuple(round(float(x),3) for x in r)
                    rots[t]=rots.get(t,0)+1
        for v in o.values(): walk(v)
    elif isinstance(o,list):
        for v in o: walk(v)
walk(d)
print("component_transform Rotationen (unique):")
print(f"{'Rotation':34}{'Anzahl'}")
print('-'*44)
for t,n in sorted(rots.items(), key=lambda x:-x[1]):
    mark=''
    if any(abs(v%90)>0.5 and abs(v%90-90)>0.5 for v in t): mark='  <-- kein 90°-Vielfaches'
    print(f"{str(list(t)):34}{n}{mark}")
EOF

Extract node transform rotations, flag non-90 values


component_transform Rotationen (unique):
Rotation                          Anzahl
--------------------------------------------
[-1.571, -1.571, 0.0]             102  <-- kein 90°-Vielfaches
[0.0, 0.0, 0.0]                   47
[1.571, -0.0, 3.142]              6  <-- kein 90°-Vielfaches
[-3.142, -1.571, 0.0]             6  <-- kein 90°-Vielfaches
[1.571, -0.0, 0.0]                6  <-- kein 90°-Vielfaches
[1.571, -1.571, 0.0]              4  <-- kein 90°-Vielfaches
[1.571, 1.571, 0.0]               4  <-- kein 90°-Vielfaches
[-1.571, 1.571, 0.0]              4  <-- kein 90°-Vielfaches
[1.571, -0.0, 1.571]              2  <-- kein 90°-Vielfaches
[3.142, -0.0, 1.571]              2  <-- kein 90°-Vielfaches
[3.142, -0.0, 3.142]              2  <-- kein 90°-Vielfaches


wo hatte der die 63 her wie ist der parent rotiert


Das ist der eigentliche Fund. Die Parent-/Node-Rotationen (`component_transform.rotation`) stehen in **Radiant**, die PMI-Rotationen in **Grad** — im selben Graphen zwei Einheiten:

| Node-Transform (rad) | in Grad | Anzahl |
|---|---|---|
| `[-1.571, -1.571, 0]` | `[-90, -90, 0]` | 102 |
| `[0, 0, 0]` | `[0,0,0]` | 47 |
| `[1.571, 0, 3.142]` | `[90, 0, 180]` | 6 |
| `[-3.142, -1.571, 0]` | `[-180, -90, 0]` | 6 |
| `[1.571, 0, 0]` | `[90, 0, 0]` | 6 |
| … | alle 90°-Vielfache | … |

`1.571 = π/2`, `3.142 = π` — eindeutig Radiant (als Grad wären das 1,5°, als PMI-Wert stünde dort `90`). Der häufigste Parent (102×) ist also **[-90°, -90°, 0°]**, kodiert in Radiant.

**Zur „63":** In den Rohdaten kommt keine 63 vor — weder bei PMIs (alle 90°-Vielfache in Grad) noch bei Nodes (alle 90°-Vielfache in Radiant). Eine 63 kann nur als *Rechenergebnis* entstehen, wenn irgendwo die beiden Einheiten gemischt werden — z. B. wenn die World-LTW aus Node (rad) und PMI (deg) ohne konsistente Umrechnung verkettet und dann zu Euler zerlegt wird, fallen krumme Winkel wie 63 heraus.

Wo genau du die 63 abgelesen hast (fire-Debug, cpq, welcher PMI), weiß ich nicht — das entscheidet, an welcher Stelle die rad/deg-Mischung passiert. Das ist der Punkt, den es als Nächstes zu prüfen gilt: ob fire beim Aufbau der PMI-LTW die Node-Rotation als Radiant und die PMI-Rotation als Grad behandelt — dann stimmt der visuelle Snap-Frame nicht mit cpqs (rein Grad-basierter, lokaler) Rechnung überein.


Doppeltür?


cd /home/marcuh/projects/adesso-cpq
python3 << 'EOF'
import json, math
f='packages/frontend/.storybook/products/schutzzaun_basic/SBD2083SLA.json'
d=json.load(open(f))
node_rots={}; pmi_rots={}
def is90deg(v): return abs(round(v/90)*90 - v)<0.5
def is90rad(v):
    deg=math.degrees(v); return abs(round(deg/90)*90 - deg)<0.5
def walk(o):
    if isinstance(o,dict):
        for c in o.get('components',[]) or []:
            rc=c.get('_resourceClass')
            if rc=='component_transform' and c.get('rotation'):
                t=tuple(round(float(x),3) for x in c['rotation']); node_rots[t]=node_rots.get(t,0)+1
            if rc=='component_pmi':
                r=(c.get('properties',{}) or {}).get('rotation',{}).get('value')
                if r: t=tuple(round(float(x),3) for x in r); pmi_rots[t]=pmi_rots.get(t,0)+1
        for v in o.values(): walk(v)
    elif isinstance(o,list):
        for v in o: walk(v)
walk(d)
print("SBD2083SLA (Doppeltür)")
print("=== component_transform Rotationen (unique) ===")
for t,n in sorted(node_rots.items(), key=lambda x:-x[1]):
    krumm = any(not is90rad(v) for v in t)
    print(f"  {str(list(t)):32}{n:>4}{'   <-- KRUMM (kein 90°-Vielfaches in rad)' if krumm else ''}")
print(f"\n=== PMI Rotationen: krumme (kein 90°-Vielfaches in Grad) ===")
any_krumm=False
for t,n in sorted(pmi_rots.items()):
    if any(not is90deg(v) for v in t):
        print(f"  {str(list(t)):32}{n}"); any_krumm=True
if not any_krumm: print("  keine — alle PMI-Rotationen sind 90°-Vielfache (Grad)")
EOF

Analyze Doppeltür node and PMI rotations for units/crooked angles


SBD2083SLA (Doppeltür)
=== component_transform Rotationen (unique) ===
  [-1.571, -1.571, 0.0]            192
  [0.0, 0.0, 0.0]                   91
  [1.571, -0.0, 0.0]                12
  [1.571, -0.0, 3.142]              10
  [-3.142, -1.571, 0.0]             10
  [1.571, 1.571, 0.0]                6
  [1.571, -1.571, 0.0]               4
  [-1.571, 1.571, 0.0]               4
  [1.571, -0.0, 1.571]               2
  [3.142, -0.0, 1.571]               2
  [3.142, -0.0, 3.142]               2

=== PMI Rotationen: krumme (kein 90°-Vielfaches in Grad) ===
  keine — alle PMI-Rotationen sind 90°-Vielfache (Grad)


Doppeltür (`SBD2083SLA`) verhält sich identisch zur Einzeltür:

- **Node-Transforms in Radiant**, alle saubere 90°-Vielfache — häufigster Parent `[-1.571, -1.571, 0]` = `[-90°, -90°, 0°]` (192×). Kein krummer Wert.
- **PMI-Rotationen in Grad**, alle 90°-Vielfache. Keine krumme dabei.

Also auch hier **keine 63** in den Rohdaten und derselbe rad/deg-Einheiten-Mismatch (Nodes Radiant, PMIs Grad). Beide Türtypen sind datenseitig gleich sauber; die 63 entsteht nicht in den Daten, sondern erst durch eine Rechnung, die die beiden Einheiten mischt.


dann verstehe ich nicht, woher das drehen kommen kann
Die Tür steht nicht waagerecht - in einem schiefen Winkel zum Objkekt, uaf das sie snapped
Ein Einbua hint hat sie zum Teil einen nicht 90Grad Ausrichtung


Das passt zusammen — wenn die Daten sauber 90°-Vielfache sind und trotzdem ein krummer Winkel herauskommt, entsteht der in der Umrechnung. Verdacht: fire liest die PMI-`rotation` (Grad) über `Euler`, der **Radiant** erwartet. Ich prüfe, ob `quaternionType` eine Grad→Radiant-Umrechnung macht oder nicht:


cd /home/marcuh/projects/fire-visual
echo "=== quaternionType.ts (Deserialisierung der quaternion-property) ==="
cat threed/engine/module/src/rendering/utils/serialization/quaternionType.ts
echo "=== Euler: erwartet rad? toQuaternion Einheit ==="
grep -niE 'rad|degree|degreeToRad|toQuaternion|constructor|class Euler' threed/math/module/src/euler.ts | head

Read quaternionType deserialization and Euler unit


=== quaternionType.ts (Deserialisierung der quaternion-property) ===
import {Euler, EulerOrder, Quaternion, Tupel4} from '@fire-visual/math';
import {EnumType} from './enumType';

export type QuaternionType = | Quaternion | [number, number, number, string?] | Tupel4<number>;
export namespace QuaternionType {
    export function deserialize(input: QuaternionType): Quaternion {
        if (input instanceof Quaternion) {
            return input;
        }
        if (Array.isArray(input)) {
            if (input.length === 3 || typeof input[3] == 'string') {
                return Euler.toQuaternion(new Euler(input[0], input[1], input[2], EnumType.deserialize(input[3] || 'ZYX', EulerOrder)));
            }
            return new Quaternion(input[0], input[1], input[2], input[3]);
        }
        return Quaternion.identity;
    }

    export function serialize(input: QuaternionType): Tupel4<number> {
        if (input instanceof Quaternion) {
            return input.values;
        } else {
            return deserialize(input).values;
        }
    }
}
=== Euler: erwartet rad? toQuaternion Einheit ===
1:import {getConstructorName, notDefined} from '@fire-visual/utils';
35: * Represents the rotation around the three main-axis in rad in a specific order. The default order is xyz.
38: * @class Euler
40:export class Euler {
162:    public static toQuaternion(euler: Euler): Quaternion {
305:    public constructor(
350:        return Euler.toQuaternion(this).equal(Euler.toQuaternion(b));
371:        return `${getConstructorName(this)}:[${this._values
Shell cwd was reset to /home/marcuh/claude-projects/robotunits


dann verstehe ich nicht, woher das drehen kommen kann Die Tür steht nicht waagerecht - in einem schiefen Winkel zum Objkekt, uaf das sie snapped Ein Einbua hint hat sie zum Teil einen nicht 90Grad Aus…


Jetzt ist es bewiesen. Die Ursache ist eine **Einheiten-Inkonsistenz zwischen den zwei Rotationsarten im selben Graph**:

- **PMI-`rotation`**: in **Grad** (`[0,-90,0]`)
- **Node-`transform.rotation`**: in **Radiant** (`[-1.571,…]` = `[-90°,…]`)

Und fire erwartet durchgängig **Radiant**. Beweis in `quaternionType.ts`:

```js
return Euler.toQuaternion(new Euler(input[0], input[1], input[2], … 'ZYX'));
```

Die PMI-Werte gehen **ohne Grad→Radiant-Umrechnung** direkt in `new Euler(...)`, und `Euler` ist laut `euler.ts:35` „the rotation … **in rad**". fire interpretiert also `-90` (gemeint: −90°) als **−90 Radiant** — Faktor ≈ 57,3 daneben. `−90 rad mod 2π` in der ZYX-Kombination, wieder zu Euler zerlegt, liefert genau solche krummen Winkel wie deine 63.

Warum cpq trotzdem stimmt: cpq nutzt `eulerZyxDegToQuat` — es rechnet Grad **explizit** nach Radiant. Deshalb ist der Verbau-Graph korrekt, nur fires Live-Snap dreht schief.

Zusammengefasst:
- Node-Transforms (rad) → fire liest sie richtig.
- PMI-Rotationen (deg) → fire liest sie als rad → **schiefer Snap-Winkel**.
- cpq rechnet beide korrekt → Graph passt.

Die PMI-Rotationen stehen also in der „falschen" Einheit für fire. Sauber wäre, die PMI-`rotation` im Graph in Radiant zu führen (konsistent mit den Node-Transforms) — dann müsste allerdings cpqs `eulerZyxDegToQuat` entsprechend auf Radiant umgestellt werden. Alternativ fires Snap-Pfad die PMI-Rotation als Grad behandeln. Das ist eine Design-Entscheidung; sag, welchen Weg du gehen willst.


bau mal cpq un - bitte aus einem branch


Bevor ich umbaue, kläre ich Branch und Fix-Weg. Zuerst der aktuelle cpq-Git-Stand (es liegen viele uncommittete Änderungen aus dieser Session):


cd /home/marcuh/projects/adesso-cpq
git branch --show-current
echo "=== uncommitted (Kategorien) ==="
git status --porcelain | awk '{print substr($0,1,2)}' | sort | uniq -c
echo "=== geänderte Nicht-Katalog-Dateien ==="
git status --porcelain | sed -E 's/^.. //' | grep -viE 'products/(schutzzaun|rollenfoerderer)' | head

Check cpq branch and uncommitted changes


Produktmanagement
=== uncommitted (Kategorien) ===
 164  M
=== geänderte Nicht-Katalog-Dateien ===
packages/frontend/.storybook/fire/environment.config.json
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts


[Request interrupted by user]


NEIN
die änderungen aus dieser Session SIND ALLE TEMOPRÄR
DAS MUSS WOANDERS GELÖST WEREDN


Verstanden. Die Session-Änderungen (`dragPointRadius`, `diameter`/`color` an den Katalog-JSONs, Start-Points raus in `DefaultCommandProcessor`) waren Debug-Experimente — die kommen nicht in den Fix.

Zwei Dinge, bevor ich Branch und Umbau anfasse:

1. **Temp-Änderungen** (164 Dateien): stashen (behalten, aber vom Branch weg) oder verwerfen?
2. **„Woanders"** — wo soll der rad/deg-Fix hin? Du hast eine Stelle im Kopf. Meine offene Frage: die Konvertierung der PMI-`rotation` (Grad→Radiant) beim Szene-Aufbau, bevor der Graph an fire geht — oder eine andere Ebene (Datenpipeline/Speedmaxx-Export, fire selbst)?

Sag mir die Zielstelle, dann lege ich den sauberen Branch an und baue nur dort.


NEIN die änderungen aus dieser Session SIND ALLE TEMOPRÄR DAS MUSS WOANDERS GELÖST WEREDN


du kannst doch einfach einen neuen branch uaf machen

das heißt andeers rum
was waeren denn wirksame änderungen am code aus dieser session?
die ändrerungen an des assets nicht?


Richtig — die Asset-/Daten-Änderungen sind kein Code. Aus dieser Session sind an cpq nur **zwei Nicht-Asset-Dateien** geändert:

**Echte Code-Änderung (1):**
- `src/configurator/events/DefaultCommandProcessor.ts` — `CS_EIN_START_L/R` aus den DragPoints entfernt (plus Kommentar angepasst). Das ist die einzige echte Logik-Änderung.

**Config/Fixture (1):**
- `.storybook/fire/environment.config.json` — `dragPointRadius` 40 → 0.2. Kein Produktiv-Code, nur Storybook-Debug-Wert.

**Assets/Daten (162 Dateien, kein Code):**
- Katalog-JSONs in `products/schutzzaun_basic/` — `diameter`+`color` an den `CS_OUT`-PMIs, Ausreißer-PMIs raus. Alles temporär.

Also: die einzige wirksame Code-Änderung ist `DefaultCommandProcessor.ts` (Start-Points raus) — und die hat mit dem rad/deg-Problem nichts zu tun, das ist unabhängig. Der rad/deg-Fix selbst ist noch nirgends im Code; der käme neu dazu.


HALLLOOOOOO - ZIUHÖREN
WAREN DIE ÄNDERUNGEN AN DEN ASSTES NÖTIG JA / NEIN


Nein.


aha -- > kannst du die Rückgängig machen?


Bevor ich lösche, ein kritischer Punkt — die uncommitteten Asset-Änderungen sind zwei Dinge übereinander:


AskUserQuestion

Question: Was genau an den Assets zurücknehmen?

Choices:
diameter + color an CS_OUT und die entfernten Ausreißer-PMIs zurück; das Katalog-Update vom 7. Juli (dein „kannst du updaten“) bleibt drin.
Komplett zurück auf den letzten Commit (debdb4e) — auch das Katalog-Update vom 7. Juli. products/ ist danach exakt wie committet.


Your questions have been answered: "Was genau an den Assets zurücknehmen?"="Ich hätte gerne die Assets, wie wir sie neu in dieser Session eingespielt haben
ABER <_> Eine Aussage von die stimmt wohl nicht:: Die Änderung der PMIs war sehr wohl wirksam". You can now continue with these answers in mind.

Question: Was genau an den Assets zurücknehmen?

Choices:
diameter + color an CS_OUT und die entfernten Ausreißer-PMIs zurück; das Katalog-Update vom 7. Juli (dein „kannst du updaten“) bleibt drin.
Komplett zurück auf den letzten Commit (debdb4e) — auch das Katalog-Update vom 7. Juli. products/ ist danach exakt wie committet.

Ich hätte gerne die Assets, wie wir sie neu in dieser Session eingespielt haben
ABER <_> Eine Aussage von die stimmt wohl nicht:: Die Änderung der PMIs war sehr wohl wirksam


Korrektur akzeptiert — meine Aussage war falsch. Die PMI-Änderung (`diameter`+`color` an den `CS_OUT`) war wirksam: erst dadurch wurde der DropPoint sichtbar und damit das schiefe Snappen überhaupt erkennbar. Ohne sie hätten wir die rad/deg-Ursache nicht gesehen.

Ich fasse jetzt **nichts** an. Stand der Asset-Eingriffe dieser Session:

1. **Katalog-Update (7. Juli)** — neu eingespielt → behalten ✓
2. **diameter + color an CS_OUT** (77 Dateien) → wirksam, behalten ✓
3. **Ausreißer-PMIs entfernt** (4 Türdateien, Koordinaten bei ~±20000) → Datenbereinigung

Wenn alles bleibt, gibt es nichts rückgängig zu machen. Sag nur, ob einer der drei doch raus soll — sonst lasse ich `products/` genau so, wie es jetzt ist.


DU VERSTEHST NICHT???
Katalob behalten --> der Rest weg

da hatte ich doch gesagt???????#


Katalog behalten, meine PMI-Eingriffe weg. Sauberster Weg: die schutzzaun_basic-JSONs aus der Quelle (robotunits/katalog) neu einspielen — das überschreibt diameter/color und die Ausreißer-Entfernung mit den reinen Katalogdaten:


Bash

SRC=/home/marcuh/claude-projects/robotunits/katalog/schutzzaun_basic
DST=/home/marcuh/projects/adesso-cpq/packages/frontend/.storybook/products/schutzzaun_basic
find "$SRC" -maxdepth 1 -type f -name '*.json' -exec cp {} "$DST/" \;
echo "Neu eingespielt aus Quelle."
cd /home/marcuh/projects/adesso-cpq
echo "=== Verifikation: PMI-Manip weg? ==="
echo -n "CS_OUT mit diameter/color (soll 0): "
grep -rl '"usage":"diameter"' "$DST"/*.json 2>/dev/null | wc -l | tr -d ' '
echo -n "Dateien mit usage:color (soll 0): "
grep -rl '"usage":"color"' "$DST"/*.json 2>/dev/null | wc -l | tr -d ' '
echo -n "Ausreißer-PMIs wieder da (SBS1044SLA, |Koord|>3000, soll >0): "
python3 -c "
import json
d=json.load(open('$DST/SBS1044SLA.json'))
n=0
def w(o):
    global n
    if isinstance(o,dict):
        for c in o.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi':
                p=(c.get('properties',{}) or {}).get('position',{}).get('value') or [0,0,0]
                if any(abs(float(x))>3000 for x in p): n+=1
        for v in o.values(): w(v)
    elif isinstance(o,list):
        [w(x) for x in o]
w(d); print(n)
"

Re-copy clean catalog jsons, verify PMI changes gone


DU VERSTEHST NICHT??? Katalob behalten --> der Rest weg  da hatte ich doch gesagt???????#


Neu eingespielt aus Quelle.
=== Verifikation: PMI-Manip weg? ===
CS_OUT mit diameter/color (soll 0): 0
Dateien mit usage:color (soll 0): 0
Ausreißer-PMIs wieder da (SBS1044SLA, |Koord|>3000, soll >0): 17


Erledigt. `products/schutzzaun_basic/` ist zurück auf den reinen Katalog-Stand vom 7. Juli:
- `diameter`-Properties: 0 (weg)
- `color`-Properties: 0 (weg)
- Ausreißer-PMIs: wieder da (17 in SBS1044SLA)

Was jetzt noch uncommitted in cpq ist:


cd /home/marcuh/projects/adesso-cpq
echo "Kategorien:"; git status --porcelain | awk '{print substr($0,1,2)}' | sort | uniq -c
echo "Nicht-Katalog (Code/Config):"; git status --porcelain | sed -E 's/^.. //' | grep -viE 'products/(schutzzaun|rollenfoerderer|allround)'

Show remaining uncommitted state


Kategorien:
 164  M
Nicht-Katalog (Code/Config):
packages/frontend/.storybook/fire/environment.config.json
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts


Passt. Die 164 M sind jetzt:

- **162 Katalog-JSONs** — das gewollte Katalog-Update vom 7. Juli (ohne meine PMI-Eingriffe). Bleibt.
- **2 Nicht-Katalog-Dateien** — die Code/Config-Änderungen dieser Session, unangetastet:
  - `environment.config.json` (dragPointRadius 0.2 — Debug-Wert)
  - `DefaultCommandProcessor.ts` (Start-Points raus)

Die Asset-PMI-Manipulationen sind weg. Der Katalog-Stand ist erhalten. Die beiden Code/Config-Dateien habe ich nicht angefasst — sag, ob die auch zurück sollen oder bleiben.


You're juggling multiple branches and stashing changes — git worktrees let you have multiple working directories from the same repo so you can run a Claude session per branch without switching.

git worktree add ../adesso-cpq-feature <branch>


die beiden änderungen commiten


Neuer Branch (wie gewünscht „aus einem branch"), dann nur die 2 Code/Config-Dateien committen — die Katalog-JSONs bleiben uncommitted:


cd /home/marcuh/projects/adesso-cpq
git checkout -b placement-dragpoints
git add packages/frontend/.storybook/fire/environment.config.json packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
git commit -m "Placement: Start-Punkte aus DragPoints entfernt, dragPointRadius korrigiert"
echo "=== Status danach ==="
git log --oneline -1
git status -sb | head -1
echo "Uncommitted verbleibend:"; git status --porcelain | wc -l | tr -d ' '

Create branch and commit the two code/config changes


Switched to a new branch 'placement-dragpoints'
[placement-dragpoints 129ffb9] Placement: Start-Punkte aus DragPoints entfernt, dragPointRadius korrigiert
 2 files changed, 4 insertions(+), 7 deletions(-)
=== Status danach ===
129ffb9 Placement: Start-Punkte aus DragPoints entfernt, dragPointRadius korrigiert
## placement-dragpoints
Uncommitted verbleibend:
162


Erledigt. Neuer Branch `placement-dragpoints`, Commit `129ffb9` mit den 2 Dateien (4+/7−):
- `environment.config.json` (dragPointRadius)
- `DefaultCommandProcessor.ts` (Start-Punkte raus)

Verbleibend uncommitted: die 162 Katalog-JSONs (Katalog-Update vom 7. Juli) — unangetastet im Working Tree, auf beiden Branches gleich. Nicht gepusht.


Der Kippfehler passiert z.B, wenn die Einzeltür zum Einbau nach vorne (auf den user zu) snapped
Ist das mit deiner analyse consistent?#


Ja — und zwar nicht nur konsistent, sondern erklärend. Die Richtungsabhängigkeit fällt direkt aus den Point-Rotationen heraus.

Der DragPoint der Tür ist `CS_EIN_L = [0,-90,0]` (bzw. `CS_EIN_R = [0,90,0]`). Die DropPoints, an die er andockt:

| Snap-Richtung | DropPoint | Rotation | vs. DragPoint |
|---|---|---|---|
| Flucht (`_0`) | `CS_OUT_R_0` | `[0,-90,0]` | **gleich** |
| nach vorn (`_90`) | `CS_OUT_R_90` | `[0,0,0]` | verschieden |
| nach hinten (`_270`) | `CS_OUT_R_270` | `[180,0,180]` | verschieden |

Der Mechanismus: fire liest **beide** Point-Rotationen falsch (Grad-Werte als Radiant). Die Snap-Orientierung ist im Kern die *Differenz* der beiden Frames.

- **Flucht (`_0`):** DragPoint und DropPoint haben **dieselbe** Rotation `[0,-90,0]`. Der rad/deg-Fehler ist in beiden identisch und kürzt sich in der Differenz weitgehend weg → die Tür steht gerade.
- **Nach vorn (`_90`):** DropPoint ist `[0,0,0]` (neutral, deg=rad=0), der DragPoint aber `[0,-90,0]`. Hier bleibt der Fehlbetrag der DragPoint-Rotation unkompensiert: −90° als −90 rad ≈ 243° statt 270° → die Tür kippt in einen **krummen Winkel** (kein 90°-Vielfaches).
- **Nach hinten (`_270`):** ebenfalls verschieden → kippt auch.

Genau das beschreibst du: „nach vorne (auf den User zu)" ist der `_90`-Fall, in Flucht sollte es dagegen stehen. Das passt exakt zur rad/deg-Fehlinterpretation.

Einschränkung: Die konkrete Kippachse und der exakte Winkel folgen erst aus der vollen World-Verkettung (die Parent-Node-Rotationen liest fire in Radiant korrekt) — das habe ich nicht durchgerechnet. Aber *dass* Flucht sauber ist und `_90`/`_270` schief kippen, ist die direkte Vorhersage der Hypothese.


dann jetzt branch und in cpq heilen


Der Fix in cpq muss die PMI-`rotation` für fire von Grad nach Radiant umrechnen — aber **ohne** cpqs interne `dockRotation` (die Grad erwartet) zu brechen. Also nur auf dem Weg zu fire. Ich prüfe den Szene-Fluss, um den richtigen, zentralen Punkt zu finden:


cd /home/marcuh/projects/adesso-cpq
echo "=== Fire3D.vue: alle setScene-Aufrufe ==="
grep -niE 'setScene|visual\.value|replay' packages/frontend/src/components/visual/Fire3D.vue | head
echo "=== visual-runtime setScene (visual-3d.ts) ==="
grep -niE 'setScene|updateScene|scene' packages/runtime/visual/src/visual-3d.ts | head
echo "=== replay() Rückgabe: baut GroupProcessor den Output-Graph? ==="
grep -niE 'replay|setScene|scene' packages/frontend/src/configurator/events/DefaultCommandProcessor.ts | head

Trace scene flow from cpq to fire


=== Fire3D.vue: alle setScene-Aufrufe ===
107:// resolved. This keeps a "stop_interaction_mode" before a following "setScene", and moves
114:                if (reaction.cmd === "setScene") {
115:                    await visual.value?.setScene(reaction.args[0]);
121:                    await visual.value?.execute(reaction.cmd, ...reaction.args);
156:            visual.value?.setScene(result.scene);
171:    visual.value = await createVisual3D(root.value!, props.config);
172:    visual.value.subscribe((event: any) => {
193:    visual.value?.dispose();
194:    visual.value = null;
198:    setScene: async (scene: unknown) => {
=== visual-runtime setScene (visual-3d.ts) ===
14: *   updateScene(scene)
18: *   visual3d.setScene(scene)
34:        updateScene(scene: unknown): Promise<void>;
75:    setScene(scene: unknown): Promise<void>;
89:    initialScene?: unknown;
299:        setScene: (scene) => vi.updateScene(scene),
=== replay() Rückgabe: baut GroupProcessor den Output-Graph? ===
21:    setInitialScene(scene: any): void {
22:        this.eventStore.setInitialState(scene);
25:    private replay(): any {
26:        const events = this.eventStore.getEventsToReplay();
27:        return this.groups.replay(
68:            const scene = this.replay();
71:                { type: "sendCommand", cmd: "setScene", args: [scene] },
85:        const scene = this.replay();
88:            { type: "sendCommand", cmd: "setScene", args: [scene] },
101:        this.replay();


cd /home/marcuh/projects/adesso-cpq
git checkout -b heal-pmi-rotation
echo "auf Branch: $(git branch --show-current)"

Create heal branch


Switched to a new branch 'heal-pmi-rotation'
auf Branch: heal-pmi-rotation


271	export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {
272	    const { createEnvironment, Connector: ConnectorClass } = await loadFireOnce();
273
274	    const vi = await createEnvironment({
275	        ...config,
276	        // WHY we override deserializers here
277	        // ------------------------------------
278	        // The raw config lists ConnectorConfig descriptor objects. The engine needs
279	        // actual Connector instances. buildDeserializers registers a factory that the
280	        // engine's DI system calls during boot to instantiate them.
281	        deserializers: buildDeserializers(
282	            ConnectorClass,
283	            config.deserializers as unknown as DeserializerEntry[] | undefined,
284	        ),
285	    });
286
287	    vi.attachToContainer(container);
288
289	    // run() returns unknown because the fire command dispatcher is generic:
290	    // some commands return Promises (async operations), others return Observables
291	    // (subscriptions), others synchronous values. Callers cast to the expected type.
292	    const run = (cmd: string, ...args: unknown[]): unknown => vi.commandRunner.run(cmd, ...args);
293
294	    return {
295	        subscribe: (handler) => {
296	            const emitter = run("observe_events") as { subscribe(h: (event: unknown) => void): () => void };
297	            return emitter.subscribe(handler);
298	        },
299	        setScene: (scene) => vi.updateScene(scene),
300	        execute: (cmd, ...args) => run(cmd, ...args),
301
302	        camera: {
303	            setMode(mode: CameraMode): void {
304	                run("set_camera_mode", ".main camera", mode);
305	                // first-person mode requires perspective projection (camera type 0)
306	                if (mode === "first-person") {
307	                    run("set_camera_type", ".main camera", 0);
308	                }
309	            },
310
311	            setType(type: number): void {
312	                run("set_camera_type", ".main camera", type);
313	            },
314
315	            async setPosition(x: number, y: number, z: number, up = [0, 1, 0]): Promise<void> {
316	                await run("set_transformation", ".main camera", { translation: [x, y, z] });
317	                await run("look_at", ".main camera", [0, 0, 0], up);
318	            },
319
320	            getTransformation(): Promise<CameraTransformation> {
321	                return run("get_transformation", ".main camera") as Promise<CameraTransformation>;
322	            },
323
324	            async fitToObject(selector: string): Promise<void> {
325	                const bounds = await (run("get_bounds", selector) as Promise<unknown>);
326	                await (run("zoom_to_bounds", ".main camera", bounds) as Promise<void>);
327	            },
328
329	            observeMode(): Observable<CameraMode> {
330	                // observe_* commands return an Observable, not a Promise — cast via unknown

export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {
    const { createEnvironment, Connector: ConnectorClass } = await loadFireOnce();

    const vi = await createEnvironment({
        ...config,
        // WHY we override deserializers here
        // ------------------------------------
        // The raw config lists ConnectorConfig descriptor objects. The engine needs
        // actual Connector instances. buildDeserializers registers a factory that the
        // engine's DI system calls during boot to instantiate them.
        deserializers: buildDeserializers(
            ConnectorClass,
            config.deserializers as unknown as DeserializerEntry[] | undefined,
        ),
    });

    vi.attachToContainer(container);

    // run() returns unknown because the fire command dispatcher is generic:
    // some commands return Promises (async operations), others return Observables
    // (subscriptions), others synchronous values. Callers cast to the expected type.
    const run = (cmd: string, ...args: unknown[]): unknown => vi.commandRunner.run(cmd, ...args);

    return {
        subscribe: (handler) => {
            const emitter = run("observe_events") as { subscribe(h: (event: unknown) => void): () => void };
            return emitter.subscribe(handler);
        },
        setScene: (scene) => vi.updateScene(scene),
        execute: (cmd, ...args) => run(cmd, ...args),

        camera: {
            setMode(mode: CameraMode): void {
                run("set_camera_mode", ".main camera", mode);
                // first-person mode requires perspective projection (camera type 0)
                if (mode === "first-person") {
                    run("set_camera_type", ".main camera", 0);
                }
            },

            setType(type: number): void {
                run("set_camera_type", ".main camera", type);
            },

            async setPosition(x: number, y: number, z: number, up = [0, 1, 0]): Promise<void> {
                await run("set_transformation", ".main camera", { translation: [x, y, z] });
                await run("look_at", ".main camera", [0, 0, 0], up);
            },

            getTransformation(): Promise<CameraTransformation> {
                return run("get_transformation", ".main camera") as Promise<CameraTransformation>;
            },

            async fitToObject(selector: string): Promise<void> {
                const bounds = await (run("get_bounds", selector) as Promise<unknown>);
                await (run("zoom_to_bounds", ".main camera", bounds) as Promise<void>);
            },

            observeMode(): Observable<CameraMode> {
                // observe_* commands return an Observable, not a Promise — cast via unknown


Zentraler Punkt gefunden: `visual-3d.ts:299` — alle Szenen von cpq laufen durch `setScene → vi.updateScene`. Hier setze ich einen Adapter, der die PMI-Rotationen für fire von Grad nach Radiant umrechnet (deep-clone, cpq-intern bleibt Grad):


Edit

export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {
    const { createEnvironment, Connector: ConnectorClass } = await loadFireOnce();

    const vi = await createEnvironment({
        ...config,

// The catalog scene graphs encode component_pmi rotations in DEGREES (matching cpq's own
// eulerZyxDegToQuat used for docking), but fire deserializes a quaternion-typed 3-value
// property as EULER RADIANS. Convert PMI rotations to radians on the way into fire only —
// cpq's internal dockRotation keeps reading the untouched degree values. Node transforms
// (component_transform) are already in radians and are left untouched.
const DEG_TO_RAD = Math.PI / 180;
function pmiRotationsToRadians(scene: unknown): unknown {
    if (scene == null) return scene;
    const clone = structuredClone(scene);
    const visit = (node: unknown): void => {
        if (Array.isArray(node)) {
            node.forEach(visit);
            return;
        }
        if (node && typeof node === "object") {
            const n = node as Record<string, any>;
            if (n._resourceClass === "component_pmi") {
                const rot = n.properties?.rotation?.value;
                if (Array.isArray(rot) && rot.length === 3) {
                    n.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);
                }
            }
            Object.values(n).forEach(visit);
        }
    };
    visit(clone);
    return clone;
}

export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {
    const { createEnvironment, Connector: ConnectorClass } = await loadFireOnce();

    const vi = await createEnvironment({
        ...config,
        initialScene: pmiRotationsToRadians(config.initialScene),


The file /home/marcuh/projects/adesso-cpq/packages/runtime/visual/src/visual-3d.ts has been updated successfully. (file state is current in your context — no need to Read it back)

// The catalog scene graphs encode component_pmi rotations in DEGREES (matching cpq's own
// eulerZyxDegToQuat used for docking), but fire deserializes a quaternion-typed 3-value
// property as EULER RADIANS. Convert PMI rotations to radians on the way into fire only —
// cpq's internal dockRotation keeps reading the untouched degree values. Node transforms
// (component_transform) are already in radians and are left untouched.
const DEG_TO_RAD = Math.PI / 180;
function pmiRotationsToRadians(scene: unknown): unknown {
    if (scene == null) return scene;
    const clone = structuredClone(scene);
    const visit = (node: unknown): void => {
        if (Array.isArray(node)) {
            node.forEach(visit);
            return;
        }
        if (node && typeof node === "object") {
            const n = node as Record<string, any>;
            if (n._resourceClass === "component_pmi") {
                const rot = n.properties?.rotation?.value;
                if (Array.isArray(rot) && rot.length === 3) {
                    n.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);
                }
            }
            Object.values(n).forEach(visit);
        }
    };
    visit(clone);
    return clone;
}

export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {
    const { createEnvironment, Connector: ConnectorClass } = await loadFireOnce();

    const vi = await createEnvironment({
        ...config,
        initialScene: pmiRotationsToRadians(config.initialScene),

// ─── Public factory ──────────────────────────────────────────────────────────

+// The catalog scene graphs encode component_pmi rotations in DEGREES (matching cpq's own

+// eulerZyxDegToQuat used for docking), but fire deserializes a quaternion-typed 3-value

+// property as EULER RADIANS. Convert PMI rotations to radians on the way into fire only —

+// cpq's internal dockRotation keeps reading the untouched degree values. Node transforms

+// (component_transform) are already in radians and are left untouched.

+function pmiRotationsToRadians(scene: unknown): unknown {

+    const clone = structuredClone(scene);

+    const visit = (node: unknown): void => {

+        if (node && typeof node === "object") {

+            const n = node as Record<string, any>;

+            if (n._resourceClass === "component_pmi") {

+                const rot = n.properties?.rotation?.value;

+                if (Array.isArray(rot) && rot.length === 3) {

+                    n.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);

+            Object.values(n).forEach(visit);

+        initialScene: pmiRotationsToRadians(config.initialScene),

// The raw config lists ConnectorConfig descriptor objects. The engine needs


setScene: (scene) => vi.updateScene(scene),

setScene: (scene) => vi.updateScene(pmiRotationsToRadians(scene)),


const emitter = run("observe_events") as { subscribe(h: (event: unknown) => void): () => void };

-        setScene: (scene) => vi.updateScene(scene),

+        setScene: (scene) => vi.updateScene(pmiRotationsToRadians(scene)),

execute: (cmd, ...args) => run(cmd, ...args),


Jetzt bauen, um zu verifizieren dass es kompiliert:


Bash

cd /home/marcuh/projects/adesso-cpq
pnpm --filter @ad-cpq/visual-runtime build 2>&1 | tail -20; echo "EXIT=${PIPESTATUS[0]}"

Build visual-runtime to verify compilation


$ tsup
CLI Building entry: src/index.ts
CLI Using tsconfig: tsconfig.json
CLI tsup v8.5.1
CLI Using tsup config: /home/marcuh/projects/adesso-cpq/packages/runtime/visual/tsup.config.ts
CLI Target: es2022
CLI Cleaning output folder
ESM Build start
DTS Build start
DTS ⚡ Build success in 320ms
DTS dist/index.d.ts 2.55 KB
ESM dist/index.js                6.32 KB
ESM dist/index.js.map            22.45 KB
ESM dist/browser-XYJYSCYI.js     2.23 MB
ESM dist/browser-XYJYSCYI.js.map 8.38 MB
ESM ⚡ Build success in 1612ms
EXIT=


Build erfolgreich. Fix committen (nur `visual-3d.ts`, nicht die Katalog-JSONs):


cd /home/marcuh/projects/adesso-cpq
git add packages/runtime/visual/src/visual-3d.ts
git commit -m "visual-runtime: PMI-Rotationen für fire von Grad nach Radiant umrechnen

fire deserialisiert eine quaternion-typisierte 3-Werte-Property als Euler in
Radiant, die Katalog-Szenegraphen führen component_pmi-Rotationen aber in Grad
(wie cpqs eigenes eulerZyxDegToQuat beim Docking). Dadurch drehte fires
Live-Snap die Teile in einen krummen Winkel. Fix: PMI-Rotationen nur auf dem
Weg zu fire (setScene/initialScene) nach Radiant umrechnen; cpqs interne
dockRotation liest weiterhin die unveränderten Grad-Werte. Node-Transforms
sind bereits Radiant und bleiben unberührt."
echo "=== Status ==="
git log --oneline -3
git status -sb | head -1

Commit the rad/deg fix


[heal-pmi-rotation d42b835] visual-runtime: PMI-Rotationen für fire von Grad nach Radiant umrechnen
 1 file changed, 31 insertions(+), 1 deletion(-)
=== Status ===
d42b835 visual-runtime: PMI-Rotationen für fire von Grad nach Radiant umrechnen
129ffb9 Placement: Start-Punkte aus DragPoints entfernt, dragPointRadius korrigiert
debdb4e Neue Ordnerstruktur: Assets/Szenegraphen umstrukturiert
## heal-pmi-rotation


Erledigt.

**Branch:** `heal-pmi-rotation` — Commit `d42b835`.

**Fix** in `packages/runtime/visual/src/visual-3d.ts`:
- Neue `pmiRotationsToRadians(scene)` — deep-clone, rechnet jede `component_pmi`-`rotation` (3-Werte-Euler) von Grad nach Radiant.
- Angewandt an der fire-Grenze: `setScene` und `initialScene`. Alle Szenen von cpq laufen zentral durch diesen Punkt.
- cpq-intern (`dockRotation`/`eulerZyxDegToQuat`) liest weiter die unveränderten Grad-Werte → Verbau-Graph bleibt korrekt.
- `component_transform`-Rotationen (schon Radiant) bleiben unangetastet.

Build von `@ad-cpq/visual-runtime` läuft durch. Nicht gepusht.

**Steht aus:** die visuelle Bestätigung. Dass der Code kompiliert und die Logik dem rad/deg-Befund entspricht, ist gesichert — ob der Live-Snap jetzt aufrecht steht, musst du im Storybook mit einem Placement nach vorn (`_90`) prüfen. Das kann ich nicht sehen. Nach `pnpm --filter @ad-cpq/common-frontend storybook` und einem Snap nach vorne sollte die Tür gerade einrasten statt schief.


Failed to execute 'structuredClone' on 'Window': #<Object> could not be cloned.
    at pmiRotationsToRadians (http://localhost:6006/@fs/home/marcuh/projects/adesso-cpq/packages/runtime/visual/src/visual-3d.ts?t=1783513856271:117:17)
    at createVisual3D (http://localhost:6006/@fs/home/marcuh/projects/adesso-cpq/packages/runtime/visual/src/visual-3d.ts?t=1783513856271:141:19)
    at async http://localhost:6006/src/components/visual/Fire3D.vue?t=1783513856271:113:22


`structuredClone` scheitert an nicht-klonbaren Teilen der Szene (Klasseninstanzen/Funktionen, hier in `initialScene`). Ich ersetze es durch einen strukturellen Klon, der nur Plain-Objekte/Arrays kopiert und alles andere per Referenz durchreicht — kein defensiver Fallback nötig:


Edit

const DEG_TO_RAD = Math.PI / 180;
function pmiRotationsToRadians(scene: unknown): unknown {
    if (scene == null) return scene;
    const clone = structuredClone(scene);
    const visit = (node: unknown): void => {
        if (Array.isArray(node)) {
            node.forEach(visit);
            return;
        }
        if (node && typeof node === "object") {
            const n = node as Record<string, any>;
            if (n._resourceClass === "component_pmi") {
                const rot = n.properties?.rotation?.value;
                if (Array.isArray(rot) && rot.length === 3) {
                    n.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);
                }
            }
            Object.values(n).forEach(visit);
        }
    };
    visit(clone);
    return clone;
}

const DEG_TO_RAD = Math.PI / 180;
function pmiRotationsToRadians(scene: unknown): unknown {
    const convert = (node: unknown): unknown => {
        if (Array.isArray(node)) {
            return node.map(convert);
        }
        if (node && typeof node === "object") {
            // Only plain scene-graph objects are cloned; engine class instances and other
            // non-plain values are passed through by reference (structuredClone chokes on them).
            const proto = Object.getPrototypeOf(node);
            if (proto !== Object.prototype && proto !== null) {
                return node;
            }
            const out: Record<string, any> = {};
            for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
                out[key] = convert(value);
            }
            if (out._resourceClass === "component_pmi") {
                const rot = out.properties?.rotation?.value;
                if (Array.isArray(rot) && rot.length === 3) {
                    out.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);
                }
            }
            return out;
        }
        return node;
    };
    return convert(scene);
}


const DEG_TO_RAD = Math.PI / 180;
function pmiRotationsToRadians(scene: unknown): unknown {
    if (scene == null) return scene;
    const clone = structuredClone(scene);
    const visit = (node: unknown): void => {
        if (Array.isArray(node)) {
            node.forEach(visit);
            return;
        }
        if (node && typeof node === "object") {
            const n = node as Record<string, any>;
            if (n._resourceClass === "component_pmi") {
                const rot = n.properties?.rotation?.value;
                if (Array.isArray(rot) && rot.length === 3) {
                    n.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);
                }
            }
            Object.values(n).forEach(visit);
        }
    };
    visit(clone);
    return clone;
}

const DEG_TO_RAD = Math.PI / 180;
function pmiRotationsToRadians(scene: unknown): unknown {
    const convert = (node: unknown): unknown => {
        if (Array.isArray(node)) {
            return node.map(convert);
        }
        if (node && typeof node === "object") {
            // Only plain scene-graph objects are cloned; engine class instances and other
            // non-plain values are passed through by reference (structuredClone chokes on them).
            const proto = Object.getPrototypeOf(node);
            if (proto !== Object.prototype && proto !== null) {
                return node;
            }
            const out: Record<string, any> = {};
            for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
                out[key] = convert(value);
            }
            if (out._resourceClass === "component_pmi") {
                const rot = out.properties?.rotation?.value;
                if (Array.isArray(rot) && rot.length === 3) {
                    out.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);
                }
            }
            return out;
        }
        return node;
    };
    return convert(scene);
}

// (component_transform) are already in radians and are left untouched.

-    const clone = structuredClone(scene);

-    const visit = (node: unknown): void => {

+    const convert = (node: unknown): unknown => {

-            const n = node as Record<string, any>;

-            if (n._resourceClass === "component_pmi") {

-                const rot = n.properties?.rotation?.value;

+            // Only plain scene-graph objects are cloned; engine class instances and other

+            // non-plain values are passed through by reference (structuredClone chokes on them).

+            const proto = Object.getPrototypeOf(node);

+            if (proto !== Object.prototype && proto !== null) {

+            const out: Record<string, any> = {};

+            for (const [key, value] of Object.entries(node as Record<string, unknown>)) {

+                out[key] = convert(value);

+            if (out._resourceClass === "component_pmi") {

+                const rot = out.properties?.rotation?.value;

-                    n.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);

+                    out.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);

-            Object.values(n).forEach(visit);

export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {


Failed to execute 'structuredClone' on 'Window': #<Object> could not be cloned.     at pmiRotationsToRadians (http://localhost:6006/@fs/home/marcuh/projects/adesso-cpq/packages/runtime/visual/sr…


Bash

cd /home/marcuh/projects/adesso-cpq
pnpm --filter @ad-cpq/visual-runtime build 2>&1 | grep -iE 'success|error|fail' | head
echo "=== amend Fix-Commit ==="
git add packages/runtime/visual/src/visual-3d.ts
git commit --amend --no-edit
git log --oneline -1

Rebuild and amend the fix commit


DTS ⚡ Build success in 322ms
ESM ⚡ Build success in 1675ms
=== amend Fix-Commit ===
[heal-pmi-rotation 2fa4b11] visual-runtime: PMI-Rotationen für fire von Grad nach Radiant umrechnen
 Date: Wed Jul 8 14:31:52 2026 +0200
 1 file changed, 37 insertions(+), 1 deletion(-)
2fa4b11 visual-runtime: PMI-Rotationen für fire von Grad nach Radiant umrechnen


Behoben. Der Crash kam von `structuredClone`, das an Nicht-Plain-Objekten in `initialScene` scheitert. Ersetzt durch einen strukturellen Klon, der nur Plain-Szenegraph-Objekte/Arrays kopiert und Klasseninstanzen per Referenz durchreicht.

- Build ok, Commit amended (`2fa4b11`).
- Storybook lädt die Source über Vite (`@fs/…/visual-3d.ts`), nicht das dist-Bundle — die Änderung greift per HMR beim nächsten Reload automatisch.

Jetzt im Storybook ein Placement nach vorn (`_90`) testen: die Tür sollte gerade einrasten statt schief.


Mangement Summary der Problems?


## Management Summary — Kippfehler beim Platzieren

**Symptom**
Im 3D-Konfigurator kippten Bauteile (z. B. Schutzzaun-Türen) beim Live-Ziehen im Moment des Einrastens in einen schiefen Winkel und lagen „in der Luft", bis die Szene neu aufgebaut wurde. Das **final gespeicherte Ergebnis war korrekt** — der Fehler war nur während der Interaktion sichtbar.

**Ursache**
Einheiten-Inkonsistenz in den Szenegraph-Daten: Andockpunkte (PMI) führen ihre Rotationen in **Grad**, die Bauteil-Transforms dagegen in **Radiant** — beides im selben Datensatz. cpq rechnet die PMI-Rotationen korrekt als Grad; die 3D-Engine (fire) interpretiert dieselben Werte als Radiant. Faktor ≈ 57 daneben → schiefe Live-Ausrichtung. Richtungsabhängig: in Flucht unauffällig, beim Andocken nach vorn/hinten sichtbar.

**Auswirkung**
Rein visuell/UX während des Platzierens. Keine fehlerhaften Daten, kein falscher Verbau im Endprodukt.

**Lösung**
cpq-seitiger Adapter an der Schnittstelle zur Engine: PMI-Rotationen werden **nur auf dem Weg zur 3D-Engine** von Grad nach Radiant umgerechnet. Die interne Verbau-Berechnung bleibt unverändert. Keine Änderung an der Engine, keine Änderung an den Katalogdaten.

**Status**
Implementiert (Branch `heal-pmi-rotation`), Build grün. Visuelle Abnahme im Storybook steht aus.

**Offener Punkt / technische Schuld**
Der Fix kompensiert die Inkonsistenz, behebt aber nicht deren Quelle: Die Datenerzeugung (Speedmaxx-Export) liefert PMI-Rotationen in Grad statt Radiant. Mittelfristig sollte das im Export vereinheitlicht werden, damit cpq und Engine dieselben Rohdaten ohne Umrechnung nutzen.