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

import { describe, it, expect } from "vitest";
import { DefaultCommandProcessor } from "./DefaultCommandProcessor";
import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044SRA.json";

const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const selOf = (r: any[]) => (r.find((x: any) => x.cmd === "set_selection") as any)?.args[0] as string[] | undefined;
const click = (id: string) => ({ eventTypeName: "selection-click", target: { id }, selectionAction: "set" });

describe("scratch: leaf click on real asset", () => {
    it("selecting via a leaf handle resolves to the part root", () => {
        const proc = new DefaultCommandProcessor();
        proc.setInitialScene(base());
        const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: structuredClone(productData).handles }));

        const leaf = scene.handles.find((h: any) => (h.tags ?? []).includes("part-FUELLUNG_LINKS_OBEN"));
        const partRoot = scene.handles.find((h: any) => (h.tags ?? []).includes("role-part"));
        console.log("leaf:", leaf?.id, "| partRoot:", partRoot?.id);

        const sel = selOf(proc.handleEvent(click(leaf.id)));
        console.log("set_selection:", sel);
        expect(sel).toEqual([`#${partRoot.id}`]);
    });
});

-        const leaf = scene.handles.find((h: any) => (h.tags ?? []).includes("part-FUELLUNG_LINKS_OBEN"));

+        const leaf = scene.handles.find((h: any) => (h.tags ?? []).some((t: string) => t.startsWith("part-")));


Bash

pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/events/__scratch_selection.test.ts 2>&1 | tail -25

Re-run scratch leaf-click selection test


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

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  10:24:58
   Duration  539ms (transform 51ms, setup 0ms, import 131ms, tests 8ms, environment 305ms)


node -e "
const src=require('fs').readFileSync('packages/frontend/node_modules/@ad/fire/browser/index.js','utf8');
let re=/set_selection/g, m, c=0;
while((m=re.exec(src)) && c<5){ console.log('=== @'+m.index+' ==='); console.log(src.slice(m.index-500,m.index+500).replace(/\n/g,' ')); c++; console.log(); }
"

Find set_selection command implementation in fire


Fire3D.vue:175 🔎 fire-event: selection-click | target.id: h-8fb92e90-dbae-4133-967a-ddc74ea975c6 | target.selector: > handle#root:nth-child(7) > handle:nth-child(1) > handle#root.role-scene:nth-child…


Überprüfen, ob Seite aktualisiert werden muss


node -e "
const src=require('fs').readFileSync('packages/frontend/node_modules/@ad/fire/browser/index.js','utf8');
// SelectionManager class body
let i=src.indexOf('EntityLabel)(\"SelectionManager\")');
console.log(src.slice(i-3000,i).replace(/\n/g,' ').slice(-2900));
" 2>&1 | head -20

Read SelectionManager implementation


git show --stat cc927c5 297a73c | head -30; echo ===; git show 297a73c -- packages/frontend/src 2>/dev/null | head -80

Inspect the fire 1.0.6 update commits


Error: Exit code 1
commit cc927c558046b5c13eeb151065791b9de592d63d
Author: marcus.hinz <marcus.hinz@adesso.de>
Date:   Wed Jul 1 10:19:51 2026 +0200

    @ad/fire auf 1.0.6

 packages/frontend/package.json       |  2 +-
 packages/runtime/visual/package.json |  2 +-
 pnpm-lock.yaml                       | 14 +++++++-------
 3 files changed, 9 insertions(+), 9 deletions(-)

commit 297a73ceafc425cea28212d90097adf1e6cb814f
Author: marcus.hinz <marcus.hinz@adesso.de>
Date:   Wed Jul 1 11:26:54 2026 +0200

    update 1.06

 .../products/rollenfoerderer/rollenfoerderer.json  |  973 +++++++
 .../rollenfoerdererprofil_aussenradius.json        |    1 +
 .../products/schutzzaun_allround/SAD2083PLA.json   |    2 +-
 .../products/schutzzaun_allround/SAD2083PRA.json   |    2 +-
 .../products/schutzzaun_allround/SAD2083SLA.json   |    2 +-
 .../products/schutzzaun_allround/SAD2083SRA.json   |    2 +-
 .../products/schutzzaun_allround/SAE0131SNN.json   |    2 +-
 .../products/schutzzaun_allround/SAE0174PNN.json   |    2 +-
 .../products/schutzzaun_allround/SAE0174SNN.json   |    2 +-
 .../products/schutzzaun_allround/SAE0217PNN.json   |    2 +-
 .../products/schutzzaun_allround/SAE0217SNN.json   |    2 +-
 .../products/schutzzaun_allround/SAE0260PNN.json   |    2 +-
 .../products/schutzzaun_allround/SAE0260SNN.json   |    2 +-
(eval):1: == not found


grep -rn "createVisual3D" packages/frontend/src packages/runtime 2>/dev/null --include='*.ts' --include='*.vue' | grep -v test | head; echo ---; grep -rn "feature" packages/runtime/visual/src/*.ts 2>/dev/null | head -20; ls packages/runtime/visual/src 2>/dev/null

Locate createVisual3D and feature configuration


packages/frontend/src/components/visual/fire3d.types.ts:1:import type { createVisual3D, CameraTransformation } from "@ad-cpq/visual-runtime";
packages/frontend/src/components/visual/fire3d.types.ts:5:export type Visual3D = Awaited<ReturnType<typeof createVisual3D>>;
packages/frontend/src/components/visual/Fire3D.vue:36:import { createVisual3D } from "@ad-cpq/visual-runtime";
packages/frontend/src/components/visual/Fire3D.vue:86:type Visual3D = Awaited<ReturnType<typeof createVisual3D>>;
packages/frontend/src/components/visual/Fire3D.vue:171:    visual.value = await createVisual3D(root.value!, props.config);
packages/runtime/visual/src/visual-3d.ts:258:// into a separate chunk that is only fetched when createVisual3D() is first called,
packages/runtime/visual/src/visual-3d.ts:271:export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {
packages/runtime/visual/src/index.ts:1:export { createVisual3D } from "./visual-3d";
packages/runtime/visual/dist/index.d.ts:69:declare function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D>;
packages/runtime/visual/dist/index.d.ts:71:export { type CameraMode, type CameraTransformation, type Observable, type Visual3D, type Visual3DCamera, type Visual3DConfig, createVisual3D };
---
index.ts
visual-3d.ts


grep -n "Feature\|editor" packages/runtime/visual/src/visual-3d.ts | head -30

Show feature wiring in visual-3d.ts


(Bash completed with no output)


sed -n '255,330p' packages/runtime/visual/src/visual-3d.ts

Read createVisual3D implementation


// WHY lazy loading
// ----------------
// @ad/fire is large (~2 MB bundled). Loading it lazily means bundlers can split it
// into a separate chunk that is only fetched when createVisual3D() is first called,
// rather than blocking the initial page load.
let firePromise: Promise<FireModule> | undefined;

function loadFireOnce(): Promise<FireModule> {
    if (!firePromise) {
        firePromise = import("@ad/fire") as unknown as Promise<FireModule>;
    }
    return firePromise;
}

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

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

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

    vi.attachToContainer(container);

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

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

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

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

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

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

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

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


grep -rn "config" packages/frontend/src/components/visual/ProductConfigurator.stories.ts | head; echo ---; find packages/frontend/.storybook -name '*environment*' -o -name '*config*.json' | head; echo ---; grep -rln "features" packages/frontend/.storybook --include='*.json' | head -3

Find the Visual3D config used in the story


---
packages/frontend/.storybook/fire/environment.config.json
---
packages/frontend/.storybook/fire/environment.config.json


python3 -c "
import json
d=json.load(open('packages/frontend/.storybook/fire/environment.config.json'))
def walk(o,p=''):
    if isinstance(o,dict):
        for k,v in o.items():
            if k in ('features','feature') or 'feature' in str(k).lower(): print(p+k, json.dumps(v)[:400])
            walk(v,p+k+'.')
    elif isinstance(o,list):
        for i,v in enumerate(o): walk(v,p+str(i)+'.')
walk(d)
print(list(d.keys()))
"

Inspect environment.config.json feature list


settings.features [{"typeKey": "datamanager", "connectors": [{"method": "get", "responseType": "arraybuffer", "path": "https://rob-geko.hifassets.com/fire/assets/fbx/<%=path%>", "filter": {"path": ".+\\.fbx$", "typeKey": "(sceneGraph|geometry)"}}, {"method": "get", "responseType": "arraybuffer", "path": "https://rob-geko.hifassets.com/fire/assets/<%=path%>", "filter": {"path": ".+\\.(glb|gltf)$", "typeKey": "(scene
['moveMode', 'logLevel', 'initialScene', 'settings']


python3 -c "
import json
d=json.load(open('packages/frontend/.storybook/fire/environment.config.json'))
fs=d['settings']['features']
print([f.get('typeKey') for f in fs])
"

List feature typeKeys in environment config


['datamanager', 'renderContext', 'skybox', 'collision', 'editor', 'input', 'interaction', 'billboard', 'iconRenderer', 'pathfinding', 'ui', 'pmi', 'relations', 'textrenderer', 'animation']


node -e "
const src=require('fs').readFileSync('packages/frontend/node_modules/@ad/fire/browser/index.js','utf8');
// find 'get bounds' occurrences with context mentioning children or components
let re=/get bounds\(\)/g, m;
while((m=re.exec(src))){ const ctx=src.slice(m.index,m.index+500).replace(/\n/g,' '); if(ctx.includes('children')||ctx.includes('mapComponents')||ctx.includes('getComponents')){ console.log('=== @'+m.index+' ==='); console.log(ctx); console.log(); } }
" 2>&1 | head -25

Find Handle.bounds implementation in fire bundle


node -e "
const src=require('fs').readFileSync('packages/frontend/node_modules/@ad/fire/browser/index.js','utf8');
let re=/get bounds\(\)\{/g, m, c=0;
while((m=re.exec(src)) && c<8){ console.log('=== @'+m.index+' ==='); console.log(src.slice(m.index,m.index+350).replace(/\n/g,' ')); c++; console.log(); }
"

List all get bounds() implementations


=== @490922 ===
get bounds(){return this.localBounds.transform(this.LTW)}getTransformationProvider(){return this}setPosition(e){this.position=e}setRotation(e){this.rotation=e}onInitialize(){this.transform=this.handle.getComponent(o.TransformComponent),this._bind=l.Matrix4_4.compose(this._settings.bindTranslation??this._settings.translation,this._settings.bindRotat

=== @520557 ===
get bounds(){return this.geometry?.bounds?.transform(this.LTW)}isSelectionClickable(){return this.active&&!!this.geometry&&this.selectable}get selectable(){return this._settings.selectable}set selectable(e){e!==this._settings.selectable&&(this._settings.selectable=e,this.sync(s.selectable))}get id(){return this._settings.id}set id(e){this._settings

=== @870089 ===
get bounds(){return this._bounds||this.updateBounds(o.Bounds.createFromVertices(...this.vertices)),this._bounds}updateBounds(e){this._bounds=e,this.syncTarget.boundingSphere=new a.iyt(M.Vector3(e.center),e.extents.magnitude)}get type(){return this._settings.type}constructor(e={}){super(e)}onInitialize(){const e=new a.LoY;e.name=this.syncTargetName,

=== @875127 ===
get bounds(){return this._bounds||this.updateBounds(o.BufferGeometry.bounds(this._settings)),this._bounds}constructor(e){super(e),this.renderType=Be.mesh|Be.line}clear(){const e=this.settings;e.positions=[],e.normals&&(e.normals=[]),e.colors&&(e.colors=[]),e.materialIndices&&(e.materialIndices=[]),e.uvs&&(e.uvs=[]),e.uvs2&&(e.uvs2=[]),this.updateSe

=== @976255 ===
get bounds(){const e=this.collectBounds([this]);return e.length||e.push(o.Bounds.zero.transform(this.transform.LTW)),o.Bounds.getEnclosingBounds(...e)}collectBounds(e){const t=[];for(const n of e)n.iterateComponents?.(e=>{e.localBounds&&t.push(e.localBounds.transform(n.transform.LTW))}),n.descendants?.length>0&&t.push(...this.collectBounds(n.descen

=== @1015380 ===
get bounds(){return this._bounds||(this._bounds=this.descriptor.getBounds(this.shape,this.ltw)),this._bounds}get localBounds(){return this.shapeDescriptorManager.get(this.shape.shapeType).getLocalBounds(this.shape,this.ltw)}get geometry(){return[this.shapeDescriptorManager.get(this.shape.shapeType).createGeometry(this.shape),this.ltw]}get center(){

=== @1074224 ===
get bounds(){return this._bounds||(this._bounds=o.Bound.createFromVertices(this.vertices)),this._bounds}constructor(e,t,n){this.node=e,this.indices=t,this.context=n,this.index=aa.nextIndex(),this._directedEdges=[],this.createDirectedEdges(),this.identifier=`${this.node.index}-${t.join("|")}`}processContacts(e,t){const n=[],r={};try{const{lines:s,ho

=== @1580257 ===
get bounds(){return this._bounds}set bounds(e){this._bounds=e}get selectable(){return this._settings.selectable}set selectable(e){this._settings.selectable!==e&&(this._settings.selectable=e,this.sync(r.selectable))}isSelectionClickable(){return this.active&&this.selectable}getTransformationProvider(){return null}get startRefSelector(){return this._


grep -rn "playwright\|puppeteer" packages/frontend/package.json package.json 2>/dev/null; ls node_modules/.bin 2>/dev/null | grep -i "playwright\|chrom" | head

Check for browser automation tooling


git log --oneline --all --grep -i -E "select" 2>/dev/null | head; git log --oneline -20 -- packages/frontend/src/components/visual/Fire3D.vue | head; echo ---; git log -S "TEMP Analyse" --oneline -- packages/frontend/src/components/visual/Fire3D.vue | head -3

Find past selection-related commits


0633a88 git-sync: 2026-06-30 13:35
b961242 refactor
ce6f85c git-sync: 2026-06-29 16:38
f641410 Verbau: verbundene Zaunteile platzieren, bewegen, rotieren
1436730 git-sync: 2026-06-26 22:05
4b0e73f Produktbrowser: Einfuegen wieder per Klick (childClicked); Fire3D: JSON-Download bei Szenenwechsel entfernt
842b84b initial / eob
5aaf58b style: Prettier-Format über die gesamte Codebasis
d3559d6 refactor: Streamline 3D selection and enable pathfinding & relations
38fa2ec feat: Add "Platzieren" command to 3D context menu
---


git log -S "🔎" --oneline -- packages/frontend/src/components/visual/Fire3D.vue | head -3; git log --oneline --since=2026-06-28 -- packages/frontend/src | head -15; echo ===; git show b961242 --stat | head -20

Find when the TEMP debug log was added and recent src changes


Exit code 1
36ec21b eob
0633a88 git-sync: 2026-06-30 13:35
b961242 refactor
95bfb26 git-sync: 2026-06-29 20:16
5c8eaec git-sync: 2026-06-29 18:39
ce6f85c git-sync: 2026-06-29 16:38
(eval):1: == not found


git status --short -- packages/frontend/src | head -20; echo ---; git diff HEAD --stat -- packages/frontend/src | tail -15

Show uncommitted src changes


 M packages/frontend/src/components/building-blocks/ProductBrowser.stories.ts
 M packages/frontend/src/components/building-blocks/ProductBrowser.vue
 M packages/frontend/src/components/building-blocks/ProductBrowserPanel.stories.ts
 M packages/frontend/src/components/building-blocks/ProductSettingsPanel.stories.ts
 M packages/frontend/src/components/visual/Fire3D.vue
 M packages/frontend/src/components/visual/Fire3d.stories.ts
 M packages/frontend/src/components/visual/ProductConfigurator.stories.ts
 M packages/frontend/src/components/visual/ProductConfigurator.vue
 M packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts
 M packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
 M packages/frontend/src/configurator/model/GroupProcessor.test.ts
 M packages/frontend/src/configurator/model/GroupProcessor.ts
?? packages/frontend/src/configurator/events/__scratch_selection.test.ts
---
 .../building-blocks/ProductBrowser.stories.ts      |  4 +--
 .../components/building-blocks/ProductBrowser.vue  |  2 --
 .../building-blocks/ProductBrowserPanel.stories.ts | 41 ++++++++++++++++++++--
 .../ProductSettingsPanel.stories.ts                |  6 ++--
 packages/frontend/src/components/visual/Fire3D.vue | 12 +++++++
 .../src/components/visual/Fire3d.stories.ts        |  6 ++--
 .../visual/ProductConfigurator.stories.ts          |  4 +--
 .../src/components/visual/ProductConfigurator.vue  |  4 +--
 .../events/DefaultCommandProcessor.test.ts         |  2 +-
 .../configurator/events/DefaultCommandProcessor.ts | 12 +++----
 .../src/configurator/model/GroupProcessor.test.ts  |  2 +-
 .../src/configurator/model/GroupProcessor.ts       |  8 ++---
 12 files changed, 74 insertions(+), 29 deletions(-)


git diff HEAD -- packages/frontend/src/configurator/events/DefaultCommandProcessor.ts packages/frontend/src/configurator/model/GroupProcessor.ts packages/frontend/src/components/visual/ProductConfigurator.vue packages/frontend/src/components/building-blocks/ProductBrowser.vue

Show uncommitted logic changes in processors and configurator


diff --git a/packages/frontend/src/components/building-blocks/ProductBrowser.vue b/packages/frontend/src/components/building-blocks/ProductBrowser.vue
index a9891d8..beb981c 100644
--- a/packages/frontend/src/components/building-blocks/ProductBrowser.vue
+++ b/packages/frontend/src/components/building-blocks/ProductBrowser.vue
@@ -94,8 +94,6 @@ const categories = computed(() =>
         name: g.gruppe,
         image: GROUP_IMAGES[g.gruppe],
         children: g.kataloggruppen
-            // TODO (Paul): Schutzzaun Element — "Element" im schutzzaun_basic ist kaputt, vorerst rausgenommen, muss gefixt werden.
-            .filter((kg) => !(g.gruppe === "schutzzaun_basic" && kg.name === "Element"))
             .map((kg) => {
                 const arts = kg.artikelgruppen.flatMap((ag) => ag.artikel);
                 const def = arts.find((a) => a.filter?.fuellung === "polycarbonat") ?? arts.find((a) => a.initialartikel);
diff --git a/packages/frontend/src/components/visual/ProductConfigurator.vue b/packages/frontend/src/components/visual/ProductConfigurator.vue
index 3ba7f33..fb415cd 100644
--- a/packages/frontend/src/components/visual/ProductConfigurator.vue
+++ b/packages/frontend/src/components/visual/ProductConfigurator.vue
@@ -11,7 +11,7 @@ const props = defineProps<{
 const config = ref<Record<string, unknown> | null>(null);
 const fire = ref<InstanceType<typeof Fire3D> | null>(null);

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

 // Leerer Graph als Replay-Basis. Produkte kommen als add-product-Events obendrauf.
@@ -33,7 +33,7 @@ async function onProductSelected(node: { scenegraph: string }) {
 }

 onMounted(async () => {
-    config.value = await fetch("/assets/environment.config.json").then((r) => r.json());
+    config.value = await fetch("/fire/environment.config.json").then((r) => r.json());
 });
 </script>

diff --git a/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts b/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
index aff7625..a513cde 100644
--- a/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
+++ b/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
@@ -286,8 +286,8 @@ export class DefaultCommandProcessor extends CommandProcessor {
                         subject,
                         allowAllDragPoints: true,
                         dragPoints: [
-                            { selector: ".name-CS_LINKS", dropPoints: [`.name-CS_RECHTS:not(${subject} .name-CS_RECHTS)${openOnly}`] },
-                            { selector: ".name-CS_RECHTS", dropPoints: [`.name-CS_LINKS:not(${subject} .name-CS_LINKS)${openOnly}`] },
+                            { selector: ".name-CS_EIN_VORNE", dropPoints: [`.name-CS_EIN_HINTEN:not(${subject} .name-CS_EIN_HINTEN)${openOnly}`] },
+                            { selector: ".name-CS_EIN_HINTEN", dropPoints: [`.name-CS_EIN_VORNE:not(${subject} .name-CS_EIN_VORNE)${openOnly}`] },
                         ],
                     },
                 ],
@@ -408,15 +408,15 @@ export class DefaultCommandProcessor extends CommandProcessor {
                                             // targets (self-snap), plus every already-occupied point on
                                             // both sides so only open ends remain.
                                             {
-                                                selector: `.name-CS_LINKS${openOnly}`,
+                                                selector: `.name-CS_EIN_VORNE${openOnly}`,
                                                 dropPoints: [
-                                                    `.name-CS_RECHTS:not(${groupSelector} .name-CS_RECHTS)${openOnly}`,
+                                                    `.name-CS_EIN_HINTEN:not(${groupSelector} .name-CS_EIN_HINTEN)${openOnly}`,
                                                 ],
                                             },
                                             {
-                                                selector: `.name-CS_RECHTS${openOnly}`,
+                                                selector: `.name-CS_EIN_HINTEN${openOnly}`,
                                                 dropPoints: [
-                                                    `.name-CS_LINKS:not(${groupSelector} .name-CS_LINKS)${openOnly}`,
+                                                    `.name-CS_EIN_VORNE:not(${groupSelector} .name-CS_EIN_VORNE)${openOnly}`,
                                                 ],
                                             },
                                         ],
diff --git a/packages/frontend/src/configurator/model/GroupProcessor.ts b/packages/frontend/src/configurator/model/GroupProcessor.ts
index 15b4aeb..5581f28 100644
--- a/packages/frontend/src/configurator/model/GroupProcessor.ts
+++ b/packages/frontend/src/configurator/model/GroupProcessor.ts
@@ -108,8 +108,6 @@ export type GroupEvent =
     | GroupRegroupEvent;

 export class GroupProcessor {
-    // Record-time id allocation. Opaque, no semantics in ids (no product/instance prefix).
-    private idCounter = 0;

     // Last replayed state — record-time queries (which group owns a part, where its open ends are)
     // read this; replay() rebuilds it from the event log.
@@ -120,9 +118,11 @@ export class GroupProcessor {
     // Built each materialise: handle id → {parent handle id, role}. The role resolver reads this.
     private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();

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

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


ls /Users/marcus.hinz/projects/adesso-cpq/node_modules/.pnpm | grep -i "@ad" ; ls ~/Library/pnpm/store 2>/dev/null | head -3; pnpm store path 2>/dev/null

Check available @ad/fire versions in pnpm store


@ad+fire@1.0.5
@ad+fire@1.0.6
v10
v11
/Users/marcus.hinz/Library/pnpm/store/v11


grep -c "custom-selection" /Users/marcus.hinz/projects/adesso-cpq/node_modules/.pnpm/@ad+fire@1.0.5/node_modules/@ad/fire/browser/index.js; grep -c "custom-selection" /Users/marcus.hinz/projects/adesso-cpq/node_modules/.pnpm/@ad+fire@1.0.6/node_modules/@ad/fire/browser/index.js

Compare custom-selection support between fire 1.0.5 and 1.0.6


0
0


for v in 1.0.5 1.0.6; do echo "=== $v ==="; node -e "
const src=require('fs').readFileSync('/Users/marcus.hinz/projects/adesso-cpq/node_modules/.pnpm/@ad+fire@$v/node_modules/@ad/fire/browser/index.js','utf8');
const i=src.indexOf('extractSelectablesFromHits(e){');
console.log(src.slice(i,i+520).replace(/\n/g,' '));
"; done

Diff extractSelectablesFromHits between fire versions


=== 1.0.5 ===

=== 1.0.6 ===
extractSelectablesFromHits(e){if(!e)return[];return e.filter(e=>e.contacts&&e.contacts.length>0).map(e=>e.contacts[0].collider).filter(e=>e?.triggersSelection).map(e=>{if(!this.selectableManager)return e.handle;const t=this.selectableManager.getSelectable(e);return t||e.handle}).filter(e=>null!=e)}};(0,r.Cg)([(0,s.PropertyResolver)(h.InputFeature),(0,r.Sn)("design:type",h.InputFeature)],f.prototype,"inputFeature",void 0),(0,r.Cg)([(0,s.PropertyResolver)(i.CollisionFeature),(0,r.Sn)("design:type",i.CollisionFeature)


node -e "
const src=require('fs').readFileSync('/Users/marcus.hinz/projects/adesso-cpq/node_modules/.pnpm/@ad+fire@1.0.5/node_modules/@ad/fire/browser/index.js','utf8');
// find CursorSelector / selection-click in 1.0.5
let i=src.indexOf('selection-click');
console.log('selection-click @',i);
if(i>0) console.log(src.slice(i-800,i+900).replace(/\n/g,' '));
"

Find selection-click emission in fire 1.0.5


selection-click @ 3538202
urceMappingURL=modeOptionProvider.js.map ;// ../../threed/interaction/dist/src/options/cursorInteractionType.js var CursorInteractionType; (function (CursorInteractionType) {     CursorInteractionType[CursorInteractionType["none"] = 0] = "none";     CursorInteractionType[CursorInteractionType["default"] = 1] = "default"; })(CursorInteractionType || (CursorInteractionType = {})); //# sourceMappingURL=cursorInteractionType.js.map ;// ../../threed/interaction/dist/src/options/index.js   //# sourceMappingURL=index.js.map ;// ../../threed/interaction/dist/src/event/selectionClickEvent.js   class SelectionClickEvent extends InteractionEvent {     constructor(target) {         super();         this.target = target;     }     getRepresentation() {         return [{                 eventTypeName: 'selection-click',                 target: this.target ? (0,browser.selectorElementReferenceRepresentation)(this.target) : null             }];     } } //# sourceMappingURL=selectionClickEvent.js.map ;// ../../threed/interaction/dist/src/feature/cursorSelector.js         /**  * Cursor-based object selector that uses raycasting through collision system.  *  * The CursorSelector listens for mouse button presses and performs a raycast  * from the camera through the cursor position to determine what object (if any)  * is pointed at. It updates the SelectionManager with the clicked object or  * clears the selection if no object was hit.  *  * @export  * @class CursorSelector  * @extends {FeatureComponent}  */ let CursorSelector = class CursorSelector extends browser.FeatureComponent {     /**      * Creates an instance of CursorSelector.      * @param {Partial<FeatureComponent.Settings>} setti


node -e "
const src=require('fs').readFileSync('/Users/marcus.hinz/projects/adesso-cpq/node_modules/.pnpm/@ad+fire@1.0.5/node_modules/@ad/fire/browser/index.js','utf8');
const i=src.indexOf('cursorSelector.js');
const j=src.indexOf('sourceMappingURL=cursorSelector.js.map');
console.log(src.slice(i,j).slice(0,4500));
"

Read full CursorSelector source from fire 1.0.5


cursorSelector.js

/**
 * Cursor-based object selector that uses raycasting through collision system.
 *
 * The CursorSelector listens for mouse button presses and performs a raycast
 * from the camera through the cursor position to determine what object (if any)
 * is pointed at. It updates the SelectionManager with the clicked object or
 * clears the selection if no object was hit.
 *
 * @export
 * @class CursorSelector
 * @extends {FeatureComponent}
 */
let CursorSelector = class CursorSelector extends browser.FeatureComponent {
    /**
     * Creates an instance of CursorSelector.
     * @param {Partial<FeatureComponent.Settings>} settings
     * @memberof CursorSelector
     */
    constructor(settings) {
        super(settings);
    }
    onInstantiate() {
        // Get the primary render context for cursor selection
        this.renderContext = this.inputFeature.renderContext;
    }
    processInput(inputMap) {
        if (!this.renderContext) {
            return;
        }
        // Handle selection click (left click) - sets the selection
        if (inputMap.selectionClick && !inputMap.pointerIsDragging) {
            this.raycastAtCursor(inputMap.pointerNormalizedPosition).then((selectables) => {
                // Use only the first hit for selection
                this.dispatchEvent(new SelectionClickEvent(selectables?.[0]));
                if (this.settings.selection == CursorInteractionType.none) {
                    return;
                }
                this.selectionManager.selection = selectables.length > 0 ? [selectables[0]] : [];
            });
        }
        // Handle context click (right click)
        if (inputMap.contextClick && !inputMap.pointerIsDragging) {
            this.raycastAtCursor(inputMap.pointerNormalizedPosition).then((selectables) => {
                this.dispatchEvent(new ContextClickEvent({ pointedEntities: selectables }));
            });
        }
    }
    /**
     * Performs a raycast from the camera through the given normalized cursor position.
     *
     * @private
     * @param {Vector2} pointerNormalizedPosition - The normalized cursor position
     * @returns {Promise<Handle[]>} A promise that resolves to an array of all handles hit by the raycast.
     *                               Returns an empty array if no objects were hit.
     * @memberof CursorSelector
     */
    raycastAtCursor(pointerNormalizedPosition) {
        return new Promise((resolve) => {
            this.collisionFeature.castRay(this.renderContext.camera, pointerNormalizedPosition, (hits) => {
                const selectables = this.extractSelectablesFromHits(hits);
                resolve(selectables);
            });
        });
    }
    /**
     * Extracts Handle objects from collision hit results.
     *
     * @private
     * @param {any} hits - The collision hits from a raycast
     * @returns {Handle[]} An array of handles extracted from the hits
     * @memberof CursorSelector
     */
    extractSelectablesFromHits(hits) {
        if (!hits) {
            return [];
        }
        const colliders = hits
            .filter((hit) => hit.contacts && hit.contacts.length > 0)
            .map((hit) => hit.contacts[0].collider)
            .filter((collider) => collider !== undefined && collider !== null);
        return colliders.map((collider) => {
            if (!this.selectableManager) {
                return collider.handle;
            }
            const selectableForCollider = this.selectableManager.getSelectable(collider);
            if (!selectableForCollider) {
                return collider.handle;
            }
            return selectableForCollider;
        }).filter(selectable => selectable !== undefined && selectable !== null);
    }
};
(0,tslib_es6/* __decorate */.Cg)([
    (0,dist_browser.PropertyResolver)(src.InputFeature),
    (0,tslib_es6/* __metadata */.Sn)("design:type", src.InputFeature)
], CursorSelector.prototype, "inputFeature", void 0);
(0,tslib_es6/* __decorate */.Cg)([
    (0,dist_browser.PropertyResolver)(browser.CollisionFeature),
    (0,tslib_es6/* __metadata */.Sn)("design:type", browser.CollisionFeature)
], CursorSelector.prototype, "collisionFeature", void 0);
(0,tslib_es6/* __decorate */.Cg)([
    (0,dist_browser.PropertyResolver)(SelectionManager),
    (0,tslib_es6/* __metadata */.Sn)("design:type", SelectionManager)
], CursorSelector.prototype, "selectionManager", void 0);
(0,tslib_es6/* __decorate */.Cg)([
    (0,dist_bro


Remove scratch diagnostic test


Zwei Angaben brauche ich noch, dann ist es eingekreist:

1. Klapp in der Konsole beim zweiten Event (`selection`) das `raw` → `selected[0]` auf: welche ID/Selector steht da — der Part-Root (`h-26bc7f6d…`, `.role-part`) oder das geklickte Blatt (`h-8fb92e90…`)?
2. Was heißt „geht gar nicht" konkret: kein blauer Auswahlrahmen um das Teil? Oder Rahmen da, aber Kontextmenü/Verschieben tut nichts?

Stand der Analyse bis hierher:

- Dein Log zeigt: der Klick trifft sauber ein Szenen-Handle (`h-…`, `part-FUELLUNG_LINKS_OBEN`) — meine PMI-These von vorhin ist damit widerlegt (die `selectable:false`-Änderung an den Assets schadet aber nicht, DropPoints hängen nur am `type`).
- Den kompletten Processor-Weg habe ich mit dem echten Tür-Asset in einem Wegwerf-Test verifiziert: Klick auf ein Blatt-Handle liefert korrekt `set_selection ["#<part-root>"]`. Der Test ist wieder gelöscht.
- In fire kommt danach genau ein `selection`-Event mit einem Element an — fire hält also eine Selektion. Offen ist nur noch, ob das die richtige Entity ist und woran die sichtbare Rückmeldung scheitert.
- Nebenbefund: fire 1.0.6 filtert Klick-Treffer neu auf `collider.triggersSelection` (1.0.5 nahm jeden Collider) — falls Klicks auf manche Teile gar kein `selection-click` mit target erzeugen, liegt es daran.


schau mal bitte, ob ich die selektion vielleicht nur nicht sehe
(2o m hinter dem Bildschirm)


schau mal bitte, ob ich die selektion vielleicht nur nicht sehe
(2o m hinter dem Bildschirm)
Ich galube, das ist so platzieren läuft los

Sieht aber falsch aus

Sicher, dass die alten Assets eingebunden sind?


grep -n "staticDirs" -A 10 packages/frontend/.storybook/main.ts 2>/dev/null || cat packages/frontend/.storybook/main.ts | head -40; echo ---; grep -rn "catalog.json\|FetchAssetLoader\|DefaultProductService" packages/frontend/src/services/*.ts 2>/dev/null | head

Check storybook staticDirs and product service asset paths


7:    staticDirs: [
8-        { from: "./assets", to: "/assets" },
9-        // @ad/fire (webpack publicPath '/') loads its workers origin-absolute (/glbProcessor.worker.js),
10-        // so ./server-root (holding the worker files) is served at "/".
11-        { from: "./server-root", to: "/" },
12-        { from: "./fire", to: "/fire" },
13-        { from: "./products", to: "/products" },
14-        // UI/story fixtures (icons, theme template) — unrelated to the 3D assets.
15-        { from: "./app-resources", to: "/app-resources" },
16-    ],
17-    viteFinal: async (config) => {
---
packages/frontend/src/services/product-service.ts:50:export class FetchAssetLoader implements AssetLoader {
packages/frontend/src/services/product-service.ts:70:export class DefaultProductService implements ProductService {


sed -n '40,110p' packages/frontend/src/services/product-service.ts

Read product service loading paths


 */
export interface AssetLoader {
    resolve<T>(ref: AssetReference): Promise<T>;
}

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

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

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

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

export class DefaultProductService implements ProductService {
    private instanceCount = 0;

    constructor(private readonly loader: AssetLoader) {}

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

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

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


sed -n '1,60p' packages/frontend/src/components/visual/ProductConfigurator.vue; echo ---TEMPLATE---; sed -n '60,120p' packages/frontend/src/components/visual/ProductConfigurator.vue

Read ProductConfigurator component fully


<template>

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

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

</template>


schau mal bitte, ob ich die selektion vielleicht nur nicht sehe (2o m hinter dem Bildschirm) Ich galube, das ist so platzieren läuft los  Sieht aber falsch aus  Sicher, dass die alten Assets eingebund…


git diff HEAD -- packages/frontend/src/components/building-blocks/ProductBrowserPanel.stories.ts packages/frontend/src/components/visual/ProductConfigurator.stories.ts | head -80

Show uncommitted story wiring changes


diff --git a/packages/frontend/src/components/building-blocks/ProductBrowserPanel.stories.ts b/packages/frontend/src/components/building-blocks/ProductBrowserPanel.stories.ts
index 689a7f0..4a2fb3d 100644
--- a/packages/frontend/src/components/building-blocks/ProductBrowserPanel.stories.ts
+++ b/packages/frontend/src/components/building-blocks/ProductBrowserPanel.stories.ts
@@ -2,7 +2,42 @@ import type { Meta, StoryObj } from "@storybook/vue3";
 import { ref } from "vue";
 import ProductBrowserPanel from "./ProductBrowserPanel.vue";

-const catalog = await fetch("/assets/catalog/catalog.json").then((r) => r.json());
+const catalog = await fetch("/products/catalog.json").then((r) => r.json());
+
+// The Panel consumes a CatalogNode tree (name/image/children); build it from the raw catalog's `gruppen`.
+const GROUP_IMAGES: Record<string, string> = {
+    schutzzaun_basic: "safety_fence_basic.png",
+    schutzzaun_allround: "safety_fence_allround.png",
+    rollenfoerderer: "powered_roller_conveyor.png",
+};
+type Node = { name: string; type: string; image?: string; scenegraph?: string; children?: Node[] };
+const toTree = (cat: any): Node => ({
+    name: "Katalog",
+    type: "category",
+    children: (cat.gruppen ?? []).map((g: any) => ({
+        name: g.gruppe,
+        type: "category",
+        image: GROUP_IMAGES[g.gruppe],
+        children: (g.kataloggruppen ?? []).map((kg: any) => {
+            const arts = kg.artikelgruppen.flatMap((ag: any) => ag.artikel);
+            const rep =
+                arts.find((a: any) => a.filter?.fuellung === "polycarbonat") ??
+                arts.find((a: any) => a.initialartikel) ??
+                arts[0];
+            return {
+                name: kg.name,
+                type: "category",
+                image: rep?.assets?.bild,
+                children: arts.map((a: any) => ({
+                    name: a.artikelnummer,
+                    type: "product",
+                    image: a.assets?.bild,
+                    scenegraph: a.assets?.scenegraph,
+                })),
+            };
+        }),
+    })),
+});

 type StoryArgs = {
     data: unknown;
@@ -51,8 +86,8 @@ const meta: Meta<StoryArgs> = {
         themeItemHeight: { control: { type: "text" }, table: { category: "List" }, name: "Item Height" },
     },
     args: {
-        data: catalog,
-        imageBasePath: "/assets/catalog/",
+        data: toTree(catalog),
+        imageBasePath: "/products/",
         ...DEFAULTS,
     },
     render: (args: Record<string, unknown>) => ({
diff --git a/packages/frontend/src/components/visual/ProductConfigurator.stories.ts b/packages/frontend/src/components/visual/ProductConfigurator.stories.ts
index 7deaf53..2fbe327 100644
--- a/packages/frontend/src/components/visual/ProductConfigurator.stories.ts
+++ b/packages/frontend/src/components/visual/ProductConfigurator.stories.ts
@@ -19,7 +19,7 @@ export const Configurator: Story = {
         setup() {
             const catalog = ref<unknown>(null);
             onMounted(async () => {
-                catalog.value = await fetch("/fire/products/catalog.json").then((r) => r.json());
+                catalog.value = await fetch("/products/catalog.json").then((r) => r.json());
             });
             return { catalog };
         },
@@ -30,7 +30,7 @@ export const Configurator: Story = {
                         <ProductBrowser
                             v-if="catalog"
                             :data="catalog"
-                            image-base-path="/fire/products/"
+                            image-base-path="/products/"
                             @child-clicked="select"
                         />


python3 - <<'EOF'
import json, math

def euler_to_mat(rx,ry,rz):
    cx,sx,cy,sy,cz,sz=math.cos(rx),math.sin(rx),math.cos(ry),math.sin(ry),math.cos(rz),math.sin(rz)
    # XYZ order assumption
    Rx=[[1,0,0],[0,cx,-sx],[0,sx,cx]]; Ry=[[cy,0,sy],[0,1,0],[-sy,0,cy]]; Rz=[[cz,-sz,0],[sz,cz,0],[0,0,1]]
    def mul(A,B): return [[sum(A[i][k]*B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
    return mul(mul(Rz,Ry),Rx)

def apply(t,R,s,p):
    x=[p[0]*s[0],p[1]*s[1],p[2]*s[2]]
    return [R[0][0]*x[0]+R[0][1]*x[1]+R[0][2]*x[2]+t[0],
            R[1][0]*x[0]+R[1][1]*x[1]+R[1][2]*x[2]+t[1],
            R[2][0]*x[0]+R[2][1]*x[1]+R[2][2]*x[2]+t[2]]

f='packages/frontend/.storybook/products/schutzzaun_basic/SBE0174PNA.json'
d=json.load(open(f))
byid={}; bykey={}
for h in d['handles']:
    tr=next((c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform'),None)
    byid[h['id']]=(h,tr)
    if tr and tr.get('key'): bykey[tr['key']]=h['id']

def world(hid, p):
    h,tr=byid[hid]
    chainp=p
    while tr is not None:
        t=tr.get('translation',[0,0,0]); r=tr.get('rotation',[0,0,0]); s=tr.get('scale',[1,1,1])
        chainp=apply(t,euler_to_mat(*r),s,chainp)
        pk=tr.get('parentKey')
        if not pk or pk not in bykey: break
        h,tr=byid[bykey[pk]]
    return chainp

far=[]; near=0
for h in d['handles']:
    for c in h.get('components',[]):
        if c.get('_resourceClass')=='component_pmi':
            w=world(h['id'], c['properties']['position']['value'])
            if max(abs(v) for v in w)>3000: far.append((c.get('id'), [round(v,1) for v in w]))
            else: near+=1
print('PMIs im Teil-Bereich (<3m):', near)
print('PMIs weit draussen (>3m):', len(far))
for n,w in far[:12]: print('  ', n, w)
EOF

Compute world positions of all PMIs in a post asset


PMIs im Teil-Bereich (<3m): 139
PMIs weit draussen (>3m): 27
   CS_SVS [-20.0, 21670.0, -20.0]
   CS_EIN_QUER [0.0, -4674219.0, -0.0]
   CS_EIN_STEHER [0.0, -4674219.0, -0.0]
   CS_EIN_STUMMEL [-1.2, -4674219.0, -0.0]
   CS_SVS [114.0, 21670.0, -20.0]
   CS_EIN_QUER [134.0, -4674219.0, -0.0]
   CS_EIN_STEHER [134.0, -4674219.0, -0.0]
   CS_EIN_STUMMEL [132.8, -4674219.0, -0.0]
   CS_EIN_QUER [-7896.0, 2127.0, 0.0]
   CS_EIN_STEHER [-7896.0, 2127.0, 0.0]
   CS_EIN_STUMMEL [-7896.0, 2128.3, 0.0]
   CS_EIN_QUER [-7896.0, 146.0, 0.0]


Bestätigt — die Selektion funktioniert, du siehst sie nur nicht sinnvoll: Der blaue Auswahlrahmen umschließt die Bounds des Teils **inklusive aller PMI-Positionen**, und pro Teil liegen ~27 Roh-PMIs weit draußen: CS_SVS bei y = 21 670 mm (21,7 m) und CS_EIN_QUER/STEHER/STUMMEL sogar bei −4 674 m — der Export hat die Teillänge in `scale.z` der Handles eingebacken, dadurch werden die lokalen PMI-Positionen mitskaliert. Der Rahmen wird also ein kilometergroßes Gebilde, das „hinter dem Bildschirm" verschwindet. Dieselben Ausreißer erklären auch, warum Platzieren „falsch aussieht", falls solche Punkte als Snap-Ziele auftauchen.

Die 139 PMIs im Teilbereich (inkl. der Klemmen-VORNE/HINTEN fürs Andocken) sind in Ordnung.

Plan: Skript über alle Produkt-JSONs — pro Teil die Welt-Bounds der Collider rechnen, jede `component_pmi`, deren Weltposition mehr als 500 mm außerhalb liegt, entfernen. Die Andock-PMIs bleiben unberührt (liegen alle im Teil). Dateien ohne Collider (die zwei defekten basic-Artikel) lasse ich aus. Auf Go.


ok


python3 - <<'EOF'
import json, glob, math

def euler_to_mat(rx,ry,rz):
    cx,sx,cy,sy,cz,sz=math.cos(rx),math.sin(rx),math.cos(ry),math.sin(ry),math.cos(rz),math.sin(rz)
    Rx=[[1,0,0],[0,cx,-sx],[0,sx,cx]]; Ry=[[cy,0,sy],[0,1,0],[-sy,0,cy]]; Rz=[[cz,-sz,0],[sz,cz,0],[0,0,1]]
    def mul(A,B): return [[sum(A[i][k]*B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
    return mul(mul(Rz,Ry),Rx)

def apply(t,R,s,p):
    x=[p[0]*s[0],p[1]*s[1],p[2]*s[2]]
    return [R[i][0]*x[0]+R[i][1]*x[1]+R[i][2]*x[2]+t[i] for i in range(3)]

MARGIN=500.0
total_removed=0; report=[]
for f in sorted(glob.glob('packages/frontend/.storybook/products/**/*.json', recursive=True)):
    try:
        d=json.load(open(f,encoding='utf-8'))
    except Exception:
        continue
    if not isinstance(d,dict) or 'handles' not in d: continue
    byid={}; bykey={}
    for h in d['handles']:
        tr=next((c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform'),None)
        byid[h['id']]=(h,tr)
        if tr and tr.get('key'): bykey[tr['key']]=h['id']
    def world(hid,p):
        h,tr=byid[hid]; cp=list(p)
        seen=set()
        while tr is not None:
            cp=apply(tr.get('translation',[0,0,0]),euler_to_mat(*tr.get('rotation',[0,0,0])),tr.get('scale',[1,1,1]),cp)
            pk=tr.get('parentKey')
            if not pk or pk not in bykey or pk in seen: break
            seen.add(pk); h,tr=byid[bykey[pk]]
        return cp
    # Collider-Welt-Bounds
    lo=[float('inf')]*3; hi=[float('-inf')]*3; ncol=0
    for h in d['handles']:
        tr=next((c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform'),None)
        s=tr.get('scale',[1,1,1]) if tr else [1,1,1]
        for c in h.get('components',[]):
            if c.get('_resourceClass')=='component_collider':
                ncol+=1
                ctr=world(h['id'], c.get('center',[0,0,0]))
                ext=[abs(c.get('dimension',[0,0,0])[i])*abs(s[i]) for i in range(3)]
                m=max(ext) # Rotation unbekannt -> isotrop kugeln
                for i in range(3):
                    lo[i]=min(lo[i],ctr[i]-m); hi[i]=max(hi[i],ctr[i]+m)
    if ncol==0:
        report.append((f.split('/')[-1],'ohne Collider, uebersprungen')); continue
    removed=0
    for h in d['handles']:
        comps=h.get('components',[])
        keep=[]
        for c in comps:
            if c.get('_resourceClass')=='component_pmi':
                w=world(h['id'], c['properties']['position']['value'])
                if any(w[i]<lo[i]-MARGIN or w[i]>hi[i]+MARGIN for i in range(3)):
                    removed+=1; continue
            keep.append(c)
        h['components']=keep
    if removed:
        with open(f,'w',encoding='utf-8') as fh: json.dump(d,fh,ensure_ascii=False)
        total_removed+=removed
        report.append((f.split('/')[-1], f'{removed} PMIs entfernt'))
print('gesamt entfernt:', total_removed)
for n,r in report[:15]: print(' ',n,r)
print(' ...' if len(report)>15 else '')
print('betroffene Dateien:', sum(1 for _,r in report if 'entfernt' in r))
EOF

Remove PMIs far outside collider bounds in all product JSONs


gesamt entfernt: 4664
  SAD2083PLA.json 86 PMIs entfernt
  SAD2083PRA.json 86 PMIs entfernt
  SAD2083SLA.json 40 PMIs entfernt
  SAD2083SRA.json 40 PMIs entfernt
  SAE0131SNN.json 8 PMIs entfernt
  SAE0174PNN.json 27 PMIs entfernt
  SAE0174SNN.json 14 PMIs entfernt
  SAE0217PNN.json 27 PMIs entfernt
  SAE0217SNN.json 14 PMIs entfernt
  SAE0260PNN.json 27 PMIs entfernt
  SAE0260SNN.json 14 PMIs entfernt
  SAE0303PNN.json 37 PMIs entfernt
  SAE0303SNN.json 14 PMIs entfernt
  SAE0346PNN.json 37 PMIs entfernt
  SAE0346SNN.json 14 PMIs entfernt
 ...
betroffene Dateien: 159


python3 - <<'EOF'
import json, glob, os
bad=[]
for f in sorted(glob.glob('packages/frontend/.storybook/products/schutzzaun_basic/*.json')):
    try: d=json.load(open(f))
    except Exception: continue
    if 'handles' not in d: continue
    names=set(c.get('id') for h in d['handles'] for c in h.get('components',[]) if c.get('_resourceClass')=='component_pmi')
    base=os.path.basename(f)
    if base in ('SBE0131SNA.json','SBE1206PNA.json'): continue
    if not {'CS_EIN_VORNE','CS_EIN_HINTEN'}<=names: bad.append(base)
print('basic ohne VORNE/HINTEN nach Cleanup:', bad or 'keine')
d=json.load(open('packages/frontend/.storybook/products/schutzzaun_basic/SBE0174PNA.json'))
names=[c.get('id') for h in d['handles'] for c in h.get('components',[]) if c.get('_resourceClass')=='component_pmi']
print('SBE0174PNA PMIs:', len(names), '| top-level erhalten:', all(n in names for n in ['CS_EIN_L','CS_EIN_R','CS_OUT_L_0','CS_SELECT_L_0','CS_EIN_VORNE','CS_EIN_HINTEN']))
EOF

Verify docking PMIs survived the cleanup


basic ohne VORNE/HINTEN nach Cleanup: keine
SBE0174PNA PMIs: 139 | top-level erhalten: True


Erledigt. 4 664 Ausreißer-PMIs aus 159 Produkt-JSONs entfernt (alles, was mehr als 500 mm außerhalb der Collider-Welt-Bounds des Teils liegt — die Punkte bei 21 m bzw. −4 674 m). Verifiziert: alle Andock-PMIs (`CS_EIN_VORNE`/`CS_EIN_HINTEN` auf den Klemmen) und die aufbereiteten Root-PMIs (`CS_EIN_L/R`, `CS_OUT_*`, `CS_SELECT_*`) sind überall erhalten; die zwei defekten basic-Artikel und die Dateien ohne Collider blieben unangetastet.

Nach Hard-Reload sollte der Auswahlrahmen jetzt eng um das Teil liegen.


Sieht gar nicht schecht aus
aber der Kasten ist tief
Die tür hat eine Tiefe von vielleicht 10cm
der Kasten hat nach vorne ungefähr noch mal diesen abstand, nach hintent sieht es her aus, wie 1,5m


python3 - <<'EOF'
import json, math

def euler_to_mat(rx,ry,rz):
    cx,sx,cy,sy,cz,sz=math.cos(rx),math.sin(rx),math.cos(ry),math.sin(ry),math.cos(rz),math.sin(rz)
    Rx=[[1,0,0],[0,cx,-sx],[0,sx,cx]]; Ry=[[cy,0,sy],[0,1,0],[-sy,0,cy]]; Rz=[[cz,-sz,0],[sz,cz,0],[0,0,1]]
    def mul(A,B): return [[sum(A[i][k]*B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
    return mul(mul(Rz,Ry),Rx)

f='packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SRA.json'
d=json.load(open(f))
byid={}; bykey={}
for h in d['handles']:
    tr=next((c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform'),None)
    byid[h['id']]=(h,tr)
    if tr and tr.get('key'): bykey[tr['key']]=h['id']

def chain(hid):
    out=[]; h,tr=byid[hid]; seen=set()
    while tr is not None:
        out.append(tr)
        pk=tr.get('parentKey')
        if not pk or pk not in bykey or pk in seen: break
        seen.add(pk); h,tr=byid[bykey[pk]]
    return out

def world_point(hid,p):
    cp=list(p)
    for tr in chain(hid):
        R=euler_to_mat(*tr.get('rotation',[0,0,0])); s=tr.get('scale',[1,1,1]); t=tr.get('translation',[0,0,0])
        x=[cp[0]*s[0],cp[1]*s[1],cp[2]*s[2]]
        cp=[R[i][0]*x[0]+R[i][1]*x[1]+R[i][2]*x[2]+t[i] for i in range(3)]
    return cp

def world_aabb(hid,center,dim):
    # AABB-Transform: Zentrum + per-Achse |R|*ext
    ctr=list(center); ext=[abs(x)/1 for x in dim]
    # dimension scheint Halb-/Vollmass zu sein - konservativ als Extents nehmen
    for tr in chain(hid):
        R=euler_to_mat(*tr.get('rotation',[0,0,0])); s=tr.get('scale',[1,1,1]); t=tr.get('translation',[0,0,0])
        x=[ctr[0]*s[0],ctr[1]*s[1],ctr[2]*s[2]]
        ctr=[R[i][0]*x[0]+R[i][1]*x[1]+R[i][2]*x[2]+t[i] for i in range(3)]
        se=[abs(ext[0]*s[0]),abs(ext[1]*s[1]),abs(ext[2]*s[2])]
        ext=[sum(abs(R[i][j])*se[j] for j in range(3)) for i in range(3)]
    return [ctr[i]-ext[i] for i in range(3)],[ctr[i]+ext[i] for i in range(3)]

lo=[float('inf')]*3; hi=[float('-inf')]*3
for h in d['handles']:
    for c in h.get('components',[]):
        if c.get('_resourceClass')=='component_collider':
            l,hh=world_aabb(h['id'],c.get('center',[0,0,0]),c.get('dimension',[0,0,0]))
            for i in range(3): lo[i]=min(lo[i],l[i]); hi[i]=max(hi[i],hh[i])
print('Collider-AABB lo:',[round(v) for v in lo],'hi:',[round(v) for v in hi])
out=[]
for h in d['handles']:
    for c in h.get('components',[]):
        if c.get('_resourceClass')=='component_pmi':
            w=world_point(h['id'],c['properties']['position']['value'])
            over=[max(lo[i]-w[i],w[i]-hi[i],0) for i in range(3)]
            if max(over)>50: out.append((c.get('id'),[round(v) for v in w],round(max(over))))
out.sort(key=lambda x:-x[2])
print('PMIs ausserhalb (+50mm), Tuer SBS1044SRA:')
for n,w,o in out: print('  %-24s w=%s over=%s' % (n,w,o))
EOF

Analyze remaining out-of-bounds PMIs on the door asset


Collider-AABB lo: [-522, -1084, -191] hi: [1566, 3250, 111]
PMIs ausserhalb (+50mm), Tuer SBS1044SRA:
  CS_SELECT_R_0            w=[-20, 1100, -1424] over=1233
  CS_OUT_R_0               w=[-20, 1100, -1154] over=963
  CS_OUT_R_90_B            w=[-20, 1100, -1154] over=963
  CS_OUT_R_270_B           w=[-20, 1100, -1154] over=963
  CS_EIN_START_R           w=[-20, 0, -1124] over=933
  CS_SELECT_R_90           w=[-340, 1100, -1124] over=933
  CS_SELECT_R_270          w=[300, 1100, -1124] over=933
  CS_EIN_R                 w=[-20, 1100, -1104] over=913
  CS_OUT_R_90              w=[-110, 1100, -1104] over=913
  CS_OUT_R_270             w=[30, 1100, -1104] over=913
  CS_SELECT_L_0            w=[-20, 1100, 300] over=189


python3 - <<'EOF'
import json, math

def euler_to_mat(rx,ry,rz):
    cx,sx,cy,sy,cz,sz=math.cos(rx),math.sin(rx),math.cos(ry),math.sin(ry),math.cos(rz),math.sin(rz)
    Rx=[[1,0,0],[0,cx,-sx],[0,sx,cx]]; Ry=[[cy,0,sy],[0,1,0],[-sy,0,cy]]; Rz=[[cz,-sz,0],[sz,cz,0],[0,0,1]]
    def mul(A,B): return [[sum(A[i][k]*B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
    return mul(mul(Rz,Ry),Rx)

def aabb_of(f):
    d=json.load(open(f))
    byid={}; bykey={}
    for h in d['handles']:
        tr=next((c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform'),None)
        byid[h['id']]=(h,tr)
        if tr and tr.get('key'): bykey[tr['key']]=h['id']
    def chain(hid):
        out=[]; h,tr=byid[hid]; seen=set()
        while tr is not None:
            out.append(tr); pk=tr.get('parentKey')
            if not pk or pk not in bykey or pk in seen: break
            seen.add(pk); h,tr=byid[bykey[pk]]
        return out
    lo=[1e18]*3; hi=[-1e18]*3
    for h in d['handles']:
        for c in h.get('components',[]):
            if c.get('_resourceClass')=='component_collider':
                ctr=list(c.get('center',[0,0,0])); ext=[abs(x) for x in c.get('dimension',[0,0,0])]
                for tr in chain(h['id']):
                    R=euler_to_mat(*tr.get('rotation',[0,0,0])); s=tr.get('scale',[1,1,1]); t=tr.get('translation',[0,0,0])
                    x=[ctr[i]*s[i] for i in range(3)]
                    ctr=[R[i][0]*x[0]+R[i][1]*x[1]+R[i][2]*x[2]+t[i] for i in range(3)]
                    se=[abs(ext[i]*s[i]) for i in range(3)]
                    ext=[sum(abs(R[i][j])*se[j] for j in range(3)) for i in range(3)]
                for i in range(3): lo[i]=min(lo[i],ctr[i]-ext[i]); hi[i]=max(hi[i],ctr[i]+ext[i])
    return [round(v) for v in lo],[round(v) for v in hi]

for f in ['SBE0174SNA','SBE0174PNA','SBS1044SRA','SBS1044SLA','SBD2083SRA','SBF0872SRA']:
    lo,hi=aabb_of('packages/frontend/.storybook/products/schutzzaun_basic/%s.json'%f)
    print('%-12s x:%6s..%-6s y:%6s..%-6s z:%6s..%-6s'%(f,lo[0],hi[0],lo[1],hi[1],lo[2],hi[2]))
EOF

Compare collider AABBs of element vs doors


SBE0174SNA   x:   -70..244    y: -1084..3251   z:  -151..50
SBE0174PNA   x:   -60..244    y: -1084..3251   z:  -151..50
SBS1044SRA   x:  -522..1566   y: -1084..3250   z:  -191..111
SBS1044SLA   x:  -522..1566   y: -1084..3250   z:  -191..111
SBD2083SRA   x: -1042..3125   y: -1084..3250   z:  -191..111
SBF0872SRA   x:  -975..2925   y: -1084..3251   z:  -196..111


Sieht gar nicht schecht aus aber der Kasten ist tief Die tür hat eine Tiefe von vielleicht 10cm der Kasten hat nach vorne ungefähr noch mal diesen abstand, nach hintent sieht es her aus, wie 1,5m


python3 - <<'EOF'
import json
for f in ['SBE0174SNA','SBS1044SRA','SBD2083SRA','SBF0872SRA']:
    d=json.load(open('packages/frontend/.storybook/products/schutzzaun_basic/%s.json'%f))
    for h in d['handles']:
        tr=next((c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform'),None)
        if tr and tr.get('key') in ('root','asm_root'):
            print('%-12s %-8s t=%s r=%s s=%s' % (f,tr.get('key'),tr.get('translation'),tr.get('rotation'),tr.get('scale')))
EOF

Compare root/asm_root transforms element vs doors


SBE0174SNA   root     t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1]
SBE0174SNA   asm_root t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1]
SBS1044SRA   root     t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1]
SBS1044SRA   asm_root t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1]
SBD2083SRA   root     t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1]
SBD2083SRA   asm_root t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1]
SBF0872SRA   root     t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1]
SBF0872SRA   asm_root t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1]


python3 - <<'EOF'
import json, math
# Wo landen die Klemmen-PMIs (CS_EIN_VORNE) der Tuer heute? (Welt, Part-Frame)
def euler_to_mat(rx,ry,rz):
    cx,sx,cy,sy,cz,sz=math.cos(rx),math.sin(rx),math.cos(ry),math.sin(ry),math.cos(rz),math.sin(rz)
    Rx=[[1,0,0],[0,cx,-sx],[0,sx,cx]]; Ry=[[cy,0,sy],[0,1,0],[-sy,0,cy]]; Rz=[[cz,-sz,0],[sz,cz,0],[0,0,1]]
    def mul(A,B): return [[sum(A[i][k]*B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
    return mul(mul(Rz,Ry),Rx)
f='packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SRA.json'
d=json.load(open(f))
byid={}; bykey={}
for h in d['handles']:
    tr=next((c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform'),None)
    byid[h['id']]=(h,tr)
    if tr and tr.get('key'): bykey[tr['key']]=h['id']
def world(hid,p):
    cp=list(p); h,tr=byid[hid]; seen=set()
    while tr is not None:
        R=euler_to_mat(*tr.get('rotation',[0,0,0])); s=tr.get('scale',[1,1,1]); t=tr.get('translation',[0,0,0])
        x=[cp[i]*s[i] for i in range(3)]
        cp=[R[i][0]*x[0]+R[i][1]*x[1]+R[i][2]*x[2]+t[i] for i in range(3)]
        pk=tr.get('parentKey')
        if not pk or pk not in bykey or pk in seen: break
        seen.add(pk); h,tr=byid[bykey[pk]]
    return [round(v,1) for v in cp]
for h in d['handles']:
    for c in h.get('components',[]):
        if c.get('_resourceClass')=='component_pmi' and c.get('id') in ('CS_EIN_VORNE','CS_EIN_HINTEN'):
            print(c.get('id'), 'welt=', world(h['id'], c['properties']['position']['value']))
EOF

Compute clamp PMI world positions on the door


CS_EIN_HINTEN welt= [-20.0, 0.0, -60.0]
CS_EIN_VORNE welt= [-20.0, 0.0, -60.0]
CS_EIN_HINTEN welt= [-20.0, 0.0, -20.0]
CS_EIN_VORNE welt= [-20.0, 0.0, -20.0]
CS_EIN_HINTEN welt= [1064.0, 0.0, -60.0]
CS_EIN_VORNE welt= [1064.0, 0.0, -60.0]
CS_EIN_HINTEN welt= [1064.0, 0.0, -20.0]
CS_EIN_VORNE welt= [1064.0, 0.0, -20.0]


grep -rn "CS_EIN_L\b\|CS_EIN_R\b\|CS_OUT_\|CS_SELECT_\|CS_EIN_START" packages --include='*.ts' --include='*.vue' --include='*.js' -l 2>/dev/null | grep -v node_modules | grep -v .storybook | head; echo '(leer = nichts referenziert sie)'

Confirm no code references the root chain PMIs


(leer = nichts referenziert sie)


Diagnose steht. Die Klemmen-PMIs (`CS_EIN_VORNE`/`HINTEN`) der Tür sitzen korrekt an den Enden entlang der X-Achse (x = −20 und 1064; die Tür läuft wie alle Teile entlang +x). Die 1,5 m nach hinten kommen von den Root-PMIs `CS_EIN_L/R`, `CS_EIN_START_*`, `CS_OUT_*`, `CS_SELECT_*` — die sind in einer −z-Konvention verortet (z = −1104 … −1424 bei der 1044er-Tür) und zeigen damit quer zur tatsächlichen Geometrie ins Leere. Der Auswahlrahmen umschließt sie mit.

Kein Code in adesso-cpq referenziert diese Root-PMIs (geprüft) — das Andocken läuft ausschließlich über die Klemmen-VORNE/HINTEN. Sie sind also reiner Bounds-Ballast, bei Tür wie Elementen (dort ragen sie z. B. 300 mm seitlich raus).

Plan: alle `component_pmi` mit den Namen `CS_EIN_L`, `CS_EIN_R`, `CS_EIN_START_*`, `CS_OUT_*`, `CS_SELECT_*` aus den Produkt-JSONs entfernen. Auf Go.