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


Kannst du Visotor so anpassen, dass er alle PMIs wegräumt, die nicht auf der Root liegen?


„Visitor" = die `pmiRotationsToRadians`-Funktion in `visual-3d.ts`. Bevor ich sie erweitere, muss ich „auf der Root liegen" strukturell sauber definieren. Ich sehe mir den Szenegraph-Aufbau an:


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
python3 << 'EOF'
import json
d=json.load(open('packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json'))
print("Top-Level keys:", list(d.keys()), " _resourceClass:", d.get('_resourceClass'))
handles=d.get('handles',[])
print("Anzahl top-level handles:", len(handles))
# Struktur eines handle
def child_keys(h):
    ks=[k for k in h.keys()]
    return ks
for i,h in enumerate(handles[:3]):
    comps=h.get('components',[]) or []
    crc=[c.get('_resourceClass') for c in comps]
    pmis=[c.get('id') for c in comps if c.get('_resourceClass')=='component_pmi']
    print(f"\nhandle[{i}] keys={list(h.keys())}")
    print(f"  tags={h.get('tags')}")
    print(f"  component-Klassen={crc}")
    print(f"  PMIs direkt an handle[{i}]: {pmis[:8]}{'...' if len(pmis)>8 else ''} (n={len(pmis)})")
    # gibt es nested children?
    for nk in ('children','handles','entities'):
        if nk in h: print(f"  nested '{nk}': {len(h[nk])}")
# Zähle PMIs pro Tiefe
def count_depth(node, depth, acc):
    if isinstance(node,dict):
        for c in node.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi':
                acc[depth]=acc.get(depth,0)+1
        for k in ('handles','children','entities'):
            for ch in node.get(k,[]) or []: count_depth(ch, depth+1, acc)
acc={}
for h in handles: count_depth(h,0,acc)
print("\nPMIs pro Tiefe (0 = top-level handle):", dict(sorted(acc.items())))
EOF

Inspect scene graph handle structure and PMI depth


Top-Level keys: ['_resourceClass', 'handles']  _resourceClass: sceneGraph_entity
Anzahl top-level handles: 185

handle[0] keys=['id', 'properties', 'components']
  tags=None
  component-Klassen=['component_transform', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi', 'component_pmi']
  PMIs direkt an handle[0]: ['CS_EIN', 'CS_BASE_PRE', 'CS_BASE', 'CS_EIN_L', 'CS_EIN_R', 'CS_EIN_START_L', 'CS_EIN_START_R', 'CS_OUT_R_0']... (n=23)

handle[1] keys=['id', 'properties', 'tags', 'components']
  tags=['name-assembly-root']
  component-Klassen=['component_transform']
  PMIs direkt an handle[1]: [] (n=0)

handle[2] keys=['id', 'properties', 'tags', 'components']
  tags=['name-EINZELTUER']
  component-Klassen=['component_transform', 'component_pmi']
  PMIs direkt an handle[2]: ['CS_BASIS'] (n=1)

PMIs pro Tiefe (0 = top-level handle): {0: 249}


Der Graph ist **flach** — 185 top-level Handles, alle PMIs auf Ebene 0. Die Hierarchie steckt in `component_transform.parentKey/key` (logischer Baum), nicht in Verschachtelung. „Root" muss also über `parentKey` definiert werden. Ich schaue mir den Baum an:


cd /Users/marcus.hinz/projects/adesso-cpq
python3 << 'EOF'
import json
d=json.load(open('packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json'))
handles=d.get('handles',[])
def tf(h):
    for c in h.get('components',[]) or []:
        if c.get('_resourceClass')=='component_transform': return c
    return {}
# Root-Handles = ohne parentKey
roots=[]; children=[]
for h in handles:
    t=tf(h)
    pmis=[c.get('id') for c in h.get('components',[]) or [] if c.get('_resourceClass')=='component_pmi']
    entry=(h.get('id','')[:12], (h.get('tags') or ['-'])[0], t.get('key'), t.get('parentKey'), len(pmis))
    if not t.get('parentKey'): roots.append(entry)
    else: children.append(entry)
print(f"ROOT-Handles (kein parentKey): {len(roots)}")
for e in roots: print("  id=%s tag=%-22s key=%s parent=%s PMIs=%d"%e)
print(f"\nKind-Handles (mit parentKey): {len(children)}")
tot_child_pmi=sum(e[4] for e in children)
tot_root_pmi=sum(e[4] for e in roots)
print(f"  PMIs in Root-Handles: {tot_root_pmi}")
print(f"  PMIs in Kind-Handles: {tot_child_pmi}")
# Verteilung der Kind-PMIs nach parentKey-Tiefe grob
from collections import Counter
c=Counter(e[1] for e in children if e[4]>0)
print("\nKind-Handles mit PMIs, nach tag (Top 10):")
for tag,n in c.most_common(10): print(f"  {tag}: {n} Handles")
EOF

Analyze root vs child handles by parentKey


ROOT-Handles (kein parentKey): 1
  id=X-89355c41-4 tag=-                      key=root parent=None PMIs=23

Kind-Handles (mit parentKey): 184
  PMIs in Root-Handles: 23
  PMIs in Kind-Handles: 226

Kind-Handles mit PMIs, nach tag (Top 10):
  name-generic: 91 Handles
  name-EINZELTUER: 1 Handles


Struktur ist eindeutig:
- **1 Root-Handle** (`key="root"`, kein parentKey) → **23 PMIs**: die Andockpunkte (`CS_EIN`, `CS_BASE`, `CS_EIN_L/R`, `CS_EIN_START_L/R`, `CS_OUT_*`, `CS_SELECT_*`).
- **184 Kind-Handles** (mit parentKey) → **226 PMIs**: die Sub-Komponenten-PMIs (inkl. der früheren Ausreißer).

„Auf der Root" = Handle ohne `parentKey`. Ich erweitere den Visitor: Kind-Handles (mit parentKey) verlieren ihre `component_pmi` — nur für den fire-Graph, cpq-intern bleibt vollständig. Und benenne die Funktion passend um:


Edit

// 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 {
    const convert = (node: unknown): unknown => {
        if (Array.isArray(node)) {
            return node.map(convert);
        }
        if (node && typeof node === "object") {
            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);
}

// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:
//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
//      quaternion-typed 3-value property as euler radians, but the catalog scene graphs
//      encode PMI rotations in degrees (matching cpq's own eulerZyxDegToQuat used for
//      docking). Node transforms (component_transform) are already radians and untouched.
//   2. PMIs that do not sit on the root handle are dropped. Only the root's docking points
//      (CS_EIN_*, CS_OUT_*, …) matter for placement; the per-part PMIs on sub-component
//      handles only clutter the scene. A handle is a child when its transform has a parentKey.
const DEG_TO_RAD = Math.PI / 180;
function adaptSceneForFire(scene: unknown): unknown {
    const convert = (node: unknown): unknown => {
        if (Array.isArray(node)) {
            return node.map(convert);
        }
        if (node && typeof node === "object") {
            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);
                }
            }
            if (Array.isArray(out.components)) {
                const transform = out.components.find(
                    (c: any) => c?._resourceClass === "component_transform",
                );
                if (transform?.parentKey) {
                    out.components = out.components.filter(
                        (c: any) => c?._resourceClass !== "component_pmi",
                    );
                }
            }
            return out;
        }
        return node;
    };
    return convert(scene);
}


<tool_use_error>String to replace not found in file.
String: // 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 {
    const convert = (node: unknown): unknown => {
        if (Array.isArray(node)) {
            return node.map(convert);
        }
        if (node && typeof node === "object") {
            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);
}
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)</tool_use_error>

Error: String to replace not found in file.
String: // 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 {
    const convert = (node: unknown): unknown => {
        if (Array.isArray(node)) {
            return node.map(convert);
        }
        if (node && typeof node === "object") {
            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);
}
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)


Ich lese den aktuellen Stand der Funktion:


271	// The catalog scene graphs encode component_pmi rotations in DEGREES (matching cpq's own
272	// eulerZyxDegToQuat used for docking), but fire deserializes a quaternion-typed 3-value
273	// property as EULER RADIANS. Convert PMI rotations to radians on the way into fire only —
274	// cpq's internal dockRotation keeps reading the untouched degree values. Node transforms
275	// (component_transform) are already in radians and are left untouched.
276	const DEG_TO_RAD = Math.PI / 180;
277	function pmiRotationsToRadians(scene: unknown): unknown {
278	    const convert = (node: unknown): unknown => {
279	        if (Array.isArray(node)) {
280	            return node.map(convert);
281	        }
282	        if (node && typeof node === "object") {
283	            // Only plain scene-graph objects are cloned; engine class instances and other
284	            // non-plain values are passed through by reference (structuredClone chokes on them).
285	            const proto = Object.getPrototypeOf(node);
286	            if (proto !== Object.prototype && proto !== null) {
287	                return node;
288	            }
289	            const out: Record<string, any> = {};
290	            for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
291	                out[key] = convert(value);
292	            }
293	            if (out._resourceClass === "component_pmi") {
294	                const rot = out.properties?.rotation?.value;
295	                if (Array.isArray(rot) && rot.length === 3) {
296	                    out.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);
297	                }
298	            }
299	            return out;
300	        }
301	        return node;
302	    };
303	    return convert(scene);
304	}
305
306	export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {
307	    const { createEnvironment, Connector: ConnectorClass } = await loadFireOnce();
308
309	    const vi = await createEnvironment({
310	        ...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 {
    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);
}

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 {

// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:
//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
//      quaternion-typed 3-value property as euler radians, but the catalog encodes PMI
//      rotations in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node
//      transforms (component_transform) are already radians and are left untouched.
//   2. PMIs not sitting on the root handle are dropped — only the root's docking points
//      (CS_EIN_*, CS_OUT_*, …) matter for placement; per-part PMIs on sub-component handles
//      only clutter the fire scene. A handle is a child when its transform has a parentKey.
const DEG_TO_RAD = Math.PI / 180;
function adaptSceneForFire(scene: unknown): unknown {


The file /Users/marcus.hinz/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 {

// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:
//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
//      quaternion-typed 3-value property as euler radians, but the catalog encodes PMI
//      rotations in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node
//      transforms (component_transform) are already radians and are left untouched.
//   2. PMIs not sitting on the root handle are dropped — only the root's docking points
//      (CS_EIN_*, CS_OUT_*, …) matter for placement; per-part PMIs on sub-component handles
//      only clutter the fire scene. A handle is a child when its transform has a parentKey.
const DEG_TO_RAD = Math.PI / 180;
function adaptSceneForFire(scene: unknown): unknown {

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

+// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:

+//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a

+//      quaternion-typed 3-value property as euler radians, but the catalog encodes PMI

+//      rotations in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node

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

+//   2. PMIs not sitting on the root handle are dropped — only the root's docking points

+//      (CS_EIN_*, CS_OUT_*, …) matter for placement; per-part PMIs on sub-component handles

+//      only clutter the fire scene. A handle is a child when its transform has a parentKey.

-function pmiRotationsToRadians(scene: unknown): unknown {

+function adaptSceneForFire(scene: unknown): unknown {

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


            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;

            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);
                }
            }
            if (Array.isArray(out.components)) {
                const transform = out.components.find(
                    (c: any) => c?._resourceClass === "component_transform",
                );
                if (transform?.parentKey) {
                    out.components = out.components.filter(
                        (c: any) => c?._resourceClass !== "component_pmi",
                    );
                }
            }
            return out;


            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;

            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);
                }
            }
            if (Array.isArray(out.components)) {
                const transform = out.components.find(
                    (c: any) => c?._resourceClass === "component_transform",
                );
                if (transform?.parentKey) {
                    out.components = out.components.filter(
                        (c: any) => c?._resourceClass !== "component_pmi",
                    );
                }
            }
            return out;

+            if (Array.isArray(out.components)) {

+                const transform = out.components.find(

+                    (c: any) => c?._resourceClass === "component_transform",

+                if (transform?.parentKey) {

+                    out.components = out.components.filter(

+                        (c: any) => c?._resourceClass !== "component_pmi",


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
# beide Aufrufstellen umbenennen
sed -i '' 's/pmiRotationsToRadians(/adaptSceneForFire(/g' packages/runtime/visual/src/visual-3d.ts
echo "=== Aufrufe jetzt ==="
grep -n 'adaptSceneForFire\|pmiRotationsToRadians' packages/runtime/visual/src/visual-3d.ts

Rename call sites via sed


=== Aufrufe jetzt ===
280:function adaptSceneForFire(scene: unknown): unknown {
324:        initialScene: adaptSceneForFire(config.initialScene),
348:        setScene: (scene) => vi.updateScene(adaptSceneForFire(scene)),


1	/**
2	 * WHY THIS FILE EXISTS
3	 * --------------------
4	 * The original visual-3d.js was plain JavaScript. Its TypeScript types lived in a
5	 * hand-written index.d.ts next to it — two files that could silently drift apart
6	 * whenever the implementation changed. This TypeScript rewrite lets tsup generate
7	 * index.d.ts automatically from the source, so types are always in sync.
8	 *
9	 * WHY WE WRAP @ad/fire AT ALL
10	 * ----------------------------
11	 * @ad/fire (adesso-fire) exposes a low-level engine API:
12	 *   commandRunner.run('set_camera_mode', ...)
13	 *   eventEmitter.subscribe(handler)
14	 *   updateScene(scene)
15	 *
16	 * This wrapper reduces that to a stable, CPQ-friendly surface:
17	 *   visual3d.camera.setMode('orbit')
18	 *   visual3d.setScene(scene)
19	 *
20	 * Consumers of @ad-cpq/visual-runtime never need to know about commandRunner
21	 * commands, the engine's event system, or how connectors are wired up.
22	 */
23
24	// WHY we don't import types directly from @ad/fire
25	// -------------------------------------------------
26	// @ad/fire's type declarations (types/index.d.ts) re-export from @fire-visual/engine
27	// and @fire-visual/utils — internal packages that are NOT separately installed.
28	// TypeScript cannot resolve them, so named imports from @ad/fire would break compilation.
29	// Instead, we describe only the parts of the fire API we actually call as a local
30	// interface. The dynamic import is then cast to this interface at runtime.
31	interface FireModule {
32	    createEnvironment(config: unknown): Promise<{
33	        attachToContainer(container: HTMLElement): void;
34	        updateScene(scene: unknown): Promise<void>;
35	        // run() returns unknown — the command dispatcher is generic and some commands
36	        // return Promises, others return Observables, others synchronous values
37	        commandRunner: { run(cmd: string, ...args: unknown[]): unknown };
38	        dispose?: () => void;
39	    }>;
40	    // Connector is a class — instantiated by buildConnector below.
41	    // Its constructor args depend on the fire version; we type them loosely here
42	    // since we always build Connector instances via our own buildConnector helper.
43	    Connector: new (...args: unknown[]) => unknown;
44	}
45
46	// ─── Public API types ────────────────────────────────────────────────────────
47	// These are exported and form the contract for all consumers of @ad-cpq/visual-runtime.
48	// Kept explicit (not inferred from fire internals) so they remain stable
49	// even if the underlying fire implementation changes.
50
51	export type CameraMode = "orbit" | "first-person";
52
53	export interface CameraTransformation {
54	    translation: [number, number, number];
55	    rotation: [number, number, number, number];
56	    scale: [number, number, number];
57	}
58
59	export interface Observable<T> {
60	    subscribe(handler: (value: T) => void): { unsubscribe(): void };
61	}
62
63	export interface Visual3DCamera {
64	    setMode(mode: CameraMode): void;
65	    setType(type: number): void;
66	    setPosition(x: number, y: number, z: number, up?: number[]): Promise<void>;
67	    getTransformation(): Promise<CameraTransformation>;
68	    fitToObject(selector: string): Promise<void>;
69	    observeMode(): Observable<CameraMode>;
70	    observeType(): Observable<number>;
71	}
72
73	export interface Visual3D {
74	    subscribe(handler: (event: unknown) => void): () => void;
75	    setScene(scene: unknown): Promise<void>;
76	    /** Run an arbitrary fire command. Return type depends on the command — cast at call site. */
77	    execute(cmd: string, ...args: unknown[]): unknown;
78	    camera: Visual3DCamera;
79	    dispose(): void;
80	}
81
82	// Visual3DConfig intentionally mirrors EnvironmentConfig from @ad/fire but makes
83	// every field optional and adds an index signature. Consumers typically pass an
84	// environment.config.json verbatim — enforcing all required fields of EnvironmentConfig
85	// here would force TypeScript casting at every call site without adding real safety.
86	export interface Visual3DConfig {
87	    moveMode?: "orbit" | "first-person";
88	    logLevel?: number;
89	    initialScene?: unknown;
90	    settings?: unknown;
91	    // The raw config uses plain connector descriptor objects (ConnectorConfig).
92	    // buildDeserializers turns them into actual Connector instances for the engine.
93	    deserializers?: ConnectorConfig[];
94	    [key: string]: unknown;
95	}
96
97	// ConnectorConfig matches the "connectors" entries in environment.config.json.
98	// The path field is a template string that may contain <%=path%>, <%=typeKey%>, etc.
99	export interface ConnectorConfig {
100	    method: "get" | "put" | "queue" | "file";
101	    responseType: "json" | "blob" | "arraybuffer";
102	    path: string;
103	    filter?: {
104	        path?: string; // regex matched against the asset path
105	        typeKey?: string; // regex matched against the asset type key
106	    };
107	    isDefault?: boolean;
108	}
109
110	// DeserializerEntry is what the fire engine expects in its deserializers list.
111	// @fire-visual/di is an internal package and not available, so we type it loosely.
112	// The engine only reads .type (to match against incoming objects) and .callback
113	// (to construct the actual instance).
114	interface DeserializerEntry {
115	    type: unknown;
116	    callback: (input: unknown) => unknown;
117	}
118
119	// ─── Connector helpers ───────────────────────────────────────────────────────
120
121	// WHY templateReplace exists
122	// --------------------------
123	// Connector path templates come from environment.config.json and contain
124	// <%=path%>, <%=typeKey%>, <%=data.someField%> placeholders. The fire engine
125	// passes raw path/data/typeKey values to the connector; we resolve the template
126	// before handing the final URL to fetch().
127	// NOTE: This is a workaround. Ideally the supplier delivers a Connector interface
128	// that handles URL construction internally.
129	function templateReplace(template: string, values: Record<string, unknown>): string {
130	    return template.replaceAll(/<%=(\w+(?:\.\w+)*)%>/g, (match, dotPath: string) => {
131	        const keys = dotPath.split(".");
132	        let value: unknown = values;
133	        for (const key of keys) {
134	            if (value == null) return match;
135	            value = (value as Record<string, unknown>)[key];
136	        }
137	        return value !== undefined ? String(value) : match;
138	    });
139	}
140
141	type ConnectorFactory = (ConnectorClass: new (...args: unknown[]) => unknown, config: ConnectorConfig) => unknown;
142
143	// Only 'get' and 'put' are implemented. 'queue' and 'file' throw deliberately
144	// so missing implementations surface at startup rather than silently doing nothing.
145	const connectorStrategies: Record<ConnectorConfig["method"], ConnectorFactory> = {
146	    get: buildConnector,
147	    put: buildConnector,
148	    queue: () => {
149	        throw new Error("Queue connector not yet implemented");
150	    },
151	    file: () => {
152	        throw new Error("File connector not yet implemented");
153	    },
154	};
155
156	function buildConnector(ConnectorClass: new (...args: unknown[]) => unknown, config: ConnectorConfig): unknown {
157	    return new ConnectorClass(
158	        config.method,
159	        // Predicate: returns false to skip assets that don't match the configured regexes
160	        (path: string, typeKey?: string) => {
161	            if (config.filter?.path && !path.match(config.filter.path)) return false;
162	            return !(config.filter?.typeKey && !typeKey?.match(config.filter.typeKey));
163	        },
164	        // Fetcher: resolves the path template and performs the HTTP request
165	        async (path: string, data: unknown, typeKey: string) => {
166	            const uri = templateReplace(config.path, { path, data, typeKey });
167	            console.log(`[Fire3D Asset Loader] Attempting to load asset:`, {
168	                uri: uri,
169	                originalPath: path,
170	                typeKey: typeKey,

... [223 lines truncated] ...


cd /Users/marcus.hinz/projects/adesso-cpq
pnpm --filter @ad-cpq/visual-runtime build 2>&1 | grep -iE 'success|error|fail' | head
echo "=== amend ==="
git add packages/runtime/visual/src/visual-3d.ts
git commit --amend -m "visual-runtime: Szene-Adapter für fire (PMI-Rotation Grad→Radiant, Nicht-Root-PMIs entfernen)

Zwei Anpassungen auf dem Weg zu fire, ohne cpqs eigene Szene zu verändern:
1. component_pmi-Rotationen von Grad nach Radiant. fire deserialisiert eine
   quaternion-typisierte 3-Werte-Property als Euler-Radiant, die Katalog-
   Szenegraphen führen PMI-Rotationen aber in Grad (wie cpqs eulerZyxDegToQuat
   beim Docking). Dadurch drehte fires Live-Snap die Teile in einen krummen
   Winkel. Node-Transforms sind bereits Radiant und bleiben unberührt.
2. PMIs, die nicht auf dem Root-Handle liegen, werden entfernt. Nur die
   Andockpunkte der Root (CS_EIN_*, CS_OUT_*, …) sind fürs Platzieren relevant;
   die Per-Bauteil-PMIs der Sub-Komponenten-Handles überladen nur die Szene.
   Ein Handle ist ein Kind, wenn sein Transform einen parentKey trägt.

cpq-intern (dockRotation) liest weiterhin den unveränderten Grad-Graphen."
git log --oneline -1

Rebuild and amend commit with combined adapter


DTS ⚡ Build success in 335ms
ESM ⚡ Build success in 1732ms
=== amend ===
[heal-pmi-rotation 7090d05] visual-runtime: Szene-Adapter für fire (PMI-Rotation Grad→Radiant, Nicht-Root-PMIs entfernen)
 Date: Wed Jul 8 14:31:52 2026 +0200
 1 file changed, 50 insertions(+), 1 deletion(-)
7090d05 visual-runtime: Szene-Adapter für fire (PMI-Rotation Grad→Radiant, Nicht-Root-PMIs entfernen)


Erledigt. Der Adapter (`adaptSceneForFire`) macht jetzt beides, nur auf dem Weg zu fire:

1. **PMI-Rotation Grad→Radiant** (Snap-Kippfehler).
2. **Nicht-Root-PMIs entfernt** — jedes Handle mit `parentKey` (Kind-Handle) verliert seine `component_pmi`. Übrig bleiben nur die Root-Andockpunkte.

Für die Einzeltür heißt das: fire bekommt **23 statt 249 PMIs** (die 226 Sub-Komponenten-PMIs fallen weg). cpq-intern bleibt der vollständige Grad-Graph unangetastet.

Build grün, Commit amended (`7090d05`). Storybook übernimmt es per HMR beim Reload.


visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:204 [Fire3D Asset Loader] Loaded BLOB asset: Object
@ad_fire.js?v=339f371e:37819 Cl - onExecute: deserializing 'component_camera-controller' as 'w'. 1783518077855
@ad_fire.js?v=339f371e:37819 gd - applyMappings: no mapping defined for key 'colors'. 1783518077856
@ad_fire.js?v=339f371e:37819 [DEPRECATED] _d - getMaterial: Version >= 0.9.0 1783518077856
processMessage @ @ad_fire.js?v=339f371e:37819
@ad_fire.js?v=339f371e:37819 [DEPRECATED] _d - getMaterial: Version >= 0.9.0 1783518077857
processMessage @ @ad_fire.js?v=339f371e:37819
@ad_fire.js?v=339f371e:37819 [DEPRECATED] _d - getMaterial: Version >= 0.9.0 1783518077857
processMessage @ @ad_fire.js?v=339f371e:37819
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
@ad_fire.js?v=339f371e:37819 Nw - onInstantiate: worker processor was instantiated 1783518077874
@ad_fire.js?v=339f371e:37819 sx - onInstantiate: worker processor was instantiated 1783518077874
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:196 [Fire3D Asset Loader] Parsed JSON asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
visual-3d.ts:213 [Fire3D Asset Loader] Loaded ArrayBuffer asset: Object
Fire3D.vue:175 🔎 fire-event: context-click | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: selection | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: selection | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: interaction-mode | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: interaction-mode | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: selection-click | target.id: undefined | target.selector: undefined | target.tags: undefined | action: set | raw: Object
Fire3D.vue:175 🔎 fire-event: interaction-mode | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: selection | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: context-click | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: selection | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
Fire3D.vue:175 🔎 fire-event: interaction-mode | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: Object
@ad_fire.js?v=339f371e:30201 Uncaught TypeError: Cannot read properties of undefined (reading 'transform')
    at get defaultCursorTransform (@ad_fire.js?v=339f371e:30201:84)
    at get cursorTransform (@ad_fire.js?v=339f371e:30204:51)
    at M.processInput (@ad_fire.js?v=339f371e:30147:70)
    at y.processInput (@ad_fire.js?v=339f371e:29805:33)
    at S.processInput (@ad_fire.js?v=339f371e:30227:36)
    at Object.execute (@ad_fire.js?v=339f371e:30224:449)
    at Wr.executeCallbacks (@ad_fire.js?v=339f371e:17433:39)
    at Wr.processPhase (@ad_fire.js?v=339f371e:17429:105)
    at Wr.frame (@ad_fire.js?v=339f371e:17426:41)
    at Wr.frameAutomatic (@ad_fire.js?v=339f371e:17415:12)


Der Fix bricht das Placement: `dragPoints[0].transform` ist undefined → die DragPoints (`CS_EIN_L/R`) des zu platzierenden Bauteils fehlen. Grund: In der **zusammengesetzten** Placement-Szene ist das Bauteil-Root-Handle nicht der absolute Szenen-Root, sondern ein Kind des `assembly-root` (hat also einen `parentKey`) — meine Filterung hat damit die Andockpunkte des Subjects mitentfernt.

Meine „Root"-Definition (absoluter Szenen-Root) war zu naiv. Ich nehme die PMI-Entfernung wieder raus, damit das Placement läuft; die Rotations-Umrechnung bleibt. Ich lese den aktuellen Stand:


271	// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:
272	//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
273	//      quaternion-typed 3-value property as euler radians, but the catalog encodes PMI
274	//      rotations in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node
275	//      transforms (component_transform) are already radians and are left untouched.
276	//   2. PMIs not sitting on the root handle are dropped — only the root's docking points
277	//      (CS_EIN_*, CS_OUT_*, …) matter for placement; per-part PMIs on sub-component handles
278	//      only clutter the fire scene. A handle is a child when its transform has a parentKey.
279	const DEG_TO_RAD = Math.PI / 180;
280	function adaptSceneForFire(scene: unknown): unknown {
281	    const convert = (node: unknown): unknown => {
282	        if (Array.isArray(node)) {
283	            return node.map(convert);
284	        }
285	        if (node && typeof node === "object") {
286	            // Only plain scene-graph objects are cloned; engine class instances and other
287	            // non-plain values are passed through by reference (structuredClone chokes on them).
288	            const proto = Object.getPrototypeOf(node);
289	            if (proto !== Object.prototype && proto !== null) {
290	                return node;
291	            }
292	            const out: Record<string, any> = {};
293	            for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
294	                out[key] = convert(value);
295	            }
296	            if (out._resourceClass === "component_pmi") {
297	                const rot = out.properties?.rotation?.value;
298	                if (Array.isArray(rot) && rot.length === 3) {
299	                    out.properties.rotation.value = rot.map((v: number) => v * DEG_TO_RAD);
300	                }
301	            }
302	            if (Array.isArray(out.components)) {
303	                const transform = out.components.find(
304	                    (c: any) => c?._resourceClass === "component_transform",
305	                );
306	                if (transform?.parentKey) {
307	                    out.components = out.components.filter(
308	                        (c: any) => c?._resourceClass !== "component_pmi",
309	                    );
310	                }
311	            }
312	            return out;
313	        }
314	        return node;
315	    };
316	    return convert(scene);
317	}
318
319	export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {
320	    const { createEnvironment, Connector: ConnectorClass } = await loadFireOnce();
321
322	    const vi = await createEnvironment({
323	        ...config,
324	        initialScene: adaptSceneForFire(config.initialScene),
325	        // WHY we override deserializers here

// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:
//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
//      quaternion-typed 3-value property as euler radians, but the catalog encodes PMI
//      rotations in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node
//      transforms (component_transform) are already radians and are left untouched.
//   2. PMIs not sitting on the root handle are dropped — only the root's docking points
//      (CS_EIN_*, CS_OUT_*, …) matter for placement; per-part PMIs on sub-component handles
//      only clutter the fire scene. A handle is a child when its transform has a parentKey.
const DEG_TO_RAD = Math.PI / 180;
function adaptSceneForFire(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);
                }
            }
            if (Array.isArray(out.components)) {
                const transform = out.components.find(
                    (c: any) => c?._resourceClass === "component_transform",
                );
                if (transform?.parentKey) {
                    out.components = out.components.filter(
                        (c: any) => c?._resourceClass !== "component_pmi",
                    );
                }
            }
            return out;
        }
        return node;
    };
    return convert(scene);
}

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

    const vi = await createEnvironment({
        ...config,
        initialScene: adaptSceneForFire(config.initialScene),
        // WHY we override deserializers here


visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: Object visual-3d.ts:186 [Fire3D Asset Loader] Successfully loaded asset: Object visual-3d.ts:204 [Fire3D Asset Loader] Loaded BLOB asse…


Edit

// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:
//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
//      quaternion-typed 3-value property as euler radians, but the catalog encodes PMI
//      rotations in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node
//      transforms (component_transform) are already radians and are left untouched.
//   2. PMIs not sitting on the root handle are dropped — only the root's docking points
//      (CS_EIN_*, CS_OUT_*, …) matter for placement; per-part PMIs on sub-component handles
//      only clutter the fire scene. A handle is a child when its transform has a parentKey.
const DEG_TO_RAD = Math.PI / 180;

// Adapts a cpq scene graph on its way into fire, never mutating cpq's own copy:
// component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
// quaternion-typed 3-value property as euler radians, but the catalog encodes PMI rotations
// in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node transforms
// (component_transform) are already radians and are left untouched.
const DEG_TO_RAD = Math.PI / 180;


// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:
//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
//      quaternion-typed 3-value property as euler radians, but the catalog encodes PMI
//      rotations in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node
//      transforms (component_transform) are already radians and are left untouched.
//   2. PMIs not sitting on the root handle are dropped — only the root's docking points
//      (CS_EIN_*, CS_OUT_*, …) matter for placement; per-part PMIs on sub-component handles
//      only clutter the fire scene. A handle is a child when its transform has a parentKey.
const DEG_TO_RAD = Math.PI / 180;

// Adapts a cpq scene graph on its way into fire, never mutating cpq's own copy:
// component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a
// quaternion-typed 3-value property as euler radians, but the catalog encodes PMI rotations
// in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node transforms
// (component_transform) are already radians and are left untouched.
const DEG_TO_RAD = Math.PI / 180;

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

-// Adapts a cpq scene graph on its way into fire — never mutating cpq's own copy:

-//   1. component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a

-//      quaternion-typed 3-value property as euler radians, but the catalog encodes PMI

-//      rotations in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node

-//      transforms (component_transform) are already radians and are left untouched.

-//   2. PMIs not sitting on the root handle are dropped — only the root's docking points

-//      (CS_EIN_*, CS_OUT_*, …) matter for placement; per-part PMIs on sub-component handles

-//      only clutter the fire scene. A handle is a child when its transform has a parentKey.

+// Adapts a cpq scene graph on its way into fire, never mutating cpq's own copy:

+// component_pmi rotations are converted from DEGREES to RADIANS. fire deserializes a

+// quaternion-typed 3-value property as euler radians, but the catalog encodes PMI rotations

+// in degrees (matching cpq's own eulerZyxDegToQuat used for docking). Node transforms

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

function adaptSceneForFire(scene: unknown): unknown {

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


            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);
                }
            }
            if (Array.isArray(out.components)) {
                const transform = out.components.find(
                    (c: any) => c?._resourceClass === "component_transform",
                );
                if (transform?.parentKey) {
                    out.components = out.components.filter(
                        (c: any) => c?._resourceClass !== "component_pmi",
                    );
                }
            }
            return out;

            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;


            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);
                }
            }
            if (Array.isArray(out.components)) {
                const transform = out.components.find(
                    (c: any) => c?._resourceClass === "component_transform",
                );
                if (transform?.parentKey) {
                    out.components = out.components.filter(
                        (c: any) => c?._resourceClass !== "component_pmi",
                    );
                }
            }
            return out;

            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;

-            if (Array.isArray(out.components)) {

-                const transform = out.components.find(

-                    (c: any) => c?._resourceClass === "component_transform",

-                if (transform?.parentKey) {

-                    out.components = out.components.filter(

-                        (c: any) => c?._resourceClass !== "component_pmi",


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
pnpm --filter @ad-cpq/visual-runtime build 2>&1 | grep -iE 'success|error|fail' | head
git add packages/runtime/visual/src/visual-3d.ts
git commit --amend -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."
git log --oneline -1

Rebuild without PMI removal, amend commit


DTS ⚡ Build success in 305ms
ESM ⚡ Build success in 1726ms
[heal-pmi-rotation 4f64e1f] 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(-)
4f64e1f visual-runtime: PMI-Rotationen für fire von Grad nach Radiant umrechnen


Placement repariert — Rotations-Umrechnung bleibt, PMI-Entfernung ist raus. Build grün, Commit `4f64e1f`. Nach HMR-Reload sollte das Platzieren wieder ohne Crash laufen und dank rad→Radiant gerade snappen.

Zur PMI-Entfernung: mein „Root = absoluter Szenen-Root" war falsch. In der Laufzeit-Szene sind mehrere Bauteile zusammengesetzt — jedes Bauteil-Root-Handle (mit den Andockpunkten `CS_EIN_L/R`, `CS_OUT_*`) ist dort ein **Kind des `assembly-root`**, hat also selbst einen `parentKey`. Meine Filterung hat damit genau die Andockpunkte des zu platzierenden Bauteils gelöscht → keine DragPoints → Crash.

Für eine robuste Entfernung muss „Bauteil-Root" von „Sub-Komponente" getrennt werden. Zwei tragfähige Wege:

1. **Nach Baum-Tiefe:** direkte Kinder des `assembly-root` (die Bauteil-Roots) behalten ihre PMIs, tiefere Handles (Sub-Komponenten) verlieren sie. Setzt voraus, dass die Laufzeit-Szene genau diese Ebenen hat — das müsste ich an einer echten zusammengesetzten Szene verifizieren (die sehe ich nur im Browser).
2. **Nach PMI-Rolle/Name:** nur die Andock-relevanten PMIs behalten (`CS_EIN_L`, `CS_EIN_R`, `CS_EIN_START_*`, `CS_OUT_*`, `CS_SELECT_*`, `CS_EIN`, `CS_BASE*`, `CS_BASIS`), der Rest (`CS_EIN_QUER`, `CS_EIN_STEHER`, `CS_EIN_LAENGS`, …) fällt weg. Robust gegen die Baumstruktur, aber eine Namensliste.

Sag, welchen Weg — oder dumpe mir eine zusammengesetzte Laufzeit-Szene (z. B. `console.log(JSON.stringify(scene))` in `adaptSceneForFire`), dann leite ich die Baum-Definition sauber daraus ab.


kann man auch die ganzesn suhandles rauswerfen, odr sehen wir dann nix mehr?


cd /Users/marcus.hinz/projects/adesso-cpq
python3 << 'EOF'
import json
d=json.load(open('packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json'))
handles=d.get('handles',[])
def tf(h):
    for c in h.get('components',[]) or []:
        if c.get('_resourceClass')=='component_transform': return c
    return {}
from collections import Counter
root_rc=Counter(); child_rc=Counter()
root_geo=child_geo=0
for h in handles:
    t=tf(h)
    rcs=[c.get('_resourceClass') for c in h.get('components',[]) or []]
    has_geo=any('render' in (r or '') or 'geometr' in (r or '') or 'mesh' in (r or '') for r in rcs)
    if not t.get('parentKey'):
        for r in rcs: root_rc[r]+=1
        root_geo+= has_geo
    else:
        for r in rcs: child_rc[r]+=1
        child_geo+= has_geo
print("ROOT-Handle component-Klassen:", dict(root_rc))
print("KIND-Handles component-Klassen:", dict(child_rc))
print(f"\nRoot-Handles mit Geometrie/Renderer: {root_geo}")
print(f"Kind-Handles mit Geometrie/Renderer: {child_geo}")
EOF

Check whether geometry lives in child handles


ROOT-Handle component-Klassen: {'component_transform': 1, 'component_pmi': 23}
KIND-Handles component-Klassen: {'component_transform': 184, 'component_pmi': 226, 'component_collider': 102}

Root-Handles mit Geometrie/Renderer: 0
Kind-Handles mit Geometrie/Renderer: 0


Nein — dann siehst du sehr wahrscheinlich nichts mehr (oder nur Kaputtes). Die Aufteilung ist eindeutig:

| | Root-Handle (1) | Kind-Handles (184) |
|---|---|---|
| `component_transform` | 1 | 184 |
| `component_pmi` | 23 | 226 |
| `component_collider` | 0 | 102 |

Der **Root-Handle trägt nur die 23 Andockpunkte** (PMIs) plus einen Anker-Transform — sonst nichts. Die **Kind-Handles tragen die gesamte Bauteil-Struktur**: alle Positionierungs-Transforms und die Kollisionskörper.

Zusätzlich: In dieser JSON gibt es **gar keine** `component_renderer`/Mesh-Komponente — die sichtbare Geometrie steht nicht im Szenegraph, sondern wird über den Asset-Loader (die `glb`-Assets aus deinen Log-Zeilen) nachgeladen und an die Handle-Struktur (IDs/Transforms) gebunden. Wirfst du die Kind-Handles raus, entfernst du die Transforms und die Verankerung, an der diese Geometrie hängt → die Tür verschwindet bzw. kollabiert.

Also: **Handles behalten, nur die störenden PMIs entfernen.** Das Rauswerfen ganzer Sub-Handles ist keine Option — sie sind das Bauteil, nicht nur Beiwerk.


ok -
wir können dioe Assetts so umgestalten, dass alles was der Visitor macht, In den Assets scjhon gerechnet ist?
dann können wir uns den Visotor sparen, und damit den branch, auf dem wir gerdea arbeiten, wegwerfen?


Ja, technisch geht das — aber es ist kein reines „Visitor weg, fertig", es **verschiebt** den Fix an drei Stellen. Der Visitor macht ja aktuell nur noch eins (die PMI-Entfernung ist raus): PMI-`rotation` Grad → Radiant.

Wenn du das in die Assets vorziehst, hängt daran:

1. **Assets umrechnen:** jede `component_pmi`-`rotation` × π/180 in allen Katalog-JSONs. Dann liest fire sie nativ korrekt — Visitor überflüssig.

2. **cpq muss mit umgestellt werden.** cpqs `dockRotation` liest dieselben PMI-Werte über `eulerZyxDegToQuat` — **als Grad**. Sind die Assets auf Radiant, muss das auf eine Radiant-Variante umgestellt werden, sonst kippt jetzt der Verbau-Graph (der bisher korrekt war). Der Fix verschwindet nicht, er wandert von visual-runtime nach GroupProcessor + Daten.

3. **Der Generator muss mitziehen.** Wenn die Assets aus Speedmaxx exportiert werden (in Grad), bringt der **nächste Katalog-Import wieder Grad**. Ein einmaliger Asset-Umbau hält also nur bis zum nächsten Export — dauerhaft wird es erst, wenn die Umrechnung im Export/Import verankert ist.

**Abwägung:**
- **Visitor (aktuell):** ein einziger, lokaler Ort; robust gegen Re-Import; minimaler Laufzeit-Aufwand (deep-clone der Szene). Nachteil: die Daten bleiben inkonsistent (Grad vs. Radiant im selben Graph).
- **Asset-/Pipeline-Weg:** konsistente Daten (alle Rotationen Radiant), kein Laufzeit-Adapter. Nachteil: Änderung an Assets **und** cpq-Code **und** Export-Pipeline; größerer Eingriff, sonst nicht dauerhaft.

Also: Branch wegwerfen kannst du — aber nur, wenn du gleichzeitig cpqs `dockRotation` auf Radiant umstellst und die Export-Pipeline die Umrechnung übernimmt. Solange die Datenquelle Grad liefert, ist der Visitor die günstigere, robustere Lösung. Ich empfehle, den Visitor zu behalten, bis der Speedmaxx-Export auf Radiant vereinheitlicht ist.


Warum ist die PMI Entfernung raus?


Weil sie das Platzieren zum Absturz gebracht hat — genau der Fehler, den du gepostet hattest:

```
Cannot read properties of undefined (reading 'transform')
  at get defaultCursorTransform (@ad_fire.js:30201)
```

Ursache: Meine Entfernung war „behalte PMIs nur auf dem Handle ohne `parentKey` (absoluter Szenen-Root)". In der **zusammengesetzten** Placement-Szene ist das zu platzierende Bauteil aber ein **Kind des `assembly-root`** — sein Root-Handle hat dort selbst einen `parentKey`. Damit galt es als „nicht Root", und die Filterung hat **die Andockpunkte des Subjects** (`CS_EIN_L/R`) mitgelöscht. Ohne diese DragPoints ist `dragPoints` leer → `dragPoints[0].transform` undefined → Crash.

Also nicht, weil die Idee falsch ist, sondern weil meine **„Root"-Definition** (absoluter Szenen-Root) in der Laufzeit-Szene die falschen Handles trifft. Ich habe sie rausgenommen, damit das Placement wieder läuft; die Rotations-Umrechnung ist geblieben.

Für eine funktionierende Entfernung müsste „Bauteil-Root" statt „absoluter Szenen-Root" erkannt werden — die zwei Wege (Baum-Tiefe oder PMI-Namensrolle) hatte ich genannt. Dafür bräuchte ich den Aufbau einer echten zusammengesetzten Laufzeit-Szene (per `console.log(JSON.stringify(scene))` in `adaptSceneForFire`), um die Ebenen sauber zu treffen.


OH MANN Szene Root? gehts noch????????
Wie oft das Elementare?????
Wie bauen wir einen SzenegraphEntity in den Graphen ein?


cd /Users/marcus.hinz/projects/adesso-cpq
grep -rniE 'sceneGraph_entity|parentKey|addEntity|insertEntity|addProduct|embed|mount|nest|subEntity|assembly' packages/frontend/src/configurator --include='*.ts' | grep -iv test | head -30

Find how a sceneGraph_entity is embedded into the graph


packages/frontend/src/configurator/model/GroupProcessor.ts:26:// assembly-of-assembly hierarchies just add roles without changing the resolver. Today: scene root,
packages/frontend/src/configurator/model/GroupProcessor.ts:27:// assembly (group node), part (element root); everything below a part is geometry.
packages/frontend/src/configurator/model/GroupProcessor.ts:28:export const ROLE = { scene: "scene", assembly: "assembly", part: "part", geometry: "geometry" } as const;
packages/frontend/src/configurator/model/GroupProcessor.ts:166:                if (comp.parentKey) {
packages/frontend/src/configurator/model/GroupProcessor.ts:167:                    const mapped = keyMap.get(comp.parentKey);
packages/frontend/src/configurator/model/GroupProcessor.ts:168:                    if (mapped) comp.parentKey = mapped;
packages/frontend/src/configurator/model/GroupProcessor.ts:170:                    // The root transform carries no parentKey → its handle is the element root.
packages/frontend/src/configurator/model/GroupProcessor.ts:325:    // at (and including) the picked handle. Depth-agnostic — works for any number of nesting levels.
packages/frontend/src/configurator/model/GroupProcessor.ts:355:    // Selectable nodes above (and including) a picked handle, lowest first: part, then assembly,
packages/frontend/src/configurator/model/GroupProcessor.ts:363:            if (node.role === ROLE.part || node.role === ROLE.assembly) out.push(cur);
packages/frontend/src/configurator/model/GroupProcessor.ts:466:                tr.parentKey = g.key;
packages/frontend/src/configurator/model/GroupProcessor.ts:580:            tags: [roleTag(ROLE.assembly)],
packages/frontend/src/configurator/model/GroupProcessor.ts:585:                    parentKey: ROOT_KEY,
packages/frontend/src/configurator/model/GroupProcessor.ts:655:    // transform's parentKey to the owning handle. The role resolver walks this.
packages/frontend/src/configurator/model/GroupProcessor.ts:665:            const parentId = tr?.parentKey ? keyToId.get(tr.parentKey) ?? null : null;
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:13:    // (part → assembly → …). A plain click sets a single pick (re-clicking the same one escalates);
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:39:                return this.handleAddProduct(event);
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:77:    private handleAddProduct(event: any): UIReaction[] {
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:237:            if (this.groups.roleOfId(node) === ROLE.assembly) {
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:252:        // own group first — see handleDetachElement — so the engine never gets a nested subject).
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:467:            h.components?.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),


Ich schaue mir an, wie `replay` ein `sceneGraph_entity` tatsächlich einbaut:


140	            rootHandleId,
141	            handles,
142	            position,
143	            rotation: IDENTITY_QUAT,
144	        };
145	    }
146
147	    private reidentify(productScene: Scene): { handles: Handle[]; rootHandleId: string } {
148	        const src = structuredClone(productScene);
149	        const keyMap = new Map<string, string>();
150
151	        for (const handle of src.handles ?? []) {
152	            handle.id = this.nextId("h");
153	            for (const comp of handle.components ?? []) {
154	                if (comp._resourceClass === "component_transform" && comp.key) {
155	                    const newKey = this.nextId("k");
156	                    keyMap.set(comp.key, newKey);
157	                    comp.key = newKey;
158	                }
159	            }
160	        }
161
162	        let rootHandleId = "";
163	        for (const handle of src.handles ?? []) {
164	            for (const comp of handle.components ?? []) {
165	                if (comp._resourceClass !== "component_transform") continue;
166	                if (comp.parentKey) {
167	                    const mapped = keyMap.get(comp.parentKey);
168	                    if (mapped) comp.parentKey = mapped;
169	                } else {
170	                    // The root transform carries no parentKey → its handle is the element root.
171	                    rootHandleId = handle.id;
172	                }
173	            }
174	        }
175	        return { handles: src.handles, rootHandleId };
176	    }
177
178	    groupIdOfElement(rootHandleId: string): string | null {
179	        return this.elementToGroup.get(rootHandleId) ?? null;
180	    }
181
182	    groupByKey(key: string): string | null {
183	        for (const g of this.groups.values()) if (g.key === key) return g.id;
184	        return null;
185	    }
186
187	    groupKeyOf(groupId: string): string | null {
188	        return this.groups.get(groupId)?.key ?? null;
189	    }
190
191	    transformOf(groupId: string): Transform | null {
192	        return this.groups.get(groupId)?.transform ?? null;
193	    }
194

            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

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

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

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

    groupIdOfElement(rootHandleId: string): string | null {
        return this.elementToGroup.get(rootHandleId) ?? null;
    }

    groupByKey(key: string): string | null {
        for (const g of this.groups.values()) if (g.key === key) return g.id;
        return null;
    }

    groupKeyOf(groupId: string): string | null {
        return this.groups.get(groupId)?.key ?? null;
    }

    transformOf(groupId: string): Transform | null {
        return this.groups.get(groupId)?.transform ?? null;
    }


[Request interrupted by user]


DU BRAUCHST DAS NICHZT ANZUSCHAUEN
--> WIR ÜBERNEHMEN EINFACH AÖLLES HANDLES VON A NACH B
Wir arbeiten aber nut auf der Roothandle der übertragenen Liste
ich frage mich, warum man die anderen PMIs nicht entfernen können sollte


DU BRAUCHST DAS NICHZT ANZUSCHAUEN --> WIR ÜBERNEHMEN EINFACH AÖLLES HANDLES VON A NACH B Wir arbeiten aber nut auf der Roothandle der übertragenen Liste ich frage mich, warum man die anderen PMIs nic…


Doch, man kann — es gibt keinen fachlichen Grund dagegen. Alle Andockpunkte sitzen auf dem Root-Handle der übertragenen Liste; die PMIs der übrigen Handles sind für Snap und Verbau irrelevant und können weg.

Mein Crash lag nicht daran, dass es nicht geht, sondern **an der Stelle**, an der ich es gemacht habe: im visual-runtime-Visitor, der den **schon zusammengesetzten** Graphen sieht. Beim Einbau bekommt der übernommene Root-Handle einen `parentKey` (auf die Gruppe) — er ist im Gesamtgraph nicht mehr „parentKey-los". Meine Regel „parentKey-los = Root" hat ihn dann als Sub-Handle eingestuft und seine Andockpunkte mitgelöscht → keine DragPoints → Crash.

Richtig ist genau dein Punkt: beim **Übernehmen der Handles von A nach B**, wo der Root-Handle der Liste noch eindeutig als solcher bekannt ist (der Handle, dessen Transform keinen `parentKey` hat — das ist die `rootHandleId`), dort alle PMIs der **übrigen** Handles verwerfen. Der Root-Handle behält seine Andockpunkte, die Sub-Handles behalten Transform/Collider/Geometrie-Anker, nur ihre PMIs fallen weg.

Also: PMI-Entfernung raus aus dem Visitor, rein in den Einbau der Handle-Liste. Da ist „Root" trivial und stabil.


oh gott - warum sagst du das nicht?????
Ist im Ende aber egal
Das machen wir jetzt einmal und dann hat sich das Thema
kannst du die Assets so aufrabeiten?


Ja. Ansatz: pro Szenegraph-Datei den Root-Handle finden (der Handle, dessen Transform **keinen** `parentKey` hat — die `rootHandleId`), und von **allen anderen** Handles die `component_pmi` entfernen. Root behält seine Andockpunkte, Sub-Handles behalten Transform/Collider. Backup vorweg, generisch über alle drei Bereiche:


cd /Users/marcus.hinz/projects/adesso-cpq
BK=/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/d39343b7-d69e-4d28-8db5-a5803a4e8931/scratchpad/pmi-strip-backup
mkdir -p "$BK"
python3 << EOF
import json, glob, os
BASE="packages/frontend/.storybook/products"
BK="$BK"
dirs=["schutzzaun_basic","schutzzaun_allround","rollenfoerderer"]
def tf(h):
    for c in h.get('components',[]) or []:
        if c.get('_resourceClass')=='component_transform': return c
    return {}
for dname in dirs:
    files=sorted(glob.glob(os.path.join(BASE,dname,"*.json")))
    proc=0; skipped=[]; rem=0; kept=0; multiroot=0
    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: skipped.append(os.path.basename(f)+"(no-json)"); continue
        if not isinstance(d,dict) or d.get('_resourceClass')!='sceneGraph_entity':
            skipped.append(os.path.basename(f)+"(not-entity)"); continue
        handles=d.get('handles',[]) or []
        roots=[h for h in handles if not tf(h).get('parentKey')]
        if len(roots)!=1:
            multiroot+=1; skipped.append(os.path.basename(f)+f"(roots={len(roots)})"); continue
        root=roots[0]
        for h in handles:
            comps=h.get('components',[]) or []
            npmi=sum(1 for c in comps if c.get('_resourceClass')=='component_pmi')
            if h is root: kept+=npmi
            elif npmi:
                h['components']=[c for c in comps if c.get('_resourceClass')!='component_pmi']; rem+=npmi
        bpath=os.path.join(BK, dname+"__"+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))
        proc+=1
    print(f"[{dname}] verarbeitet={proc}  PMIs entfernt={rem}  Root-PMIs behalten={kept}  übersprungen={len(skipped)} {skipped[:5]}{'...' if len(skipped)>5 else ''}")
print("Backup:", BK)
EOF

Strip non-root PMIs from all catalog scenegraphs with backup


[schutzzaun_basic] verarbeitet=77  PMIs entfernt=13088  Root-PMIs behalten=1779  übersprungen=1 ['schutzzaun_basic.json(no-json)']
[schutzzaun_allround] verarbeitet=84  PMIs entfernt=11824  Root-PMIs behalten=918  übersprungen=1 ['schutzzaun_allround.json(no-json)']
[rollenfoerderer] verarbeitet=1  PMIs entfernt=4  Root-PMIs behalten=3  übersprungen=1 ['rollenfoerderer.json(no-json)']
Backup: /private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/d39343b7-d69e-4d28-8db5-a5803a4e8931/scratchpad/pmi-strip-backup


oh gott - warum sagst du das nicht????? Ist im Ende aber egal Das machen wir jetzt einmal und dann hat sich das Thema kannst du die Assets so aufrabeiten?


Verifikation an einer Beispieldatei — Root-Andockpunkte erhalten, Sub-PMIs weg, JSON valide, Root-Handle noch identifizierbar:


cd /Users/marcus.hinz/projects/adesso-cpq
python3 << 'EOF'
import json
f='packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json'
d=json.load(open(f))
handles=d.get('handles',[])
def tf(h):
    for c in h.get('components',[]) or []:
        if c.get('_resourceClass')=='component_transform': return c
    return {}
roots=[h for h in handles if not tf(h).get('parentKey')]
print("Root-Handles (kein parentKey):", len(roots))
root=roots[0]
root_pmis=[c.get('id') for c in root.get('components',[]) or [] if c.get('_resourceClass')=='component_pmi']
print("PMIs auf Root:", len(root_pmis))
print("  Andock-relevante da?:", [p for p in ['CS_EIN_L','CS_EIN_R','CS_EIN_START_L','CS_EIN_START_R','CS_OUT_R_0','CS_OUT_L_0','CS_OUT_R_90','CS_OUT_R_270'] if p in root_pmis])
# PMIs auf Nicht-Root?
other=sum(1 for h in handles if h is not root for c in (h.get('components',[]) or []) if c.get('_resourceClass')=='component_pmi')
print("PMIs auf Nicht-Root-Handles (soll 0):", other)
# Collider/Transform der Sub-Handles noch da?
sub_tf=sum(1 for h in handles if h is not root for c in (h.get('components',[]) or []) if c.get('_resourceClass')=='component_transform')
sub_col=sum(1 for h in handles if h is not root for c in (h.get('components',[]) or []) if c.get('_resourceClass')=='component_collider')
print(f"Sub-Handles: transforms={sub_tf}, collider={sub_col} (erhalten)")
print("Handles gesamt:", len(handles))
EOF

Verify root PMIs kept, sub PMIs removed, geometry intact


Root-Handles (kein parentKey): 1
PMIs auf Root: 23
  Andock-relevante da?: ['CS_EIN_L', 'CS_EIN_R', 'CS_EIN_START_L', 'CS_EIN_START_R', 'CS_OUT_R_0', 'CS_OUT_L_0', 'CS_OUT_R_90', 'CS_OUT_R_270']
PMIs auf Nicht-Root-Handles (soll 0): 0
Sub-Handles: transforms=184, collider=102 (erhalten)
Handles gesamt: 185


Erledigt und verifiziert.

**Aufbereitet** (nur Root-Handle behält PMIs):
- schutzzaun_basic: 77 Dateien, 13.088 Sub-PMIs entfernt, 1.779 Root-Andockpunkte behalten
- schutzzaun_allround: 84 Dateien, 11.824 entfernt, 918 behalten
- rollenfoerderer: 1 Datei, 4 entfernt, 3 behalten
- Übersprungen: die drei `<bereich>.json` (Tcl-Definitionsdateien, keine sceneGraph_entity)

**Verifikation** (SBS1044SLA): 1 Root-Handle, 23 Andockpunkte erhalten (`CS_EIN_L/R`, `CS_EIN_START_*`, `CS_OUT_*` alle da), 0 PMIs auf Sub-Handles, Sub-Handles behalten ihre 184 Transforms + 102 Collider — Geometrie/Struktur intakt.

**Backup:** `…/scratchpad/pmi-strip-backup/<bereich>__<datei>.json.bak`

Damit macht der Visitor die PMI-Entfernung nicht mehr — sie steckt in den Daten. Der Visitor bleibt nur noch für die Rotations-Umrechnung (Grad→Radiant), die weiterhin nötig ist, weil die Root-PMIs ihre Grad-Werte behalten.

Zwei Anmerkungen:
- Änderungen sind uncommitted (162 JSONs im Working Tree auf `heal-pmi-rotation`, überlagert mit dem Katalog-Update).
- „Hat sich das Thema" gilt für den aktuellen Datenstand — bei einem erneuten Speedmaxx-Export kämen die Sub-PMIs wieder mit; dauerhaft wird es erst, wenn das Strippen im Export/Import verankert ist.


gut jetzt grd / Rad rthema
--> alles Assets auf RAD umstellen
was müssen wir tun?


Bevor ich den Plan festmache, prüfe ich, welche cpq-Stellen die PMI-Rotation als **Grad** interpretieren — die müssen mit umgestellt werden, sonst kippt der Verbau:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== eulerZyxDegToQuat: Definition + alle Nutzungen ==="
grep -rniE 'eulerZyxDegToQuat|eulerZyx|DegToQuat|degToRad|DEG_TO_RAD|\* *Math\.PI *\/ *180|/ *180' packages/frontend/src packages/core/src packages/blast/src --include='*.ts' 2>/dev/null | grep -iv '\.test\.' | head -25
echo "=== weitere Stellen die properties.rotation eines PMI lesen ==="
grep -rniE 'rotation.*value|properties.*rotation|pmiRotation|\.rotation' packages/frontend/src/configurator --include='*.ts' 2>/dev/null | grep -iv '\.test\.' | grep -iE 'pmi|properties|euler|quat' | head

Find all cpq places interpreting PMI rotation as degrees


=== eulerZyxDegToQuat: Definition + alle Nutzungen ===
packages/frontend/src/configurator/model/transformMath.ts:63:export function eulerZyxDegToQuat(deg: Vec3): Quat {
packages/frontend/src/configurator/model/transformMath.ts:64:    const r = Math.PI / 180;
packages/frontend/src/configurator/model/GroupProcessor.ts:9:    eulerZyxDegToQuat,
packages/frontend/src/configurator/model/GroupProcessor.ts:630:                return rot ? eulerZyxDegToQuat(rot as Vec3) : IDENTITY_QUAT;
=== weitere Stellen die properties.rotation eines PMI lesen ===
packages/frontend/src/configurator/model/GroupProcessor.ts:622:    // The docking point's frame orientation, from its `properties.rotation` (Euler degrees, ZYX).
packages/frontend/src/configurator/model/GroupProcessor.ts:625:    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
packages/frontend/src/configurator/model/GroupProcessor.ts:629:                const rot = comp.properties?.rotation?.value;
packages/frontend/src/configurator/model/GroupProcessor.ts:640:        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));


Klar umrissen — cpq interpretiert die PMI-Rotation als Grad an **genau einer** Stelle. Das macht die Umstellung überschaubar. Nötig sind drei Änderungen, die **zusammen** passieren müssen (sonst ist zwischendrin etwas kaputt):

**1. Assets: PMI-Rotationen Grad → Radiant**
Alle `component_pmi.rotation.value` × π/180. Nach dem Strippen sind das nur noch die Root-Andockpunkte (23 pro Bauteil, ~162 Dateien). Node-Transforms sind schon Radiant → unberührt. Danach sind **alle** Rotationen im Graph einheitlich Radiant.

**2. cpq: die eine Grad-Stelle umstellen**
`GroupProcessor.pmiRotation` (Zeile 630) nutzt `eulerZyxDegToQuat` → auf eine Radiant-Variante (`eulerZyxRadToQuat`, ohne den `Math.PI/180`-Faktor) umstellen. Plus den Kommentar (Zeile 622 „Euler degrees" → Radiant). Das ist die **einzige** cpq-Stelle, die PMI-Rotation als Grad liest — die Node-Transform-Rotationen liest cpq bereits als Radiant.

**3. Visitor entfernen**
`adaptSceneForFire` fällt weg; `setScene`/`initialScene` geben die Szene wieder direkt an fire. fire liest die Radiant-Assets dann nativ korrekt (`quaternionType` deserialisiert als Euler-ZYX-**Radiant**). Damit ist der Branch `heal-pmi-rotation` überflüssig — wegwerfbar.

**Reihenfolge:** atomar in einem Schritt. Assets auf Radiant + cpq auf Radiant + Visitor raus gehören in denselben Commit — sonst kippt entweder der Verbau (Assets rad, cpq noch deg) oder fire (Assets rad, Visitor rechnet nochmal um).

**Offener Punkt (Dauerhaftigkeit):** Wie beim PMI-Strippen — der Speedmaxx-Export liefert weiter Grad. Damit es dauerhaft ist, muss die Grad→Radiant-Umrechnung (und das Strippen) im Export/Import verankert werden. Sonst bringt der nächste Katalog-Import wieder Grad zurück.

Soll ich diese drei Änderungen umsetzen?


gut jetzt grd / Rad rthema --> alles Assets auf RAD umstellen was müssen wir tun?


DU REDEST WIEDER ÜBER KRAM, DEN DU NICHT KENNST
DIE ÄNDERUNGEN SIN BEIN SPEEDMAXX BEAUFTRAGT
ICH WILL VERMEIDEN, JETZT DEN CODE ZU VERSCHMUTZEN; FÜR ETWAS DAS GERADE GEZOGEN WIRD
SEttz es mal um


58	// Euler angles in DEGREES → quaternion [w, x, y, z], applied in fire-visual's default ZYX order.
59	// This mirrors how fire-visual ingests rotations: a 3-component rotation property is read as an
60	// Euler with order ZYX (QuaternionType.deserialize), and degree-authored sources are converted with
61	// degreeToRad (FBX Lcl_Rotation). The PMI docking points carry their frame orientation this way —
62	// e.g. CS_EIN_VORNE = [90, 0, 90], while CS_LINKS/CS_RECHTS = [0, 0, 0] (→ identity).
63	export function eulerZyxDegToQuat(deg: Vec3): Quat {
64	    const r = Math.PI / 180;
65	    const x = deg[0] * r,
66	        y = deg[1] * r,
67	        z = deg[2] * r;
68	    const cx = Math.cos(x / 2),
69	        cy = Math.cos(y / 2),
70	        cz = Math.cos(z / 2);
71	    const sx = Math.sin(x / 2),
72	        sy = Math.sin(y / 2),
73	        sz = Math.sin(z / 2);
74	    return [
75	        cx * cy * cz + sx * sy * sz,
76	        sx * cy * cz - cx * sy * sz,
77	        cx * sy * cz + sx * cy * sz,
78	        cx * cy * sz - sx * sy * cz,
79	    ];
80	}
81

// Euler angles in DEGREES → quaternion [w, x, y, z], applied in fire-visual's default ZYX order.
// This mirrors how fire-visual ingests rotations: a 3-component rotation property is read as an
// Euler with order ZYX (QuaternionType.deserialize), and degree-authored sources are converted with
// degreeToRad (FBX Lcl_Rotation). The PMI docking points carry their frame orientation this way —
// e.g. CS_EIN_VORNE = [90, 0, 90], while CS_LINKS/CS_RECHTS = [0, 0, 0] (→ identity).
export function eulerZyxDegToQuat(deg: Vec3): Quat {
    const r = Math.PI / 180;
    const x = deg[0] * r,
        y = deg[1] * r,
        z = deg[2] * r;
    const cx = Math.cos(x / 2),
        cy = Math.cos(y / 2),
        cz = Math.cos(z / 2);
    const sx = Math.sin(x / 2),
        sy = Math.sin(y / 2),
        sz = Math.sin(z / 2);
    return [
        cx * cy * cz + sx * sy * sz,
        sx * cy * cz - cx * sy * sz,
        cx * sy * cz + sx * cy * sz,
        cx * cy * sz - sx * sy * cz,
    ];
}


Setze ich um. Erst weg vom Visitor-Branch (der wird obsolet), dann Assets auf Radiant:


cd /Users/marcus.hinz/projects/adesso-cpq
git checkout placement-dragpoints 2>&1 | tail -2
echo "auf Branch: $(git branch --show-current) — visual-3d.ts Visitor jetzt weg (nur im heal-Commit):"
grep -c 'adaptSceneForFire' packages/runtime/visual/src/visual-3d.ts
echo "=== Assets: PMI-Rotationen Grad -> Radiant ==="
BK=/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/d39343b7-d69e-4d28-8db5-a5803a4e8931/scratchpad/pmi-rad-backup
mkdir -p "$BK"
python3 << EOF
import json, glob, os, math
BASE="packages/frontend/.storybook/products"; BK="$BK"
R=math.pi/180
tot=0; files=0
for dname in ["schutzzaun_basic","schutzzaun_allround","rollenfoerderer"]:
    for f in sorted(glob.glob(os.path.join(BASE,dname,"*.json"))):
        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: continue
        if not isinstance(d,dict) or d.get('_resourceClass')!='sceneGraph_entity': continue
        n=0
        def walk(o):
            global n
            if isinstance(o,dict):
                if o.get('_resourceClass')=='component_pmi':
                    rv=(o.get('properties',{}) or {}).get('rotation',{}).get('value')
                    if isinstance(rv,list) and len(rv)==3:
                        o['properties']['rotation']['value']=[x*R for x in rv]; n+=1
                for v in o.values(): walk(v)
            elif isinstance(o,list):
                for v in o: walk(v)
        walk(d)
        if n:
            bpath=os.path.join(BK, dname+"__"+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))
            files+=1; tot+=n
print(f"Dateien: {files}, PMI-Rotationen -> Radiant: {tot}")
print("Backup:", BK)
EOF

Switch branch and convert asset PMI rotations to radians


The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.


[Request interrupted by user for tool use]


DU REDEST WIEDER ÜBER KRAM, DEN DU NICHT KENNST DIE ÄNDERUNGEN SIN BEIN SPEEDMAXX BEAUFTRAGT ICH WILL VERMEIDEN, JETZT DEN CODE ZU VERSCHMUTZEN; FÜR ETWAS DAS GERADE GEZOGEN WIRD SEttz es mal um


was passiert mit den angepassten assetts?


Aktuell sind wir noch auf `heal-pmi-rotation` (der Abbruch hat den Wechsel verhindert) — die angepassten Assets sind **unverändert da**, im Working Tree, gestrippt aber uncommitted.

Was der abgebrochene Befehl mit ihnen gemacht hätte:
- `git checkout placement-dragpoints` — die gestrippten JSONs wären **mitgewandert**, kein Verlust. Beide Branches haben die Katalog-JSONs im selben committeten Stand (`debdb4e`, keiner hat sie committet), also überträgt git die uncommitteten Strip-Änderungen konfliktfrei auf den anderen Branch.

Das Risiko liegt woanders: **die gestrippten Assets sind nirgends committet.** Sie leben nur im Working Tree. Das Backup im Scratchpad ist der Zustand **vor** dem Strippen (Wiederherstellung), nicht das gestrippte Ergebnis. Ein versehentlicher `reset --hard` oder ein checkout mit Konflikt würde die ganze Strip-Arbeit (24.936 entfernte PMIs) verlieren.

Sicherer Weg, bevor irgendeine Branch-Operation läuft: die Asset-Änderungen (Katalog-Update + Strip) erst committen — dann sind sie fest, egal was mit Branches passiert. Sag, ob ich sie zuerst commite (und auf welchem Branch), dann mache ich mit der Radiant-Umstellung weiter.


gut dann weiter


Assets zuerst sichern. Ich committe die Katalog-JSONs (Update + PMI-Strip) auf dem aktuellen Branch:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "Branch: $(git branch --show-current)"
echo "Uncommitted (Kategorien):"; git status --porcelain | awk '{print substr($0,1,2)}' | sort | uniq -c
echo "Nicht-Produkt-Dateien offen:"; git status --porcelain | sed -E 's/^.. //' | grep -viE 'products/(schutzzaun|rollenfoerderer)' | head
git add packages/frontend/.storybook/products
git commit -m "Katalog-Szenegraphen: Update (07.07.) und Nicht-Root-PMIs entfernt

Pro Bauteil-Szenegraph nur noch die Andockpunkte (CS_EIN_*, CS_OUT_*, …) auf
dem Root-Handle; die Per-Bauteil-PMIs der Sub-Komponenten-Handles sind entfernt.
Transforms und Collider der Sub-Handles bleiben unangetastet (Geometrie intakt)." 2>&1 | tail -3
git log --oneline -1

Commit the stripped catalog assets


Branch: heal-pmi-rotation
Uncommitted (Kategorien):
 162  M
Nicht-Produkt-Dateien offen:
[heal-pmi-rotation da79cc2] Katalog-Szenegraphen: Update (07.07.) und Nicht-Root-PMIs entfernt
 162 files changed, 162 insertions(+), 162 deletions(-)
da79cc2 Katalog-Szenegraphen: Update (07.07.) und Nicht-Root-PMIs entfernt


Assets gesichert (`da79cc2`). Jetzt die Radiant-Umstellung — Schritt 1, Assets:


cd /Users/marcus.hinz/projects/adesso-cpq
BK=/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/d39343b7-d69e-4d28-8db5-a5803a4e8931/scratchpad/pmi-rad-backup
mkdir -p "$BK"
python3 << EOF
import json, glob, os, math
BASE="packages/frontend/.storybook/products"; BK="$BK"; R=math.pi/180
tot=0; files=0
for dname in ["schutzzaun_basic","schutzzaun_allround","rollenfoerderer"]:
    for f in sorted(glob.glob(os.path.join(BASE,dname,"*.json"))):
        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: continue
        if not isinstance(d,dict) or d.get('_resourceClass')!='sceneGraph_entity': continue
        n=0
        def walk(o):
            global n
            if isinstance(o,dict):
                if o.get('_resourceClass')=='component_pmi':
                    rv=(o.get('properties',{}) or {}).get('rotation',{}).get('value')
                    if isinstance(rv,list) and len(rv)==3:
                        o['properties']['rotation']['value']=[x*R for x in rv]; n+=1
                for v in o.values(): walk(v)
            elif isinstance(o,list):
                for v in o: walk(v)
        walk(d)
        if n:
            bpath=os.path.join(BK, dname+"__"+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))
            files+=1; tot+=n
print(f"Dateien: {files}, PMI-Rotationen -> Radiant: {tot}")
EOF
echo "=== Kontrolle SBS1044SLA: CS_EIN_L Rotation jetzt in Radiant? ==="
python3 -c "
import json
d=json.load(open('packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json'))
for h in d['handles']:
    for c in h.get('components',[]) or []:
        if c.get('_resourceClass')=='component_pmi' and c.get('id') in ('CS_EIN_L','CS_OUT_R_0','CS_OUT_R_270'):
            print(c['id'], c['properties']['rotation']['value'])
"

Convert asset PMI rotations to radians


Dateien: 162, PMI-Rotationen -> Radiant: 2700
=== Kontrolle SBS1044SLA: CS_EIN_L Rotation jetzt in Radiant? ===
CS_EIN_L [-0.0, -1.5707963267948966, 0.0]
CS_OUT_R_0 [-0.0, -1.5707963267948966, 0.0]
CS_OUT_R_270 [3.141592653589793, 3.673940397441252e-16, 3.141592653589793]


Radiant korrekt (`CS_EIN_L` = -1.5708 = -90°). Jetzt cpq umstellen — erst prüfen, ob Tests `eulerZyxDegToQuat` nutzen:


cd /Users/marcus.hinz/projects/adesso-cpq
grep -rn 'eulerZyxDegToQuat' packages/frontend/src packages/frontend/**/*.test.ts 2>/dev/null
echo "--- in Tests? ---"
grep -rln 'eulerZyxDegToQuat' packages/frontend --include='*.test.ts' 2>/dev/null

Check test usage of eulerZyxDegToQuat


packages/frontend/src/configurator/model/transformMath.ts:63:export function eulerZyxDegToQuat(deg: Vec3): Quat {
packages/frontend/src/configurator/model/GroupProcessor.test.ts:3:import { quatRotate, IDENTITY_QUAT, eulerZyxDegToQuat, type Quat } from "./transformMath";
packages/frontend/src/configurator/model/GroupProcessor.test.ts:213:    it("eulerZyxDegToQuat: 90° about Z equals the [w,x,y,z] Z90 quat", () => {
packages/frontend/src/configurator/model/GroupProcessor.test.ts:214:        const q = eulerZyxDegToQuat([0, 0, 90]);
packages/frontend/src/configurator/model/GroupProcessor.ts:9:    eulerZyxDegToQuat,
packages/frontend/src/configurator/model/GroupProcessor.ts:630:                return rot ? eulerZyxDegToQuat(rot as Vec3) : IDENTITY_QUAT;
packages/frontend/src/configurator/model/GroupProcessor.test.ts:3:import { quatRotate, IDENTITY_QUAT, eulerZyxDegToQuat, type Quat } from "./transformMath";
packages/frontend/src/configurator/model/GroupProcessor.test.ts:213:    it("eulerZyxDegToQuat: 90° about Z equals the [w,x,y,z] Z90 quat", () => {
packages/frontend/src/configurator/model/GroupProcessor.test.ts:214:        const q = eulerZyxDegToQuat([0, 0, 90]);
--- in Tests? ---
packages/frontend/src/configurator/model/GroupProcessor.test.ts


Radiant korrekt umgerechnet. Jetzt die cpq-Seite umstellen — `eulerZyxDegToQuat` → `eulerZyxRadToQuat` (die einzige Grad-Stelle):


Edit

// Euler angles in DEGREES → quaternion [w, x, y, z], applied in fire-visual's default ZYX order.
// This mirrors how fire-visual ingests rotations: a 3-component rotation property is read as an
// Euler with order ZYX (QuaternionType.deserialize), and degree-authored sources are converted with
// degreeToRad (FBX Lcl_Rotation). The PMI docking points carry their frame orientation this way —
// e.g. CS_EIN_VORNE = [90, 0, 90], while CS_LINKS/CS_RECHTS = [0, 0, 0] (→ identity).
export function eulerZyxDegToQuat(deg: Vec3): Quat {
    const r = Math.PI / 180;
    const x = deg[0] * r,
        y = deg[1] * r,
        z = deg[2] * r;

// Euler angles in RADIANS → quaternion [w, x, y, z], applied in fire-visual's default ZYX order.
// This mirrors how fire-visual ingests rotations: a 3-component rotation property is read as an
// Euler with order ZYX (QuaternionType.deserialize). The PMI docking points carry their frame
// orientation this way — e.g. CS_EIN_VORNE = [π/2, 0, π/2], while CS_LINKS/CS_RECHTS = [0, 0, 0].
export function eulerZyxRadToQuat(rad: Vec3): Quat {
    const x = rad[0],
        y = rad[1],
        z = rad[2];