/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:289:                            { selector: ".name-CS_EIN_VORNE", dropPoints: [`.name-CS_EIN_HINTEN:not(${subject} .name-CS_EIN_HINTEN)${openOnly}`] },
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:290:                            { selector: ".name-CS_EIN_HINTEN", dropPoints: [`.name-CS_EIN_VORNE:not(${subject} .name-CS_EIN_VORNE)${openOnly}`] },
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:411:                                                selector: `.name-CS_EIN_VORNE${openOnly}`,
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:413:                                                    `.name-CS_EIN_HINTEN:not(${groupSelector} .name-CS_EIN_HINTEN)${openOnly}`,
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:417:                                                selector: `.name-CS_EIN_HINTEN${openOnly}`,
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:419:                                                    `.name-CS_EIN_VORNE:not(${groupSelector} .name-CS_EIN_VORNE)${openOnly}`,
---
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:20:    anchorPmiId: "CS_RECHTS",
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:21:    joinPmiId: "CS_LINKS",
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:111:        expect(occ).toContainEqual({ element: a, pmi: "CS_RECHTS" });
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:112:        expect(occ).toContainEqual({ element: b, pmi: "CS_LINKS" });
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:113:        expect(occ).not.toContainEqual({ element: a, pmi: "CS_LINKS" });
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:114:        expect(occ).not.toContainEqual({ element: b, pmi: "CS_RECHTS" });
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:192:            anchorPmiId: "CS_LINKS",
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:193:            joinPmiId: "CS_RECHTS",
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts:226:        const r = gp.dockRotation(e1.rootHandleId, "CS_RECHTS", e2.rootHandleId, "CS_LINKS");
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts:45:    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, b), { drag: b, dragPmi: "CS_LINKS", drop: a, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));


Bash

python3 - <<'EOF'
import json, glob, os
for d in ['packages/frontend/.storybook/products/schutzzaun_basic','packages/frontend/.storybook/products/schutzzaun_allround']:
    print('==',d,'==')
    for f in sorted(glob.glob(d+'/*.json')):
        try: data=json.load(open(f))
        except Exception as e: print(os.path.basename(f),'PARSE ERROR',e); continue
        types={}; names=set()
        for h in data.get('handles',[]):
            for c in h.get('components',[]):
                if c.get('_resourceClass')=='component_pmi':
                    types[c.get('type')]=types.get(c.get('type'),0)+1
                    names.add(c.get('id'))
        vh='VORNE+HINTEN' if {'CS_EIN_VORNE','CS_EIN_HINTEN'}<=names else ('VORNE' if 'CS_EIN_VORNE' in names else ('HINTEN' if 'CS_EIN_HINTEN' in names else '-'))
        print(f"{os.path.basename(f):28} pmi_types={types} {vh}")
EOF

Scan all Zaun product JSONs for PMI types and VORNE/HINTEN presence


== packages/frontend/.storybook/products/schutzzaun_basic ==
SBD2083PLA.json              pmi_types={'point': 367} VORNE+HINTEN
SBD2083PRA.json              pmi_types={'point': 367} VORNE+HINTEN
SBD2083SLA.json              pmi_types={'point': 587} VORNE+HINTEN
SBD2083SRA.json              pmi_types={'point': 587} VORNE+HINTEN
SBE0131SNA.json              pmi_types={'point': 24} -
SBE0174PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0174SNA.json              pmi_types={'point': 216} VORNE+HINTEN
SBE0217PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0217SNA.json              pmi_types={'point': 219} VORNE+HINTEN
SBE0260PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0260SNA.json              pmi_types={'point': 222} VORNE+HINTEN
SBE0303PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0303SNA.json              pmi_types={'point': 225} VORNE+HINTEN
SBE0346PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0346SNA.json              pmi_types={'point': 228} VORNE+HINTEN
SBE0389PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0389SNA.json              pmi_types={'point': 231} VORNE+HINTEN
SBE0432PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0432SNA.json              pmi_types={'point': 234} VORNE+HINTEN
SBE0475PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0475SNA.json              pmi_types={'point': 237} VORNE+HINTEN
SBE0518PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0518SNA.json              pmi_types={'point': 240} VORNE+HINTEN
SBE0561PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0561SNA.json              pmi_types={'point': 243} VORNE+HINTEN
SBE0604PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0604SNA.json              pmi_types={'point': 246} VORNE+HINTEN
SBE0647PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0647SNA.json              pmi_types={'point': 249} VORNE+HINTEN
SBE0690PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0690SNA.json              pmi_types={'point': 252} VORNE+HINTEN
SBE0733PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0733SNA.json              pmi_types={'point': 255} VORNE+HINTEN
SBE0776PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0776SNA.json              pmi_types={'point': 258} VORNE+HINTEN
SBE0819PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0819SNA.json              pmi_types={'point': 261} VORNE+HINTEN
SBE0862PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0862SNA.json              pmi_types={'point': 264} VORNE+HINTEN
SBE0905PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0905SNA.json              pmi_types={'point': 267} VORNE+HINTEN
SBE0948PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0948SNA.json              pmi_types={'point': 270} VORNE+HINTEN
SBE0991PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE0991SNA.json              pmi_types={'point': 273} VORNE+HINTEN
SBE1034PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1034SNA.json              pmi_types={'point': 276} VORNE+HINTEN
SBE1077PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1077SNA.json              pmi_types={'point': 279} VORNE+HINTEN
SBE1120PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1120SNA.json              pmi_types={'point': 282} VORNE+HINTEN
SBE1163PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1163SNA.json              pmi_types={'point': 285} VORNE+HINTEN
SBE1206PNA.json              pmi_types={'point': 24} -
SBE1206SNA.json              pmi_types={'point': 288} VORNE+HINTEN
SBE1249PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1249SNA.json              pmi_types={'point': 291} VORNE+HINTEN
SBE1292PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1292SNA.json              pmi_types={'point': 294} VORNE+HINTEN
SBE1335PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1335SNA.json              pmi_types={'point': 297} VORNE+HINTEN
SBE1378PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1378SNA.json              pmi_types={'point': 300} VORNE+HINTEN
SBE1421PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1421SNA.json              pmi_types={'point': 303} VORNE+HINTEN
SBE1464PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1464SNA.json              pmi_types={'point': 306} VORNE+HINTEN
SBE1507PNA.json              pmi_types={'point': 166} VORNE+HINTEN
SBE1507SNA.json              pmi_types={'point': 309} VORNE+HINTEN
SBF0872PLA.json              pmi_types={'point': 427} VORNE+HINTEN
SBF0872PRA.json              pmi_types={'point': 427} VORNE+HINTEN
SBF0872SLA.json              pmi_types={'point': 641} VORNE+HINTEN
SBF0872SRA.json              pmi_types={'point': 641} VORNE+HINTEN
SBS1044PLA.json              pmi_types={'point': 230} VORNE+HINTEN
SBS1044PRA.json              pmi_types={'point': 230} VORNE+HINTEN
SBS1044SLA.json              pmi_types={'point': 340} VORNE+HINTEN
SBS1044SRA.json              pmi_types={'point': 340} VORNE+HINTEN
schutzzaun_basic.json PARSE ERROR 'utf-8' codec can't decode byte 0xc9 in position 16667: invalid continuation byte
== packages/frontend/.storybook/products/schutzzaun_allround ==
SAD2083PLA.json              pmi_types={'point': 380} VORNE+HINTEN
SAD2083PRA.json              pmi_types={'point': 380} VORNE+HINTEN
SAD2083SLA.json              pmi_types={'point': 600} VORNE+HINTEN
SAD2083SRA.json              pmi_types={'point': 600} VORNE+HINTEN
SAE0131SNN.json              pmi_types={'point': 199} VORNE+HINTEN
SAE0174PNN.json              pmi_types={'point': 119} -
SAE0174SNN.json              pmi_types={'point': 169} -
SAE0217PNN.json              pmi_types={'point': 119} -
SAE0217SNN.json              pmi_types={'point': 172} -
SAE0260PNN.json              pmi_types={'point': 119} -
SAE0260SNN.json              pmi_types={'point': 175} -
SAE0303PNN.json              pmi_types={'point': 119} -
SAE0303SNN.json              pmi_types={'point': 178} -
SAE0346PNN.json              pmi_types={'point': 119} -
SAE0346SNN.json              pmi_types={'point': 181} -
SAE0389PNN.json              pmi_types={'point': 119} -
SAE0389SNN.json              pmi_types={'point': 184} -
SAE0432PNN.json              pmi_types={'point': 119} -
SAE0432SNN.json              pmi_types={'point': 187} -
SAE0475PNN.json              pmi_types={'point': 119} -
SAE0475SNN.json              pmi_types={'point': 190} -
SAE0518PNN.json              pmi_types={'point': 119} -
SAE0518SNN.json              pmi_types={'point': 193} -
SAE0561PNN.json              pmi_types={'point': 119} -
SAE0561SNN.json              pmi_types={'point': 196} -
SAE0604PNN.json              pmi_types={'point': 119} -
SAE0604SNN.json              pmi_types={'point': 199} -
SAE0647PNN.json              pmi_types={'point': 119} -
SAE0647SNN.json              pmi_types={'point': 202} -
SAE0690PNN.json              pmi_types={'point': 119} -
SAE0690SNN.json              pmi_types={'point': 205} -
SAE0733PNN.json              pmi_types={'point': 119} -
SAE0733SNN.json              pmi_types={'point': 208} -
SAE0776PNN.json              pmi_types={'point': 119} -
SAE0776SNN.json              pmi_types={'point': 211} -
SAE0819PNN.json              pmi_types={'point': 119} -
SAE0819SNN.json              pmi_types={'point': 214} -
SAE0862PNN.json              pmi_types={'point': 119} -
SAE0862SNN.json              pmi_types={'point': 217} -
SAE0905PNN.json              pmi_types={'point': 119} -
SAE0905SNN.json              pmi_types={'point': 220} -
SAE0948PNN.json              pmi_types={'point': 119} -
SAE0948SNN.json              pmi_types={'point': 223} -
SAE0991PNN.json              pmi_types={'point': 119} -
SAE0991SNN.json              pmi_types={'point': 226} -
SAE1034PNN.json              pmi_types={'point': 119} -
SAE1034SNN.json              pmi_types={'point': 229} -
SAE1077PNN.json              pmi_types={'point': 119} -
SAE1077SNN.json              pmi_types={'point': 232} -
SAE1120PNN.json              pmi_types={'point': 119} -
SAE1120SNN.json              pmi_types={'point': 235} -
SAE1163PNN.json              pmi_types={'point': 119} -
SAE1163SNN.json              pmi_types={'point': 238} -
SAE1206PNN.json              pmi_types={'point': 119} -
SAE1206SNN.json              pmi_types={'point': 241} -
SAE1249PNN.json              pmi_types={'point': 119} -
SAE1249SNN.json              pmi_types={'point': 244} -
SAE1292PNN.json              pmi_types={'point': 119} -
SAE1292SNN.json              pmi_types={'point': 247} -
SAE1335PNN.json              pmi_types={'point': 119} -
SAE1335SNN.json              pmi_types={'point': 250} -
SAE1378PNN.json              pmi_types={'point': 119} -
SAE1378SNN.json              pmi_types={'point': 253} -
SAE1421PNN.json              pmi_types={'point': 119} -
SAE1421SNN.json              pmi_types={'point': 256} -
SAE1464PNN.json              pmi_types={'point': 119} -
SAE1464SNN.json              pmi_types={'point': 259} -
SAE1507PNN.json              pmi_types={'point': 119} -
SAE1507SNN.json              pmi_types={'point': 262} -
SAF0872PLA.json              pmi_types={'point': 438} VORNE+HINTEN
SAF0872PRA.json              pmi_types={'point': 438} VORNE+HINTEN
SAF0872SLA.json              pmi_types={'point': 652} VORNE+HINTEN
SAF0872SRA.json              pmi_types={'point': 652} VORNE+HINTEN
SAP4180ANA.json              pmi_types={'point': 47} VORNE+HINTEN
SAP4180BNA.json              pmi_types={'point': 37} VORNE+HINTEN
SAP4180CNA.json              pmi_types={'point': 37} VORNE+HINTEN
SAP4180DNA.json              pmi_types={'point': 47} VORNE+HINTEN
SAP4180ENA.json              pmi_types={'point': 47} VORNE+HINTEN
SAP4180FNA.json              pmi_types={'point': 57} VORNE+HINTEN
SAP4180GNA.json              pmi_types={'point': 67} VORNE+HINTEN
SAS1044PLA.json              pmi_types={'point': 243} VORNE+HINTEN
SAS1044PRA.json              pmi_types={'point': 353} VORNE+HINTEN
SAS1044SLA.json              pmi_types={'point': 353} VORNE+HINTEN
SAS1044SRA.json              pmi_types={'point': 353} VORNE+HINTEN
schutzzaun_allround.json PARSE ERROR 'utf-8' codec can't decode byte 0xc9 in position 17439: invalid continuation byte


Read placement command construction in DefaultCommandProcessor


        // The subject is always a top-level group node (an element to re-place is detached into its
        // own group first — see handleDetachElement — so the engine never gets a nested subject).
        const movingGroupId = this.resolveGroupId(data.handleId);
        if (!movingGroupId) return [stop];
        const placement = this.placementEvent(data, movingGroupId);
        if (placement) this.eventStore.push(placement);

        const scene = this.replay();
        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
    }

    // Pull a part out of its group into its own top-level group, then start placement on that group's
    // node (a valid top-level subject). The freed neighbour points are open again post-detach.
    private handleDetachElement(event: any): UIReaction[] {
        this.replay();
        const detach = this.groups.buildDetachEvent(event.elementId);
        if (!detach) return [];
        this.eventStore.push(detach);
        const scene = this.replay();

        const groupId = detach.newGroups.find((ng) => ng.members.includes(event.elementId))?.groupId;
        const groupKey = groupId ? this.groups.groupKeyOf(groupId) : null;
        const setScene: UIReaction = { type: "sendCommand", cmd: "setScene", args: [scene] };
        if (!groupKey) return [setScene];

        const subject = "#" + groupKey;
        const openOnly = this.openEndExclusions();
        return [
            setScene,
            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
            {
                type: "sendCommand",
                cmd: "start_placement_mode",
                args: [
                    {
                        subject,
                        allowAllDragPoints: true,
                        dragPoints: [
                            { 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}`] },
                        ],
                    },
                ],
            },
        ];
    }

    // The merge (on snap) or move (free) event for a moving group.
    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {
        if (data.dropHandleId) {
            const anchorRoot = this.groups.elementRootOf(data.dropHandleId);
            const joinRoot = data.dragHandleId ? this.groups.elementRootOf(data.dragHandleId) : null;
            const targetGroupId = anchorRoot ? this.groups.groupIdOfElement(anchorRoot) : null;
            if (anchorRoot && joinRoot && targetGroupId && targetGroupId !== movingGroupId && data.dropPmiId && data.dragPmiId) {
                // The snapped points fix the join: the dragged point is placed onto the drop point.
                // Direction (any axis) follows from the point positions; the relative rotation (e.g. a
                // 90° branch onto a post) follows from the points' frame orientations.
                return {
                    type: "group-merge",
                    timestamp: Date.now(),
                    targetGroupId,
                    movingGroupId,
                    anchorRootId: anchorRoot,
                    joinRootId: joinRoot,
                    anchorPmiId: data.dropPmiId,
                    joinPmiId: data.dragPmiId,
                    rot: this.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),
                };
            }
            return null;
        }
        // No snap: free reposition of the group to the drop position.
        return {
            type: "group-move",
            timestamp: Date.now(),
            groupId: movingGroupId,
            position: data.position,
            rotation: this.groups.transformOf(movingGroupId)?.rot ?? IDENTITY_QUAT,
        };
    }


python3 - <<'EOF'
import json
for f in ['packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json',
          'packages/frontend/.storybook/products/schutzzaun_basic/SBE0174PNA.json']:
    data=json.load(open(f))
    print('==',f.split('/')[-1],'==')
    for h in data.get('handles',[]):
        for c in h.get('components',[]):
            if c.get('_resourceClass')=='component_pmi' and c.get('id') in ('CS_EIN_VORNE','CS_EIN_HINTEN'):
                tr=[x for x in h.get('components',[]) if x.get('_resourceClass')=='component_transform']
                key=tr[0].get('key') if tr else '?'
                print(f"  handle_key={key:20} pmi={c.get('id'):15} tags={c.get('tags')} pos={c['properties']['position']['value']} rot={c['properties']['rotation']['value']}")
EOF

Check tags and positions of CS_EIN_VORNE/HINTEN in panel and post


Error: Exit code 1
== SBE0174SNA.json ==
Traceback (most recent call last):
  File "<stdin>", line 11, in <module>
TypeError: unsupported format string passed to NoneType.__format__


python3 - <<'EOF'
import json
for f in ['packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json',
          'packages/frontend/.storybook/products/schutzzaun_basic/SBE0174PNA.json']:
    data=json.load(open(f))
    print('==',f.split('/')[-1],'==')
    for h in data.get('handles',[]):
        for c in h.get('components',[]):
            if c.get('_resourceClass')=='component_pmi' and c.get('id') in ('CS_EIN_VORNE','CS_EIN_HINTEN'):
                tr=[x for x in h.get('components',[]) if x.get('_resourceClass')=='component_transform']
                key=str(tr[0].get('key')) if tr else '?'
                print(' handle_key=%s pmi=%s tags=%s pos=%s rot=%s' % (key, c.get('id'), c.get('tags'), c['properties']['position']['value'], c['properties']['rotation']['value']))
EOF

Check tags and positions of CS_EIN_VORNE/HINTEN (retry)


== SBE0174SNA.json ==
 handle_key=None pmi=CS_EIN_HINTEN tags=['name-CS_EIN_HINTEN'] pos=[19.5, 20.0, 33.0] rot=[90.0, -0.0, -90.0]
 handle_key=None pmi=CS_EIN_VORNE tags=['name-CS_EIN_VORNE'] pos=[19.5, 20.0, 33.0] rot=[90.0, 0.0, 90.0]
 handle_key=None pmi=CS_EIN_HINTEN tags=['name-CS_EIN_HINTEN'] pos=[19.5, 20.0, 33.0] rot=[90.0, -0.0, -90.0]
 handle_key=None pmi=CS_EIN_VORNE tags=['name-CS_EIN_VORNE'] pos=[19.5, 20.0, 33.0] rot=[90.0, 0.0, 90.0]
== SBE0174PNA.json ==
 handle_key=None pmi=CS_EIN_HINTEN tags=['name-CS_EIN_HINTEN'] pos=[19.5, 20.0, 33.0] rot=[90.0, -0.0, -90.0]
 handle_key=None pmi=CS_EIN_VORNE tags=['name-CS_EIN_VORNE'] pos=[19.5, 20.0, 33.0] rot=[90.0, 0.0, 90.0]
 handle_key=None pmi=CS_EIN_HINTEN tags=['name-CS_EIN_HINTEN'] pos=[19.5, 20.0, 33.0] rot=[90.0, -0.0, -90.0]
 handle_key=None pmi=CS_EIN_VORNE tags=['name-CS_EIN_VORNE'] pos=[19.5, 20.0, 33.0] rot=[90.0, 0.0, 90.0]


python3 -c "
import json
d=json.load(open('packages/frontend/.storybook/products/catalog.json'))
print(json.dumps(d, indent=1)[:2500])
"

Show products catalog.json


{
 "version": 1,
 "gruppen": [
  {
   "gruppe": "schutzzaun_basic",
   "kataloggruppen": [
    {
     "name": "Element",
     "artikelgruppen": [
      {
       "name": "SBE1507PNA",
       "fuellung": "polycarbonat",
       "artikel": [
        {
         "artikelnummer": "SBE0174PNA",
         "initialartikel": false,
         "masse": {
          "laenge": 2200,
          "breite": 174,
          "hoehe": 40
         },
         "filter": {
          "fuellung": "polycarbonat",
          "breite": 174
         },
         "stammdatenBild": "basic_element_polycarbonat.png",
         "assets": {
          "scenegraph": "schutzzaun_basic/SBE0174PNA.json",
          "bild": "schutzzaun_basic/SBE0174PNA.png"
         }
        },
        {
         "artikelnummer": "SBE0217PNA",
         "initialartikel": false,
         "masse": {
          "laenge": 2200,
          "breite": 217,
          "hoehe": 40
         },
         "filter": {
          "fuellung": "polycarbonat",
          "breite": 217
         },
         "stammdatenBild": "basic_element_polycarbonat.png",
         "assets": {
          "scenegraph": "schutzzaun_basic/SBE0217PNA.json",
          "bild": "schutzzaun_basic/SBE0217PNA.png"
         }
        },
        {
         "artikelnummer": "SBE0260PNA",
         "initialartikel": false,
         "masse": {
          "laenge": 2200,
          "breite": 260,
          "hoehe": 40
         },
         "filter": {
          "fuellung": "polycarbonat",
          "breite": 260
         },
         "stammdatenBild": "basic_element_polycarbonat.png",
         "assets": {
          "scenegraph": "schutzzaun_basic/SBE0260PNA.json",
          "bild": "schutzzaun_basic/SBE0260PNA.png"
         }
        },
        {
         "artikelnummer": "SBE0303PNA",
         "initialartikel": false,
         "masse": {
          "laenge": 2200,
          "breite": 303,
          "hoehe": 40
         },
         "filter": {
          "fuellung": "polycarbonat",
          "breite": 303
         },
         "stammdatenBild": "basic_element_polycarbonat.png",
         "assets": {
          "scenegraph": "schutzzaun_basic/SBE0303PNA.json",
          "bild": "schutzzaun_basic/SBE0303PNA.png"
         }
        },
        {
         "artikelnummer": "SBE0346PNA",
         "initialartikel": false,
         "masse": {
          "laenge": 2200,
          "breite": 346,
          "hoehe": 40
         },
         "filter": {
          "fuellung": "polycarbonat",
          "bre


python3 - <<'EOF'
import json
f='packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json'
data=json.load(open(f))
# map: handle id -> (key,parentKey,translation,rotation,scale, pmis, label?)
for h in data['handles']:
    comps=h.get('components',[])
    tr=next((c for c in comps if c.get('_resourceClass')=='component_transform'),None)
    pmis=[c.get('id') for c in comps if c.get('_resourceClass')=='component_pmi']
    if tr and ('CS_EIN_VORNE' in pmis or 'CS_EIN_HINTEN' in pmis or tr.get('key')=='root' or not tr.get('parentKey')):
        print('key=%s parent=%s t=%s r=%s s=%s pmis=%s' % (tr.get('key'),tr.get('parentKey'),tr.get('translation'),tr.get('rotation'),tr.get('scale'),[p for p in pmis if 'VORNE' in str(p) or 'HINTEN' in str(p)]))
EOF

Show transforms of handles carrying VORNE/HINTEN PMIs


key=root parent=None t=[0, 0, 0] r=[0, 0, 0] s=[1, 1, 1] pmis=[]
key=None parent=asm_root t=[0.0, -33.0, -0.5] r=[-1.57079632679, 1.57079632679, 0.0] s=[1, 1, 1] pmis=['CS_EIN_HINTEN', 'CS_EIN_VORNE']
key=None parent=asm_root t=[134.0, -33.0, -0.5] r=[-1.57079632679, 1.57079632679, 0.0] s=[1, 1, 1] pmis=['CS_EIN_HINTEN', 'CS_EIN_VORNE']


grep -rln "CS_OUT\|CS_SELECT\|CS_EIN" /Users/marcus.hinz/projects/geko-core/src /Users/marcus.hinz/projects/geko-fire 2>/dev/null | grep -v node_modules | head; echo ---; grep -rn "CS_OUT_L\|CS_SELECT_L\|CS_EIN_START" /Users/marcus.hinz/projects/geko-core -r --include='*.ts' --include='*.js' --include='*.json' -l 2>/dev/null | grep -v node_modules | head

Search geko repos for the aufbereitete PMI names


ls /Users/marcus.hinz/projects/geko-core /Users/marcus.hinz/projects/geko-fire 2>&1 | head -30; echo ---; grep -rln "CS_" /Users/marcus.hinz/projects/geko-core --include='*.ts' --include='*.kt' --include='*.java' --include='*.py' 2>/dev/null | grep -v node_modules | head

List geko repos and search for PMI references


/Users/marcus.hinz/projects/geko-core:
README.md
dev
node_modules

/Users/marcus.hinz/projects/geko-fire:
README.md
assets
browser
fbxProcessor.worker.js
fbxProcessor.worker.js.LICENSE.txt
fbxProcessor.worker.js.map
glbProcessor.worker.js
glbProcessor.worker.js.LICENSE.txt
glbProcessor.worker.js.map
index.html
package.json
types
---


ls /Users/marcus.hinz/projects/geko-core/dev 2>&1 | head; echo ---; grep -rln "CS_" /Users/marcus.hinz/projects/geko-core/dev 2>/dev/null | grep -v node_modules | head -10

Inspect geko-core dev directory for PMI references


README.md
apps
eslint.config.mjs
libs
mocks
node_modules
nx.json
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
---
/Users/marcus.hinz/projects/geko-core/dev/mocks/json/viewing_objects.json


python3 - <<'EOF'
import json,re
raw=open('/Users/marcus.hinz/projects/geko-core/dev/mocks/json/viewing_objects.json',encoding='utf-8',errors='replace').read()
names=sorted(set(re.findall(r'CS_[A-Z0-9_]+',raw)))
print(len(raw),'bytes'); print(names[:60])
EOF

List PMI names in geko-core mock viewing_objects.json


1906 bytes
['CS_SVS']


python3 - <<'EOF'
import json
d=json.load(open('packages/frontend/.storybook/products/catalog.json'))
for g in d['gruppen']:
    print('gruppe:',g['gruppe'])
    for kg in g['kataloggruppen']:
        arts=[a for ag in kg['artikelgruppen'] for a in ag['artikel']]
        init=[a['artikelnummer'] for a in arts if a.get('initialartikel')]
        print('  ',kg['name'], len(arts),'artikel, initial:',init)
EOF
echo ---; grep -rn "allround\|schutzzaun_basic" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src --include='*.ts' --include='*.vue' | grep -vi test | head

Show catalog groups and which product set the frontend uses


gruppe: schutzzaun_basic
   Element 65 artikel, initial: ['SBE1507SNA']
   Einzeltür 4 artikel, initial: ['SBS1044SRA']
   Doppeltür 4 artikel, initial: ['SBD2083SRA']
   Schiebetür 4 artikel, initial: ['SBF0872SRA']
gruppe: schutzzaun_allround
   Element 65 artikel, initial: ['SAE1507SNN']
   Einzeltür 4 artikel, initial: ['SAS1044SRA']
   Doppeltür 4 artikel, initial: ['SAD2083SRA']
   Schiebetür 4 artikel, initial: ['SAF0872SRA']
   Steher 7 artikel, initial: ['SAP4180BNA']
gruppe: rollenfoerderer
   Gerade 4 artikel, initial: ['R5S_600x400', 'R5S_400x600', 'R5S_400x300', 'R5S_300x400']
   Dreheinheit 4 artikel, initial: ['R5R_600x400', 'R5R_400x600', 'R5R_400x300', 'R5R_300x400']
   Eckumsetzer50 6 artikel, initial: ['R5T050_ANFANG_600x400', 'R5T050_MITTE_600x400', 'R5T050_ENDE_600x400', 'R5T050_ANFANG_400x600', 'R5T050_MITTE_400x600', 'R5T050_ENDE_400x600']
   Kurve 8 artikel, initial: ['R5C45_0_600x400', 'R5C45_0_400x600', 'R5C90_0_600x400', 'R5C90_0_400x600', 'R5C45_1_600x400', 'R5C45_1_400x600', 'R5C90_1_600x400', 'R5C90_1_400x600']
---
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/Fire3d.stories.ts:26:                const scene = await fetch("/products/schutzzaun_basic/SBS1044PLA.json").then((r) => r.json());
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/Fire3d.stories.ts:47:                zaunScene = await fetch("/products/schutzzaun_basic/SBS1044PLA.json").then((r) => r.json());
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/building-blocks/ProductBrowser.vue:87:    schutzzaun_basic: "safety_fence_basic.png",
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/building-blocks/ProductBrowser.vue:88:    schutzzaun_allround: "safety_fence_allround.png",
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/building-blocks/ProductBrowserPanel.stories.ts:9:    schutzzaun_basic: "safety_fence_basic.png",
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/building-blocks/ProductBrowserPanel.stories.ts:10:    schutzzaun_allround: "safety_fence_allround.png",


git show HEAD:packages/frontend/.storybook/assets/scenes/zaun.json > /private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/8ef2174d-a2d8-4207-97b0-ee343422282d/scratchpad/zaun_old.json 2>/dev/null && python3 - <<'EOF'
import json
d=json.load(open('/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/8ef2174d-a2d8-4207-97b0-ee343422282d/scratchpad/zaun_old.json'))
print(type(d), list(d)[:5] if isinstance(d,dict) else len(d))
hs=d.get('handles') or d.get('sceneGraph_entity',{}).get('handles')
print('handles',len(hs))
shown=0
for h in hs:
    for c in h.get('components',[]):
        rc=c.get('_resourceClass') or c.get('type')
        if rc=='component_pmi' and shown<6:
            print(json.dumps(c)[:400]); shown+=1
EOF

Extract old committed zaun.json and show its PMI schema


<class 'dict'> ['_resourceClass', 'handles']
handles 53
{"_resourceClass": "component_pmi", "id": "CS_SVS", "tags": ["name-CS_SVS"], "properties": {"position": {"type": "vector3", "value": [0, 0, 0], "usage": "position"}, "rotation": {"type": "quaternion", "value": [0, 0, 0], "usage": "rotation"}}}
{"_resourceClass": "component_pmi", "id": "CS_BASIS", "tags": ["name-CS_BASIS"], "properties": {"position": {"type": "vector3", "value": [0, 0, 0], "usage": "position"}, "rotation": {"type": "quaternion", "value": [0, 0, 0], "usage": "rotation"}}}


Das alte, funktionierende Asset hatte PMIs ohne `type`-Feld. Ich prüfe, wie das eingesetzte @ad/fire 1.0.6 `component_pmi` parst.


ls /Users/marcus.hinz/projects/adesso-cpq/node_modules/@ad 2>/dev/null; ls /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/node_modules/@ad 2>/dev/null; find /Users/marcus.hinz/projects/adesso-cpq -maxdepth 4 -name '@ad' -type d 2>/dev/null | head

Locate @ad/fire package in node_modules


fire
/Users/marcus.hinz/projects/adesso-cpq/node_modules/.pnpm/node_modules/@ad
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/node_modules/@ad


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/node_modules/@ad/fire && ls; grep -o "component_pmi[^\"]*" -r . --include='*.js' 2>/dev/null | head -5; echo ---; grep -rn "getDropPoint\|PMIType\|pmiType" . --include='*.d.ts' 2>/dev/null | head -20

Inspect @ad/fire package for PMI type handling


README.md
browser
fbxProcessor.worker.js
fbxProcessor.worker.js.LICENSE.txt
fbxProcessor.worker.js.map
glbProcessor.worker.js
glbProcessor.worker.js.LICENSE.txt
glbProcessor.worker.js.map
package.json
types
browser/index.js:component_pmi
browser/index.js:component_pmi
---


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/node_modules/@ad/fire && node -e "
const fs=require('fs');
const src=fs.readFileSync('browser/index.js','utf8');
let i=src.indexOf('component_pmi');
while(i!==-1){ console.log('--- at',i,'---'); console.log(src.slice(i-600,i+600).replace(/\n/g,' ')); i=src.indexOf('component_pmi',i+1); }
" 2>&1 | head -40

Show code context around component_pmi in fire bundle


--- at 1664826 ---
){return this._colorOverride}};v=o=(0,l.Cg)([(0,p.SettingsResolver)({properties:{_type:"object",default:()=>({})},selectable:{_type:"boolean",default:()=>!0},id:{_type:"string",default:()=>null},tags:{_type:"array",children:{_type:"string"},default:()=>[]},type:{_type:"enum",value:r,default:r.none}}),(0,c.EntityLabel)("PMI"),(0,c.AttachComponent)(c.AttachComponentAttribute.Type.none),(0,c.UniqueComponent)(!1),(0,l.Sn)("design:paramtypes",["function"==typeof(a="undefined"!=typeof Partial&&Partial)?a:Object])],v);class _ extends c.Model.ComponentModel{static isType(e){return"object"==typeof e&&"component_pmi"==e._resourceClass}}var b;let x=class extends c.ComponentDeserializer{onInstantiate(){super.onInstantiate(),this.registerMapping("type",(e,t)=>{t.type=c.EnumType.deserialize(e,r)}),this.registerMapping("selectable",(e,t)=>{t.selectable=e}),this.registerMapping("tags",(e,t)=>{t.tags||(t.tags=[]),Array.isArray(e)?t.tags.push(...e):t.tags.push(e)}),this.registerMapping("id",(e,t)=>{t.id=e}),this.registerMapping("properties",async(e,t)=>{t.properties={};for(const[n,r]of Object.entries(e)){const i=await this.typedPropertyMapper.deserializeProperty(e[n]);t.properties[n]={...i,usage:c.E
--- at 1666763 ---
ng("tags",(e,t)=>{t.tags=e.slice(0)}),this.registerMapping("properties",async(e,t)=>{t.properties={};const n=Object.keys(e);for(const r of n){const n=await this.serializeProperty(e[r]);n?t.properties[r]=n:this.logger.error((0,h.logMessage)(this,"defines",`${r} was skipped during execution`))}})}async serializeProperty(e){return{type:c.EnumType.serialize(e.type,c.TypedPropertyType),value:await this.typedPropertyMapper.serialize(e.type,e.value),usage:c.EnumType.serialize(e.usage??s.none,s)}}async onExecute(e,t){return await this.applyMappings(this.mappings,{input:e,execution:t},{_resourceClass:"component_pmi",type:"none"})}};(0,l.Cg)([(0,p.PropertyResolver)(c.TypedPropertyMapper),(0,l.Sn)("design:type","function"==typeof(M=void 0!==c.TypedPropertyMapper&&c.TypedPropertyMapper)?M:Object)],S.prototype,"typedPropertyMapper",void 0),S=(0,l.Cg)([(0,c.EntitySerializer)({targetType:v}),(0,p.SettingsResolver)("pmiSer")],S);let w=class extends c.ComponentGizmo{constructor(){super(...arguments),this.componentKey="pmigeometry"}onInstantiate(){super.onInstantiate(),this.defaultGeometry=new c.LineGeometryAsset({vertices:[],colors:[]}),this.defaultMaterial=this.elementManager.lineMaterial(u.Color.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/node_modules/@ad/fire && node -e "
const src=require('fs').readFileSync('browser/index.js','utf8');
// find PMIType enum values
let m=src.match(/[a-zA-Z_$]+\[[a-zA-Z_$]+\.point=\d+\]=.{0,400}/);
for(const pat of ['point\\\"]=','adapter','getDropPoint']){
  let re=new RegExp(pat,'g'); let c=0,i;
  while((i=re.exec(src)) && c<3){ console.log('=== '+pat+' @'+i.index+' ==='); console.log(src.slice(i.index-300,i.index+300).replace(/\n/g,' ')); c++; }
}
" 2>&1 | head -40

Find PMI type enum and getDropPoint in fire bundle


=== adapter @1653761 ===
ponentUiGizmo:()=>q,PmiSelectableUiGizmo:()=>X});var r,i,s,o,a,l=n(117),c=n(264),u=n(700),h=n(85),p=n(75),d={};d.d=(e,t)=>{for(var n in t)d.o(t,n)&&!d.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},d.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),function(e){e[e.none=0]="none",e[e.adapter=1]="adapter",e[e.material=2]="material",e[e.point=10]="point",e[e.line=11]="line",e[e.plane=12]="plane",e[e.box=13]="box",e[e.matrix=14]="matrix"}(r||(r={})),function(e){e[e.none=0]="none",e[e.type=256]="type",e[e.id=512]="id",e[e.tags=1024]="tags",e[e.property=2048]="property",e[e.translati
=== adapter @1653773 ===
o:()=>q,PmiSelectableUiGizmo:()=>X});var r,i,s,o,a,l=n(117),c=n(264),u=n(700),h=n(85),p=n(75),d={};d.d=(e,t)=>{for(var n in t)d.o(t,n)&&!d.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},d.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),function(e){e[e.none=0]="none",e[e.adapter=1]="adapter",e[e.material=2]="material",e[e.point=10]="point",e[e.line=11]="line",e[e.plane=12]="plane",e[e.box=13]="box",e[e.matrix=14]="matrix"}(r||(r={})),function(e){e[e.none=0]="none",e[e.type=256]="type",e[e.id=512]="id",e[e.tags=1024]="tags",e[e.property=2048]="property",e[e.translation=4096]="tr
=== adapter @1657321 ===
get selectable(){return this._settings.selectable}set selectable(e){this._settings.selectable!==e&&(this._settings.selectable=e,this.sync(i.selectable))}get childIndex(){return this.handle.getComponents(o).indexOf(this)}get localBounds(){const e=this.diameter/2;switch(this.type){case r.point:case r.adapter:return new u.Bounds(this.localPosition,new u.Vector3(e,e,e));case r.box:{const e=this.existsPropertyByUsage(s.size)?this.getPropertyValueByUsage(s.size):new u.Vector3(.1,.1,.1);return new u.Bounds(u.Vector3.zero,e.multiply(.5)).transform(this.localMatrix)}case r.plane:{const e=this.existsPro
=== getDropPoint @1418081 ===
-${n}`],components:[{_resourceClass:"component_transform",translation:[0,0,0]},{_resourceClass:"component_renderer",geometry:t}]}))},r=await this.addInteractionScene(n);r&&(this.dragPointHandles=this._settings.dragPoints.map((e,t)=>r.find(`.drag-point-${t}`)))}extractDropPoints(e){return e.map(e=>e.getDropPoint?.()).filter(e=>null!=e)}betterRayAssessment(e,t){return e?(0,l.valueEqual)(e.jumpDistance,t.jumpDistance,.05)?e.rayDistance<t.rayDistance?e:t:e.jumpDistance<t.jumpDistance?e:t:t}get defaultCursorTransform(){return this._settings.defaultCursorTransform??this._settings.dragPoints[0].trans
=== getDropPoint @1663261 ===
4_4.compose(this.localPosition,this.localRotation)}get LTW(){return this.parentLTW.multiply(this.localMatrix)}isSelectionClickable(){return this.active&&this.type!==r.none&&this.settings.selectable}getTransformationProvider(){return this}setPosition(e){this.position=e}setRotation(e){this.rotation=e}getDropPoint(){return this.type==r.point||this.type==r.matrix?new y(this):this.type==r.line?new f(this):this.type==r.plane?new m(this):null}hasProperty(e,t){switch(e){case"layer":return null==t||this.layer==parseInt(t);case"active":return null==t||this.active==("true"==t.toLowerCase());case"type":re


python3 - <<'EOF'
import json
def ids(f):
    d=json.load(open(f)); s=set()
    for h in d['handles']:
        for c in h.get('components',[]):
            if c.get('_resourceClass')=='component_pmi': s.add(c.get('id'))
    return s
b=ids('packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json')
a=ids('packages/frontend/.storybook/products/schutzzaun_allround/SAE0174SNN.json')
print('basic only:',sorted(b-a)); print(); print('allround only:',sorted(a-b)); print(); print('common:',len(a&b))
EOF

Compare PMI id sets: basic vs allround panel


Error: Exit code 1
Traceback (most recent call last):
  File "<stdin>", line 8, in <module>
  File "<stdin>", line 3, in ids
FileNotFoundError: [Errno 2] No such file or directory: 'packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json'


cd /Users/marcus.hinz/projects/adesso-cpq && python3 - <<'EOF'
import json
def ids(f):
    d=json.load(open(f)); s=set()
    for h in d['handles']:
        for c in h.get('components',[]):
            if c.get('_resourceClass')=='component_pmi': s.add(c.get('id'))
    return s
b=ids('packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json')
a=ids('packages/frontend/.storybook/products/schutzzaun_allround/SAE0174SNN.json')
print('basic only:',sorted(b-a)); print(); print('allround only:',sorted(a-b)); print(); print('common:',len(a&b))
EOF

Compare PMI id sets basic vs allround (in repo dir)


basic only: ['CS_EIN_180_0_0', 'CS_EIN_270_0_0', 'CS_EIN_90_0_0', 'CS_EIN_90_0_180', 'CS_EIN_C4F_H_LINKS', 'CS_EIN_C4F_H_RECHTS', 'CS_EIN_HINTEN', 'CS_EIN_HINTEN_RECHTS', 'CS_EIN_QUER_LINKS', 'CS_EIN_QUER_RECHTS', 'CS_EIN_STEHER_HINTEN', 'CS_EIN_STEHER_OBEN', 'CS_EIN_STEHER_UNTEN', 'CS_EIN_STEHER_VORNE', 'CS_EIN_VORNE', 'CS_EIN_VORNE_LINKS', 'CS_OUT_L_0', 'CS_OUT_L_270', 'CS_OUT_L_270_B', 'CS_OUT_L_90', 'CS_OUT_L_90_B', 'CS_OUT_R_0', 'CS_OUT_R_270', 'CS_OUT_R_270_B', 'CS_OUT_R_90', 'CS_OUT_R_90_B', 'CS_SELECT_L_0', 'CS_SELECT_L_270', 'CS_SELECT_L_90', 'CS_SELECT_R_0', 'CS_SELECT_R_270', 'CS_SELECT_R_90']

allround only: ['CS_OUT_L', 'CS_OUT_R']

common: 12


python3 - <<'EOF'
import json
def show(f, wanted):
    d=json.load(open(f))
    print('==',f.split('/')[-1],'==')
    for h in d['handles']:
        comps=h.get('components',[])
        tr=next((c for c in comps if c.get('_resourceClass')=='component_transform'),None)
        for c in comps:
            if c.get('_resourceClass')=='component_pmi' and c.get('id') in wanted:
                print(' handle(t=%s r=%s parent=%s key=%s)' % (tr.get('translation'),tr.get('rotation'),tr.get('parentKey'),tr.get('key')))
                print('   %s type=%s tags=%s pos=%s rot=%s' % (c.get('id'),c.get('type'),c.get('tags'),c['properties']['position']['value'],c['properties']['rotation']['value']))
show('packages/frontend/.storybook/products/schutzzaun_allround/SAE0174SNN.json',{'CS_OUT_L','CS_OUT_R'})
show('packages/frontend/.storybook/products/schutzzaun_allround/SAP4180BNA.json',{'CS_EIN_VORNE','CS_EIN_HINTEN'})
EOF

Show CS_OUT_L/R in allround element and VORNE/HINTEN in allround Steher


== SAE0174SNN.json ==
 handle(t=[0, 0, 0] r=[0, 0, 0] parent=None key=root)
   CS_OUT_L type=point tags=['name-CS_OUT_L'] pos=[-20, 1100, -20] rot=[0, 180, 0]
 handle(t=[0, 0, 0] r=[0, 0, 0] parent=None key=root)
   CS_OUT_R type=point tags=['name-CS_OUT_R'] pos=[-20, 1100, -154] rot=[0, 0, 0]
== SAP4180BNA.json ==
 handle(t=[-40.0, -33.0, -39.5] r=[-1.57079632679, -1.57079632679, 0.0] parent=node_STEHER_1 key=None)
   CS_EIN_HINTEN type=point tags=['name-CS_EIN_HINTEN'] pos=[19.5, 20.0, 33.0] rot=[90.0, -0.0, -90.0]
 handle(t=[-40.0, -33.0, -39.5] r=[-1.57079632679, -1.57079632679, 0.0] parent=node_STEHER_1 key=None)
   CS_EIN_VORNE type=point tags=['name-CS_EIN_VORNE'] pos=[19.5, 20.0, 33.0] rot=[90.0, 0.0, 90.0]
 handle(t=[0.0, -33.0, -40.5] r=[-1.57079632679, 1.57079632679, 0.0] parent=node_STEHER_1 key=None)
   CS_EIN_HINTEN type=point tags=['name-CS_EIN_HINTEN'] pos=[19.5, 20.0, 33.0] rot=[90.0, -0.0, -90.0]
 handle(t=[0.0, -33.0, -40.5] r=[-1.57079632679, 1.57079632679, 0.0] parent=node_STEHER_1 key=None)
   CS_EIN_VORNE type=point tags=['name-CS_EIN_VORNE'] pos=[19.5, 20.0, 33.0] rot=[90.0, 0.0, 90.0]


grep -c "SBE0131SNA\|SBE1206PNA" packages/frontend/.storybook/products/catalog.json; grep -o '"artikelnummer": "SBE0131SNA"\|"artikelnummer": "SBE1206PNA"' packages/frontend/.storybook/products/catalog.json

Check whether the two PMI-less basic files are in the catalog


6
"artikelnummer": "SBE1206PNA"
"artikelnummer": "SBE0131SNA"


Befund — Wiederherstellen aus Git geht nicht, aber der brauchbare Stand liegt schon (unversioniert) im Working Tree:

**Mechanik des Defekts.** Das Links/Rechts-Anbauen läuft ausschließlich über die PMIs `CS_EIN_VORNE`/`CS_EIN_HINTEN` (`DefaultCommandProcessor.ts:289` baut damit die `start_placement_mode`-DragPoints). Das eingesetzte @ad/fire 1.0.6 liefert in `getDropPoint()` nur für PMI-Typ `point/matrix/line/plane` einen DropPoint — die Roh-Exporte haben Typ `adapter`, damit gibt es keine Snap-Ziele. Das ist das „kaputt w/ PMIs".

**Stand der Assets** (`packages/frontend/.storybook/products/`, nie committet, daher keine Git-Historie):

- **schutzzaun_basic**: Working Tree (Stand 2.7.) ist bereits aufbereitet — alle PMIs auf `point` konvertiert, alle 77 Teile haben `CS_EIN_VORNE`+`CS_EIN_HINTEN` mit korrekten Tags an beiden Enden. Ausnahmen: `SBE0131SNA` und `SBE1206PNA` (unvollständige Exporte mit nur 24 PMIs, beide im Katalog referenziert — diese zwei Artikel schnappen nicht). Der Index-Stand darunter ist der kaputte (`adapter`, PMIs fehlen).
- **schutzzaun_allround**: Steher (SAP…) und Türen haben VORNE/HINTEN, aber die komplette SAE-Serie (alle 65 Elemente) hat sie nicht — dort nur `CS_OUT_L`/`CS_OUT_R` am Root-Handle. Mit allround geht links/rechts daher nirgends bei Elementen.

Ob basic im Lauf tatsächlich schnappt, habe ich nicht verifiziert — strukturell erfüllt es alles, was der Placement-Code verlangt. Zum Testen der Geko-Integration ist basic damit der Stand, „in dem etwas geht".

**Plan für allround** (falls auch benötigt): Skript ergänzt in allen 65 SAE-JSONs an den beiden Endpunkten (Positionen von `CS_OUT_L`/`CS_OUT_R`) je ein `CS_EIN_VORNE`- und `CS_EIN_HINTEN`-PMI (Typ `point`, Tags `name-…`, Rotationen gespiegelt nach dem basic-Muster: an jedem Ende beide Punkte mit um 180° gedrehter Orientierung). Die zwei defekten basic-Dateien lasse ich unangetastet, deren Quelle ist unvollständig. Go vorausgesetzt, setze ich das um.


Die pasenden Assets sind drin?


Für schutzzaun_basic: ja — der aufbereitete Stand mit VORNE/HINTEN-PMIs als `point` liegt im Working Tree, bis auf die zwei defekten Artikel (SBE0131SNA, SBE1206PNA). Für schutzzaun_allround: nein — den 65 SAE-Elementen fehlen diese PMIs.


gib mir mal den storybook start befehl


grep -m1 '"name"' packages/frontend/package.json; grep -n '"storybook"' packages/frontend/package.json

Read frontend package name and storybook script


    "name": "@ad-cpq/common-frontend",
21:        "storybook": "storybook dev -p 6006",
46:        "storybook": "8.6.18",


```
pnpm --filter @ad-cpq/common-frontend storybook
```

Läuft auf Port 6006.


Story book ist da
ich habe wieder das selection problem
Select geht gar nicht


grep -rn "custom-selection" packages/frontend/src --include='*.ts' --include='*.vue' | head; echo ---; grep -rln "set_selection\|selection" packages/frontend/src/configurator --include='*.ts' | head

Find selection handling in frontend code


grep -n "selection\|elementClicked\|clicked" packages/frontend/src/configurator/events/DefaultCommandProcessor.ts | head -30

Show selection/click handling in DefaultCommandProcessor


12:    // Current selection as a set of picks. Each pick is a picked geometry plus its escalation level
15:    private selection: { pickedId: string; level: number }[] = [];
42:            case "escalate-selection":
54:            case "selection-click":
56:            case "selection":
92:    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
93:    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
96:        const additive = event.selectionAction === "add";
98:            if (!additive) this.selection = [];
99:            return this.selectionReactions(stop);
107:            const present = this.selection.some((p) => p.pickedId === pickedId);
108:            this.selection = present
109:                ? this.selection.filter((p) => p.pickedId !== pickedId)
110:                : [...this.selection, { pickedId, level: 0 }];
112:            const sole = this.selection.length === 1 ? this.selection[0] : null;
114:            this.selection = [{ pickedId, level }];
116:        return this.selectionReactions(stop);
122:        if (this.selection.length === 1 && this.selection[0].pickedId === event.pickedId) {
124:            this.selection[0].level = Math.min(this.selection[0].level + 1, chain.length - 1);
127:        return this.selectionReactions(stop).slice(1); // no stop needed here, just the selection
132:        return this.selection
140:    private selectionReactions(stop: UIReaction): UIReaction[] {
142:        return [stop, { type: "sendCommand", cmd: "set_selection", args: [selectors] }];
207:    // Start move/rotate strictly on the current selection: consolidate it into movable groups (break a
208:    // group only if a partial selection requires it), keep that regroup pending, select the resulting
209:    // group nodes and start the engine mode. Already-whole-group selections move without regrouping.
213:        const elementRoots = this.selectionElementRoots();
227:            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
232:    // The element roots covered by the current selection: a selected part is itself, a selected group
234:    private selectionElementRoots(): string[] {


Story book ist da ich habe wieder das selection problem Select geht gar nicht


sed -n '88,145p' packages/frontend/src/configurator/events/DefaultCommandProcessor.ts; echo ===; grep -n "elementRootOf\|selectionChainOf\|chainOf" packages/frontend/src/configurator/model/GroupProcessor.ts | head

Read selection click handling and chain resolution


Error: Exit code 1
            { type: "sendCommand", cmd: "setScene", args: [scene] },
        ];
    }
    // Default click selects the single element; re-clicking the same spot escalates up the role
    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
    private handleSelectionClickEvent(event: any): UIReaction[] {
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        const additive = event.selectionAction === "add";
        if (!event.target) {
            if (!additive) this.selection = [];
            return this.selectionReactions(stop);
        }
        this.replay();
        const pickedId = event.target.id;
        const chain = this.groups.selectableAncestors(pickedId);
        if (!chain.length) return [stop];

        if (additive) {
            const present = this.selection.some((p) => p.pickedId === pickedId);
            this.selection = present
                ? this.selection.filter((p) => p.pickedId !== pickedId)
                : [...this.selection, { pickedId, level: 0 }];
        } else {
            const sole = this.selection.length === 1 ? this.selection[0] : null;
            const level = sole && sole.pickedId === pickedId ? Math.min(sole.level + 1, chain.length - 1) : 0;
            this.selection = [{ pickedId, level }];
        }
        return this.selectionReactions(stop);
    }

    // Right-click escalation: only meaningful for a single selected pick; bump it one level.
    private handleEscalateSelection(event: any): UIReaction[] {
        this.replay();
        if (this.selection.length === 1 && this.selection[0].pickedId === event.pickedId) {
            const chain = this.groups.selectableAncestors(event.pickedId);
            this.selection[0].level = Math.min(this.selection[0].level + 1, chain.length - 1);
        }
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        return this.selectionReactions(stop).slice(1); // no stop needed here, just the selection
    }

    // The node ids currently selected (each pick resolved to its escalated node).
    private selectedNodeIds(): string[] {
        return this.selection
            .map((p) => {
                const chain = this.groups.selectableAncestors(p.pickedId);
                return chain[Math.min(p.level, chain.length - 1)];
            })
            .filter((id): id is string => !!id);
    }

    private selectionReactions(stop: UIReaction): UIReaction[] {
        const selectors = this.selectedNodeIds().map((id) => "#" + id);
        return [stop, { type: "sendCommand", cmd: "set_selection", args: [selectors] }];
    }

    private handleInteractionMode(event: any): UIReaction[] {
(eval):1: == not found


grep -n "selectableAncestors" packages/frontend/src/configurator/model/GroupProcessor.ts

Find selectableAncestors in GroupProcessor


357:    selectableAncestors(handleId: string): string[] {


sed -n '340,410p' packages/frontend/src/configurator/model/GroupProcessor.ts

Read selectableAncestors implementation


    }

    // Whether a set of element roots forms one connected run: all in the same group, and the
    // selection-induced subgraph (edges with both ends selected) is a single component. For a chain
    // this also means "gap-free" — picking across a gap leaves the induced subgraph disconnected.
    areConnected(elementIds: string[]): boolean {
        if (elementIds.length <= 1) return true;
        const groupIds = new Set(elementIds.map((id) => this.elementToGroup.get(id)));
        if (groupIds.size !== 1 || groupIds.has(undefined)) return false;
        const g = this.groups.get([...groupIds][0]!)!;
        const sel = new Set(elementIds);
        const induced = g.edges.filter((e) => sel.has(e.from) && sel.has(e.to));
        return this.connectedComponents(elementIds, induced).length === 1;
    }

    // Selectable nodes above (and including) a picked handle, lowest first: part, then assembly,
    // then any deeper container. Drives escalating selection (each level up is the next entry).
    selectableAncestors(handleId: string): string[] {
        const out: string[] = [];
        let cur: string | null = handleId;
        while (cur) {
            const node = this.nodeIndex.get(cur);
            if (!node) break;
            if (node.role === ROLE.part || node.role === ROLE.assembly) out.push(cur);
            cur = node.parentId;
        }
        return out;
    }

    // ---- replay ------------------------------------------------------------

    replay(baseScene: Scene, events: GroupEvent[]): Scene {
        this.groups = new Map();
        this.handles = new Map();
        this.elementHandles = new Map();
        this.elementToGroup = new Map();
        for (const event of events) this.apply(event);
        return this.materialize(baseScene);
    }

    private apply(event: GroupEvent): void {
        switch (event.type) {
            case "group-create": {
                const cloned = event.handles.map((h) => structuredClone(h));
                for (const handle of cloned) this.handles.set(handle.id, handle);
                this.elementHandles.set(event.rootHandleId, cloned);
                this.groups.set(event.groupId, {
                    id: event.groupId,
                    key: event.groupKey,
                    members: [event.rootHandleId],
                    edges: [],
                    transform: { pos: event.position, rot: event.rotation },
                });
                this.elementToGroup.set(event.rootHandleId, event.groupId);
                break;
            }
            case "group-merge": {
                const target = this.groups.get(event.targetGroupId);
                const moving = this.groups.get(event.movingGroupId);
                if (!target || !moving) break;
                target.members.push(...moving.members);
                target.edges.push(...moving.edges);
                target.edges.push({
                    from: event.anchorRootId,
                    to: event.joinRootId,
                    fromPmi: event.anchorPmiId,
                    toPmi: event.joinPmiId,
                    rot: event.rot,
                });
                for (const member of moving.members) this.elementToGroup.set(member, target.id);
                this.groups.delete(event.movingGroupId);


grep -n "nodeIndex\|ROLE\." packages/frontend/src/configurator/model/GroupProcessor.ts | head -25

Find nodeIndex construction and role assignment


119:    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();
213:        return this.nodeIndex.get(handleId)?.role ?? null;
329:            const node = this.nodeIndex.get(current);
339:        return this.nearestAncestorOfRole(handleId, ROLE.part);
361:            const node = this.nodeIndex.get(cur);
363:            if (node.role === ROLE.part || node.role === ROLE.assembly) out.push(cur);
469:                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
580:            tags: [roleTag(ROLE.assembly)],
597:            tags: [roleTag(ROLE.scene)],
651:        return ROLE.geometry;
662:        this.nodeIndex = new Map();
666:            this.nodeIndex.set(h.id, { parentId, role: this.roleOf(h) });


sed -n '640,700p' packages/frontend/src/configurator/model/GroupProcessor.ts

Read roleOf and nodeIndex building


        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
    }

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

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

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


python3 - <<'EOF'
import json
f='packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json'
d=json.load(open(f))
keys={}; parents={}
for h in d['handles']:
    tr=next((c for c in h.get('components',[]) if c.get('_resourceClass')=='component_transform'),None)
    if not tr: print('handle ohne transform:',h.get('id')); continue
    keys.setdefault(tr.get('key'),[]).append(h.get('id'))
    parents.setdefault(tr.get('parentKey'),0); parents[tr.get('parentKey')]+=1
print('vorhandene keys:', {k:len(v) for k,v in keys.items()})
print('parentKey-Verweise:', parents)
print('unaufloesbare parents:', [p for p in parents if p is not None and p not in keys])
EOF

Check key/parentKey resolution in a panel asset


vorhandene keys: {'root': 1, 'asm_root': 1, None: 56}
parentKey-Verweise: {None: 1, 'root': 1, 'asm_root': 56}
unaufloesbare parents: []


node -e "
const src=require('fs').readFileSync('packages/frontend/node_modules/@ad/fire/browser/index.js','utf8');
let re=/custom-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('total:', (src.match(/custom-selection/g)||[]).length);
"

Find custom-selection handling in fire bundle


total: 0


grep -rn "selection-click" packages/frontend/src --include='*.ts' --include='*.vue' | head; echo ---; grep -rn "observe\|childClicked\|elementClicked" packages/frontend/src/components/visual/Fire3d.vue 2>/dev/null | head; ls packages/frontend/src/components/visual/

Find where selection-click events originate


packages/frontend/src/components/visual/Fire3D.vue:116:                } else if (reaction.cmd === "selection-click") {
packages/frontend/src/components/visual/Fire3D.vue:173:        // TEMP Analyse: eingehende fire-Events (v.a. selection-click) sichtbar machen
packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts:17:const click = (id: string, action: "set" | "add") => ({ eventTypeName: "selection-click", target: { id }, selectionAction: action });
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:54:            case "selection-click":
---
Fire3D.vue
Fire3d.stories.ts
FireContextMenu.vue
FireControls.vue
ProductConfigurator.stories.ts
ProductConfigurator.vue
ProductEditorDemo.vue
fire3d.types.ts


sed -n '95,200p' packages/frontend/src/components/visual/Fire3D.vue

Read Fire3D event wiring around selection-click


});

function closeContextMenu() {
    contextMenu.value = null;
}

function onContextMenuSelect(entry: MenuEntry) {
    processReactions(entry.reactions);
    closeContextMenu();
}

// Reactions are awaited sequentially: a sendCommand only starts after the previous one
// resolved. This keeps a "stop_interaction_mode" before a following "setScene", and moves
// scene rebuilds off the synchronous engine event/processInput stack so an active gizmo is
// never torn down mid-frame.
async function processReactions(reactions: UIReaction[]): Promise<void> {
    for (const reaction of reactions) {
        switch (reaction.type) {
            case "sendCommand":
                if (reaction.cmd === "setScene") {
                    await visual.value?.setScene(reaction.args[0]);
                } else if (reaction.cmd === "selection-click") {
                    // Handle selection click
                    const nodeId = reaction.args[0] as string;
                    emit("selectionClick", nodeId);
                } else {
                    await visual.value?.execute(reaction.cmd, ...reaction.args);
                }
                break;
            case "dispatchEvent":
                await processReactions(processor.handleEvent(reaction.event));
                break;
            case "notification":
                toast.value?.show(reaction.message);
                break;
            case "dialog":
                dialog.value?.show(reaction.title, reaction.message);
                break;
            case "contextMenu":
                contextMenu.value = { entries: reaction.entries };
                break;
        }
    }
}
// Handle product save - send command with variables
async function handleSaveProduct(data: Record<string, any>) {
    if (!props.onSaveProduct) return;

    try {
        // Prepare the command payload with variables
        const commandPayload = {
            command: "updateProduct",
            productCategory: data.category || "default",
            variables: { ...data },
        };

        // Execute the save handler to get new product data
        const result = await props.onSaveProduct(commandPayload);

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

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

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

onBeforeUnmount(() => {
    window.removeEventListener("keydown", onKeydown);
    visual.value?.dispose();
    visual.value = null;
});

defineExpose({
    setScene: async (scene: unknown) => {
        await visualPromise;
        processor.setInitialScene(scene);


sed -n '440,480p' packages/frontend/src/configurator/model/GroupProcessor.ts; sed -n '555,605p' packages/frontend/src/configurator/model/GroupProcessor.ts

Read group-create root tagging and materialize


                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                break;
            }
        }
    }

    private materialize(baseScene: Scene): Scene {
        const handles: Handle[] = [...(baseScene?.handles ?? [])];
        if (this.groups.size > 0) handles.push(this.sceneRootHandle());

        for (const g of this.groups.values()) {
            const layout = this.computeLayout(g);
            for (const member of g.members) {
                const subtree = this.elementHandles.get(member) ?? [];
                const root = this.handles.get(member);
                const tr = this.rootTransform(root);
                const lay = layout.get(member) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };
                // Parent the element root to the group node. The member carries its group-local rotation
                // (identity for a plain left/right chain; non-identity for a branch docked at an angle);
                // the group node carries the group's world orientation on top.
                tr.parentKey = g.key;
                tr.translation = lay.pos;
                tr.rotation = lay.rot;
                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
                handles.push(...subtree);
            }
            handles.push(this.groupNodeHandle(g));
        }
        this.buildNodeIndex(handles);
        return { ...baseScene, handles };
    }

    // Group-local rigid transforms (pos + rot) per member. A group is a tree (each merge joins two
    // disjoint parts with one edge), so transforms propagate from an arbitrary origin over the
    // UNDIRECTED edge graph — traversing an edge either way.
        }
        const seen = new Set<string>();
        const comps: string[][] = [];
        for (const m of members) {
            if (seen.has(m)) continue;
            const comp: string[] = [];
            const queue = [m];
            seen.add(m);
            while (queue.length) {
                const cur = queue.shift()!;
                comp.push(cur);
                for (const nb of adj.get(cur) ?? [])
                    if (!seen.has(nb)) {
                        seen.add(nb);
                        queue.push(nb);
                    }
            }
            comps.push(comp);
        }
        return comps;
    }

    private groupNodeHandle(g: Group): Handle {
        return {
            id: g.key,
            tags: [roleTag(ROLE.assembly)],
            components: [
                {
                    _resourceClass: "component_transform",
                    key: g.key,
                    parentKey: ROOT_KEY,
                    translation: g.transform.pos,
                    rotation: g.transform.rot,
                    scale: [1, 1, 1],
                },
            ],
        };
    }

    private sceneRootHandle(): Handle {
        return {
            id: ROOT_KEY,
            tags: [roleTag(ROLE.scene)],
            components: [
                {
                    _resourceClass: "component_transform",
                    key: ROOT_KEY,
                    translation: [0, 0, 0],
                    rotation: [0, 0, 0],
                    scale: [1, 1, 1],
                },


grep -rn "group-create" packages/frontend/src --include='*.ts' | grep -v test | head; echo ---; grep -n "buildCreateEvent\|insertProduct\|addProduct" packages/frontend/src/configurator/events/DefaultCommandProcessor.ts packages/frontend/src/configurator/model/GroupProcessor.ts | head

Find where group-create events are built on insert


packages/frontend/src/configurator/model/GroupProcessor.ts:53:    type: "group-create";
packages/frontend/src/configurator/model/GroupProcessor.ts:136:            type: "group-create",
packages/frontend/src/configurator/model/GroupProcessor.ts:382:            case "group-create": {
---
packages/frontend/src/configurator/events/DefaultCommandProcessor.ts:83:        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
packages/frontend/src/configurator/model/GroupProcessor.ts:133:    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {


sed -n '120,170p' packages/frontend/src/configurator/model/GroupProcessor.ts; echo ===; sed -n '60,92p' packages/frontend/src/configurator/events/DefaultCommandProcessor.ts

Read buildCreateEvent and the insert handler


Error: Exit code 1

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

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

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

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

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

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


sed -n '170,215p' packages/frontend/src/configurator/model/GroupProcessor.ts

Read rest of reidentify


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

    // Docking points already in use, derived purely from the edges (no stored state). Each edge
    // occupies its `fromPmi` on `from` and its `toPmi` on `to`. Open ends are everything else.
    occupiedPmis(): { element: string; pmi: string }[] {
        const out: { element: string; pmi: string }[] = [];
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                out.push({ element: e.from, pmi: e.fromPmi });
                out.push({ element: e.to, pmi: e.toPmi });
            }
        }
        return out;
    }

    groupCount(): number {
        return this.groups.size;
    }

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


python3 - <<'EOF'
import json
f='packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json'
d=json.load(open(f))
for h in d['handles']:
    comps=h.get('components',[])
    rcs=[c.get('_resourceClass') for c in comps]
    if 'component_renderer' in rcs or any('render' in str(r) for r in rcs):
        c=next(c for c in comps if 'render' in str(c.get('_resourceClass')))
        print(json.dumps(c)[:500]); break
else:
    # kein renderer: alle resourceClasses zeigen
    from collections import Counter
    cnt=Counter(c.get('_resourceClass') for h in d['handles'] for c in h.get('components',[]))
    print(cnt)
EOF

Check how geometry is attached in product JSON


Counter({'component_pmi': 216, 'component_transform': 58, 'component_collider': 56})


python3 - <<'EOF'
import json
from collections import Counter
d=json.load(open('/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/8ef2174d-a2d8-4207-97b0-ee343422282d/scratchpad/zaun_old.json'))
cnt=Counter(c.get('_resourceClass') for h in d['handles'] for c in h.get('components',[]))
print('altes zaun.json:',cnt)
r=next(c for h in d['handles'] for c in h.get('components',[]) if c.get('_resourceClass')=='component_renderer')
print(json.dumps(r)[:400])
EOF

Show component mix and renderer shape in old working zaun.json


Error: Exit code 1
altes zaun.json: Counter({'component_collider': 66, 'component_transform': 53, 'component_pmi': 2})
Traceback (most recent call last):
  File "<stdin>", line 6, in <module>
StopIteration


python3 - <<'EOF'
import json
old=json.load(open('/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/8ef2174d-a2d8-4207-97b0-ee343422282d/scratchpad/zaun_old.json'))
new=json.load(open('packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json'))
def first_collider(d):
    for h in d['handles']:
        for c in h.get('components',[]):
            if c.get('_resourceClass')=='component_collider':
                return c
print('ALT:', json.dumps(first_collider(old))[:600]); print()
print('NEU:', json.dumps(first_collider(new))[:600])
EOF

Compare collider components old vs new asset


ALT: {"_resourceClass": "component_collider", "type": 2, "shapeType": 4, "center": [-248.432807922, -168.384811401, -95], "dimension": [-79.9999847412, -40, -10]}

NEU: {"_resourceClass": "component_collider", "type": 2, "shapeType": 4, "center": [0.0, 0.0, -95.0], "dimension": [-40, -40, -10]}


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

Find selection-click emission in fire bundle


node -e "
const src=require('fs').readFileSync('packages/frontend/node_modules/@ad/fire/browser/index.js','utf8');
const i=src.indexOf('processInput(e){this.renderContext&&');
console.log(src.slice(i,i+1800).replace(/\n/g,' '));
"

Read fire selection processInput picking logic


node -e "
const src=require('fs').readFileSync('packages/frontend/node_modules/@ad/fire/browser/index.js','utf8');
const i=src.indexOf('PmiSelectableUiGizmo');
// find class def occurrence after export map
let j=src.indexOf('isSelectionClickable',0);
while(j!==-1){ const ctx=src.slice(j-200,j+600).replace(/\n/g,' '); if(ctx.includes('collider')||ctx.includes('trigger')||ctx.includes('Gizmo')){ console.log('=== @'+j+' ==='); console.log(ctx); } j=src.indexOf('isSelectionClickable',j+1);}
" 2>&1 | head -30

Find where isSelectionClickable connects to colliders/gizmos


=== @1356101 ===
ent(e)}),this.addEventListener(e,te,e=>{e.entity instanceof this.componentType&&this.onDestroyComponent(e)}),this._initialized=!0}onCreateComponent(e){const t=e.entity,n={colliders:[],callbacks:[]};t.isSelectionClickable()&&this.buildColliders(t,n),n.callbacks=this.registerCallbacks(t,n),this.elements[e.entity.index]=n}buildColliders(e,t){const n=Wn.isType(e)?o.Matrix4_4.decompose(e.LTW)[0]:o.Vector3.zero;for(const r of this.createCollider(e)){const i=this.rootHandleManager.create({id:`${Me.getLabel(e).toLocaleLowerCase()}`,tags:["selectable"]},n);this._collidersRoot.addChild(i);const s=i.addComponent(Ci,r);this.selectableManager.register(s,e),t.colliders.push(s)}}onDestroyComponent(e){const t=e.entity,n=this.elements[e.entity.index];if(n){for(const e of n.callbacks)e.unsubscribe();delete
=== @1357031 ===
){const t=e.handle;e?.destroy(),t?.destroy()}}recreateColliders(e,t){this.selectableManager.deregister(e);for(const e of t.colliders){const t=e.handle;e?.destroy(),t?.destroy()}t.colliders.length=0,e.isSelectionClickable()&&this.buildColliders(e,t)}destroy(){for(const e of Object.values(this.elements))for(const t of e.callbacks)t.unsubscribe();this._collidersRoot.children.forEach(e=>{const t=e.getComponent(Ci);t&&(this.selectableManager.deregisterCollider(t),e.destroy())}),this._collidersRoot?.destroy(),this._initialized=!1,super.destroy()}}(0,r.Cg)([(0,s.PropertyResolver)(Hg),(0,r.Sn)("design:type","function"==typeof(pf=void 0!==Hg&&Hg)?pf:Object)],ff.prototype,"selectableManager",void 0);let mf=class extends tr{get background(){return this._background}set background(e){this._background=e


Ursache gefunden. Die Kette:

1. fire baut für jede PMI-Komponente mit `type ≠ none` und `selectable` (Default true) eigene **Selection-Collider** und registriert sie im SelectableManager (`isSelectionClickable()` → `buildColliders`).
2. Beim Klick nimmt der CursorSelector den ersten Raycast-Treffer; der wird über den SelectableManager auf die PMI-Komponente aufgelöst — nicht auf das Teil. `target.id` im `selection-click`-Event ist dann die PMI (z. B. `CS_…`), keine Handle-ID der Szene.
3. `selectableAncestors(pickedId)` findet dazu nichts im nodeIndex → leere Kette → `return [stop]` → keine Selektion.

Die alten Assets funktionierten, weil ihre PMIs **kein `type`-Feld** hatten (→ `none` → nicht klickbar, keine Selection-Collider). Mit der Konvertierung auf `point` (nötig für die DropPoints) wurden alle ~200 PMIs pro Teil klickbar und schlucken jetzt jeden Klick. Das deckt sich mit der TEMP-Logzeile in `Fire3D.vue:173` aus der früheren Analyse — in der Browser-Konsole müsste `target.id` eine PMI-Referenz statt einer `h-…`-Handle-ID zeigen.

**Fix (nur Assets, kein Code):** In allen Produkt-JSONs bekommt jede `component_pmi` zusätzlich `"selectable": false`. `isSelectionClickable()` wird damit false → keine Selection-Collider → Klicks treffen wieder die Teil-Collider. Die Platzierung bleibt intakt: `getDropPoint()` hängt nur am `type`, nicht an `selectable`.

Umsetzung per Skript über `packages/frontend/.storybook/products/**/**.json`. Go vorausgesetzt, führe ich das aus.


tu das


python3 - <<'EOF'
import json, glob
files = glob.glob('packages/frontend/.storybook/products/**/*.json', recursive=True)
changed = skipped = untouched = 0
for f in files:
    try:
        with open(f, encoding='utf-8') as fh: d = json.load(fh)
    except Exception as e:
        print('SKIP (parse):', f, e); skipped += 1; continue
    if not isinstance(d, dict) or 'handles' not in d:
        untouched += 1; continue
    n = 0
    for h in d['handles']:
        for c in h.get('components', []):
            if c.get('_resourceClass') == 'component_pmi' and c.get('selectable') is not False:
                c['selectable'] = False; n += 1
    if n:
        with open(f, 'w', encoding='utf-8') as fh: json.dump(d, fh, ensure_ascii=False)
        changed += 1
    else:
        untouched += 1
print('geändert:', changed, '| unverändert:', untouched, '| übersprungen:', skipped)
EOF

Add selectable:false to all component_pmi in product JSONs


SKIP (parse): packages/frontend/.storybook/products/schutzzaun_basic/schutzzaun_basic.json 'utf-8' codec can't decode byte 0xc9 in position 16667: invalid continuation byte
SKIP (parse): packages/frontend/.storybook/products/schutzzaun_allround/schutzzaun_allround.json 'utf-8' codec can't decode byte 0xc9 in position 17439: invalid continuation byte
SKIP (parse): packages/frontend/.storybook/products/rollenfoerderer/rollenfoerderer.json 'utf-8' codec can't decode byte 0xf6 in position 482: invalid start byte
geändert: 162 | unverändert: 1 | übersprungen: 3


python3 - <<'EOF'
import json
for f in ['packages/frontend/.storybook/products/schutzzaun_basic/schutzzaun_basic.json',
          'packages/frontend/.storybook/products/rollenfoerderer/rollenfoerderer.json']:
    d=json.load(open(f, encoding='latin-1'))
    pmis=sum(1 for h in d.get('handles',[]) for c in h.get('components',[]) if c.get('_resourceClass')=='component_pmi') if isinstance(d,dict) else 0
    print(f.split('/')[-1], '| keys:', list(d)[:4] if isinstance(d,dict) else type(d), '| pmi:', pmis)
EOF

Check skipped latin-1 files for PMI content


Error: Exit code 1
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/json/__init__.py", line 293, in load
    return loads(fp.read(),
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/json/decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/json/decoder.py", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 2 column 1 (char 1)


head -c 200 packages/frontend/.storybook/products/schutzzaun_basic/schutzzaun_basic.json | LC_ALL=C od -c | head -8; file packages/frontend/.storybook/products/schutzzaun_basic/schutzzaun_basic.json

Inspect encoding of skipped group file


0000000   \r  \n   #   #   #       A   r   t   i   k   e   l       D   e
0000020    f   i   n   i   t   i   o   n  \r  \n   #       A   r   t   i
0000040    k   e   l       S   B   E   0   1   7   4   P   N   A  \r  \n
0000060    j   s   o   n       s   e   t       j   s   o   n   _   s   t
0000100    r   i   n   g       A   R   T   I   K   E   L       S   B   E
0000120    0   1   7   4   P   N   A       k   a   t   a   l   o   g   g
0000140    r   u   p   p   e       "   E   l   e   m   e   n   t   "  \r
0000160   \n   j   s   o   n       s   e   t       j   s   o   n   _   s
packages/frontend/.storybook/products/schutzzaun_basic/schutzzaun_basic.json: ISO-8859 text, with CRLF line terminators


Erledigt. 162 Produkt-JSONs unter `packages/frontend/.storybook/products/` geändert: jede `component_pmi` hat jetzt `"selectable": false`. Die drei übersprungenen Dateien (`schutzzaun_basic.json`, `schutzzaun_allround.json`, `rollenfoerderer.json`) sind keine Szenegraphen, sondern Artikel-Definitionsskripte im ISO-8859-Format — dort gibt es keine PMIs.


Keine Änderrung
oder hätte ich etwas tun müssen, außer neu laden


Überprüfen, ob Seite aktualisiert werden muss


grep -rn "localStorage\|sessionStorage\|indexedDB" packages/frontend/src --include='*.ts' --include='*.vue' | grep -v test | head

Check whether the event store or scene is persisted


Keine Änderrung oder hätte ich etwas tun müssen, außer neu laden


packages/frontend/src/components/ThemeEditor.stories.ts:188:                const saved = sessionStorage.getItem(SESSION_KEY);
packages/frontend/src/components/ThemeEditor.stories.ts:191:                /* sessionStorage not available */
packages/frontend/src/components/ThemeEditor.stories.ts:218:                    const saved = sessionStorage.getItem(SESSION_KEY);
packages/frontend/src/components/base/BaseButton.stories.ts:144:                    const saved = sessionStorage.getItem(SESSION_KEY);
packages/frontend/src/components/base/BaseTextarea.stories.ts:95:                    const saved = sessionStorage.getItem(SESSION_KEY);
packages/frontend/src/components/base/BaseInput.stories.ts:98:                    const saved = sessionStorage.getItem(SESSION_KEY);
packages/frontend/src/components/base/BaseSelect.stories.ts:99:                    const saved = sessionStorage.getItem(SESSION_KEY);
packages/frontend/src/components/building-blocks/ToastNotification.stories.ts:74:                    const saved = sessionStorage.getItem(SESSION_KEY);
packages/frontend/src/components/building-blocks/ConfirmDialog.stories.ts:93:                    const saved = sessionStorage.getItem(SESSION_KEY);
packages/frontend/src/components/building-blocks/AppDialog.stories.ts:87:                    const saved = sessionStorage.getItem(SESSION_KEY);


Zwei Dinge brauche ich zur Klärung:

1. Hard-Reload gemacht (Cmd+Shift+R)? Die Produkt-JSONs kommen per `fetch("/products/…")` — der Browser-HTTP-Cache kann die alten Dateien liefern. Ein normales Neuladen reicht dann nicht.
2. Falls es danach immer noch klemmt: In der Browser-Konsole steht bei jedem Klick eine Zeile `🔎 fire-event: selection-click …` (die TEMP-Analyse in `Fire3D.vue:173`). Gib mir davon `target.id`, `target.selector` und `target.tags` — daran sehe ich sofort, ob der Klick auf einer PMI, einem Collider-Handle oder ins Leere geht.

Sonst war nichts zu tun: Die Szene wird bei jedem Einfügen frisch aus den JSONs gebaut, es gibt keine Persistenz. Wenn der Hard-Reload nichts ändert, ist meine Selectable-Diagnose falsch und die Konsolenzeile zeigt den tatsächlichen Treffer.


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(1) > handle#gk-a1272852-1eaf-4fe8-a9a7-360293c5136e.role-assembly:nth-child(1) > handle#h-26bc7f6d-b99a-4d8b-afd4-a5869fe89f64.role-part:nth-child(1) > handle#h-304da4dd-c9bd-41f5-b32f-2e03fb47581d.name-assembly-root:nth-child(1) > handle#h-8d3f8acd-ea72-4163-ad52-25a36a55f238.name-EINZELTUER:nth-child(1) > handle#h-8fb92e90-dbae-4133-967a-ddc74ea975c6.name-generic.part-FUELLUNG_LINKS_OBEN:nth-child(25) | target.tags: (2) ['name-generic', 'part-FUELLUNG_LINKS_OBEN'] | action: set | raw: {eventTypeName: 'selection-click', selectionAction: 'set', target: {…}}
Fire3D.vue:175 🔎 fire-event: selection | target.id: undefined | target.selector: undefined | target.tags: undefined | action: undefined | raw: {eventTypeName: 'selection', selected: Array(1)}


sed -n '30,90p' packages/frontend/src/configurator/events/DefaultCommandProcessor.ts

Read handleEvent dispatch including selection case


        );
    }

    handleEvent(event: any): UIReaction[] {
        switch (event?.eventTypeName) {
            case "interaction-mode":
                if (event.lifecycleState !== "update") return [];
                return this.handleInteractionMode(event);
            case "add-product":
                return this.handleAddProduct(event);
            case "detach-element":
                return this.handleDetachElement(event);
            case "escalate-selection":
                return this.handleEscalateSelection(event);
            case "prepare-action":
                return this.handlePrepareAction(event);
            case "undo":
                return this.handleUndo();
            case "redo":
                return this.handleRedo();
            case "esc":
                return this.handleEsc();
            case "context-click":
                return this.getContextForSelection(event);
            case "selection-click":
                return this.handleSelectionClickEvent(event);
            case "selection":
                return [];
            default:
                console.error("unknown event type ", event?.eventTypeName);
                return [];
        }
    }

    private handleEsc(): UIReaction[] {
        // Cancel a prepared-but-uncommitted regroup and resync the engine to the real model.
        if (this.pendingRegroup) {
            this.pendingRegroup = null;
            const scene = this.replay();
            return [
                { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
                { type: "sendCommand", cmd: "setScene", args: [scene] },
            ];
        }
        return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
    }

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


sed -n '1,60p' packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts

Read existing test setup to mirror the API


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

const product = () => structuredClone(productData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });

const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const selOf = (r: any[]) => (r.find((x: any) => x.cmd === "set_selection") as any)?.args[0] as string[] | undefined;
const cmd = (r: any[], name: string) => r.find((x: any) => x.cmd === name) as any;
const tagged = (scene: any, role: string): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes(`role-${role}`)).map((h: any) => h.id);
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;

const click = (id: string, action: "set" | "add") => ({ eventTypeName: "selection-click", target: { id }, selectionAction: action });
const placement = (subject: string, o: { drag?: string; dragPmi?: string; drop?: string; dropPmi?: string; pos: number[] }) => ({
    eventTypeName: "interaction-mode",
    modeName: "placement",
    lifecycleState: "update",
    subject: { entity: { id: subject } },
    position: { values: o.pos },
    rotation: { values: [1, 0, 0, 0] },
    dragPointSelector: o.drag ? `handle#${o.drag} pmi#${o.dragPmi}` : undefined,
    dropPointSelector: o.drop ? `handle#${o.drop} pmi#${o.dropPmi}` : undefined,
});
const moveEnd = (key: string, pos: number[]) => ({
    eventTypeName: "interaction-mode",
    modeName: "movement",
    lifecycleState: "update",
    subjects: [{ entity: { id: key } }],
    positions: [{ values: pos }],
    delta: { values: pos },
});

// Three products merged into a chain a-b-c via the processor's own event flow.
function chain() {
    const proc = new DefaultCommandProcessor();
    proc.setInitialScene(base());
    let scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    const [a, b, c] = tagged(scene, "part");
    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, b), { drag: b, dragPmi: "CS_LINKS", drop: a, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
    scene = sceneOf(proc.handleEvent(placement(groupNodeOf(scene, c), { drag: c, dragPmi: "CS_LINKS", drop: b, dropPmi: "CS_RECHTS", pos: [0, 0, 0] })));
    return { proc, scene, a, b, c };
}

describe("selection — multi-select", () => {
    it("set / add / toggle builds the selection set", () => {
        const { proc, a, b, c } = chain();
        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]);
        expect(selOf(proc.handleEvent(click(b, "add")))).toEqual([`#${a}`, `#${b}`]);
        expect(selOf(proc.handleEvent(click(c, "add")))).toEqual([`#${a}`, `#${b}`, `#${c}`]);
        expect(selOf(proc.handleEvent(click(b, "add")))).toEqual([`#${a}`, `#${c}`]); // toggle b off
        expect(selOf(proc.handleEvent(click(a, "set")))).toEqual([`#${a}`]); // plain click resets
    });
});


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…


Write

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}`]);
    });
});


File created successfully at: /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/__scratch_selection.test.ts (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}`]);
    });
});


Bash

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

Run scratch leaf-click selection test


stdout | src/configurator/events/__scratch_selection.test.ts > scratch: leaf click on real asset > selecting via a leaf handle resolves to the part root
leaf: undefined | partRoot: h-3e31a3f5-722c-4e33-950d-91acbaf5a154

 ❯ src/configurator/events/__scratch_selection.test.ts (1 test | 1 failed) 7ms
     × selecting via a leaf handle resolves to the part root 7ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/configurator/events/__scratch_selection.test.ts > scratch: leaf click on real asset > selecting via a leaf handle resolves to the part root
TypeError: Cannot read properties of undefined (reading 'id')
 ❯ src/configurator/events/__scratch_selection.test.ts:20:55
     18|         console.log("leaf:", leaf?.id, "| partRoot:", partRoot?.id);
     19|
     20|         const sel = selOf(proc.handleEvent(click(leaf.id)));
       |                                                       ^
     21|         console.log("set_selection:", sel);
     22|         expect(sel).toEqual([`#${partRoot.id}`]);

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  10:23:41
   Duration  4.35s (transform 89ms, setup 0ms, import 142ms, tests 7ms, environment 3.92s)

undefined
/Users/marcus.hinz/projects/adesso-cpq/packages/frontend:
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command failed with exit code 1: vitest run src/configurator/events/__scratch_selection.test.ts


Edit

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