Edit

    blockedPmis(): { element: string; pmi: string }[] {
        const occ = this.occupiedPmis();
        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));
        const extra: { element: string; pmi: string }[] = [];
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi); // straight element↔element junction only
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    const aP = `CS_OUT_${sideA}_${d}`;
                    const bP = `CS_OUT_${sideB}_${d}`;
                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });
                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });
                }
            }
        }
        return [...occ, ...extra];
    }

    blockedPmis(): { element: string; pmi: string }[] {
        const eff = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch
        // slot per direction, so an occupied 90/270 on one post also occupies the neighbour's mirror.
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    if (eff.has(`${e.from} CS_OUT_${sideA}_${d}`)) eff.add(`${e.to} CS_OUT_${sideB}_${d}`);
                    if (eff.has(`${e.to} CS_OUT_${sideB}_${d}`)) eff.add(`${e.from} CS_OUT_${sideA}_${d}`);
                }
            }
        }
        // A built branch also takes the middle (straight) post via its coincident _B point, so at one
        // end only one of 0/90/270 can sit: an occupied 90 or 270 blocks that end's straight-0 and the
        // other branch. (A straight-0 build is asymmetric — it leaves 90/270 open.)
        const out = new Set(eff);
        for (const key of eff) {
            const m = /^(.+) CS_OUT_([LR])_(90|270)$/.exec(key);
            if (!m) continue;
            const [, el, side, d] = m;
            out.add(`${el} CS_OUT_${side}_0`);
            out.add(`${el} CS_OUT_${side}_${d === "90" ? "270" : "90"}`);
        }
        return [...out].map((k) => {
            const sp = k.lastIndexOf(" ");
            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };
        });
    }


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

    blockedPmis(): { element: string; pmi: string }[] {
        const occ = this.occupiedPmis();
        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));
        const extra: { element: string; pmi: string }[] = [];
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi); // straight element↔element junction only
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    const aP = `CS_OUT_${sideA}_${d}`;
                    const bP = `CS_OUT_${sideB}_${d}`;
                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });
                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });
                }
            }
        }
        return [...occ, ...extra];
    }

    blockedPmis(): { element: string; pmi: string }[] {
        const eff = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch
        // slot per direction, so an occupied 90/270 on one post also occupies the neighbour's mirror.
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    if (eff.has(`${e.from} CS_OUT_${sideA}_${d}`)) eff.add(`${e.to} CS_OUT_${sideB}_${d}`);
                    if (eff.has(`${e.to} CS_OUT_${sideB}_${d}`)) eff.add(`${e.from} CS_OUT_${sideA}_${d}`);
                }
            }
        }
        // A built branch also takes the middle (straight) post via its coincident _B point, so at one
        // end only one of 0/90/270 can sit: an occupied 90 or 270 blocks that end's straight-0 and the
        // other branch. (A straight-0 build is asymmetric — it leaves 90/270 open.)
        const out = new Set(eff);
        for (const key of eff) {
            const m = /^(.+) CS_OUT_([LR])_(90|270)$/.exec(key);
            if (!m) continue;
            const [, el, side, d] = m;
            out.add(`${el} CS_OUT_${side}_0`);
            out.add(`${el} CS_OUT_${side}_${d === "90" ? "270" : "90"}`);
        }
        return [...out].map((k) => {
            const sp = k.lastIndexOf(" ");
            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };
        });
    }

// CS_OUT_<otherSide>_<same> on the neighbour. Both the placement drop points and the select

// markers read this, so the rule is graph-constant across positioning and display.

-        const occ = this.occupiedPmis();

-        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));

-        const extra: { element: string; pmi: string }[] = [];

+        const eff = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));

+        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch

+        // slot per direction, so an occupied 90/270 on one post also occupies the neighbour's mirror.

-                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi); // straight element↔element junction only

+                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);

-                    const aP = `CS_OUT_${sideA}_${d}`;

-                    const bP = `CS_OUT_${sideB}_${d}`;

-                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });

-                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });

+                    if (eff.has(`${e.from} CS_OUT_${sideA}_${d}`)) eff.add(`${e.to} CS_OUT_${sideB}_${d}`);

+                    if (eff.has(`${e.to} CS_OUT_${sideB}_${d}`)) eff.add(`${e.from} CS_OUT_${sideA}_${d}`);

+        // A built branch also takes the middle (straight) post via its coincident _B point, so at one

+        // end only one of 0/90/270 can sit: an occupied 90 or 270 blocks that end's straight-0 and the

+        // other branch. (A straight-0 build is asymmetric — it leaves 90/270 open.)

+            out.add(`${el} CS_OUT_${side}_0`);

+            const sp = k.lastIndexOf(" ");

+            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };


ok das gleice gilt natürlich auch in 0- Richtung, wenn auf 90 und / oder 270 eingebaut ist auch hier darf von den drei Stehern NUR EINER (der Mittlere ) belegt sein Gilt ebenso für Snap wir für die Se…


Rollenförderer Visualisierung und Fehler beheben


Bash

cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
pnpm exec vue-tsc --noEmit -p tsconfig.json 2>&1 | grep -E "GroupProcessor|DefaultCommandProcessor" | head; echo done

Typecheck


done


Typecheck sauber. Tests laufen lassen:


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
pnpm exec vitest run GroupProcessor DefaultCommandProcessor 2>&1 | tail -25

Run GroupProcessor and processor tests


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

 Test Files  2 passed (2)
      Tests  25 passed (25)
   Start at  18:34:24
   Duration  682ms (transform 101ms, setup 0ms, import 125ms, tests 258ms, environment 781ms)


Alle 25 Tests grün, Typecheck sauber.

Zusammengefasst in `blockedPmis()`, jetzt in einer Ableitung:
- **Junction-Spiegelung (90/270):** deckungsgleiche Steher zweier gerade verbundener Elemente teilen ihren Slot.
- **Mittlerer Steher (0):** ein gebauter Branch (90/270) belegt über seinen `_B`-Punkt den mittleren Steher mit → sperrt an dem Ende die 0-Richtung und den anderen Branch. Gerader 0-Anbau bleibt asymmetrisch (lässt 90/270 offen).

Beides greift automatisch für Snap-Points (`placementDragPoints`) und Selektor-Kugeln (`withSelectionMarkers`).

Eine Annahme habe ich getroffen: die Asymmetrie (0 sperrt Branches nicht, Branch sperrt 0). Falls pro Ende strikt nur *ein* Anbau gelten soll (auch 0 sperrt 90/270), sag Bescheid — dann fällt die Sonderbehandlung weg.


neee - jetzt ist es kaputt
Element gesetzt --> selektopunkte da -> OK
Anbau recht nach hinten -> ok
Element selektiert
Erwartung Links alle drei SElektoren (-> ok) Recht Selektor au 0 und 90 (beide nicht da)


Das war mein letzter Change — die „mittlerer Steher"-Sperre. Sie ist falsch: ein einzelner 270-Anbau soll 0 und 90 **nicht** sperren. Ich nehme sie wieder raus, zurück auf reine 90/270-Junction-Spiegelung (die für deinen Test genau stimmt):


Edit

    blockedPmis(): { element: string; pmi: string }[] {
        const eff = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch
        // slot per direction, so an occupied 90/270 on one post also occupies the neighbour's mirror.
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    if (eff.has(`${e.from} CS_OUT_${sideA}_${d}`)) eff.add(`${e.to} CS_OUT_${sideB}_${d}`);
                    if (eff.has(`${e.to} CS_OUT_${sideB}_${d}`)) eff.add(`${e.from} CS_OUT_${sideA}_${d}`);
                }
            }
        }
        // A built branch also takes the middle (straight) post via its coincident _B point, so at one
        // end only one of 0/90/270 can sit: an occupied 90 or 270 blocks that end's straight-0 and the
        // other branch. (A straight-0 build is asymmetric — it leaves 90/270 open.)
        const out = new Set(eff);
        for (const key of eff) {
            const m = /^(.+) CS_OUT_([LR])_(90|270)$/.exec(key);
            if (!m) continue;
            const [, el, side, d] = m;
            out.add(`${el} CS_OUT_${side}_0`);
            out.add(`${el} CS_OUT_${side}_${d === "90" ? "270" : "90"}`);
        }
        return [...out].map((k) => {
            const sp = k.lastIndexOf(" ");
            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };
        });
    }

    blockedPmis(): { element: string; pmi: string }[] {
        const occ = this.occupiedPmis();
        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));
        const extra: { element: string; pmi: string }[] = [];
        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch
        // slot per direction, so an occupied 90/270 on one post also blocks the neighbour's mirror.
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    const aP = `CS_OUT_${sideA}_${d}`;
                    const bP = `CS_OUT_${sideB}_${d}`;
                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });
                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });
                }
            }
        }
        return [...occ, ...extra];
    }


    blockedPmis(): { element: string; pmi: string }[] {
        const eff = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch
        // slot per direction, so an occupied 90/270 on one post also occupies the neighbour's mirror.
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    if (eff.has(`${e.from} CS_OUT_${sideA}_${d}`)) eff.add(`${e.to} CS_OUT_${sideB}_${d}`);
                    if (eff.has(`${e.to} CS_OUT_${sideB}_${d}`)) eff.add(`${e.from} CS_OUT_${sideA}_${d}`);
                }
            }
        }
        // A built branch also takes the middle (straight) post via its coincident _B point, so at one
        // end only one of 0/90/270 can sit: an occupied 90 or 270 blocks that end's straight-0 and the
        // other branch. (A straight-0 build is asymmetric — it leaves 90/270 open.)
        const out = new Set(eff);
        for (const key of eff) {
            const m = /^(.+) CS_OUT_([LR])_(90|270)$/.exec(key);
            if (!m) continue;
            const [, el, side, d] = m;
            out.add(`${el} CS_OUT_${side}_0`);
            out.add(`${el} CS_OUT_${side}_${d === "90" ? "270" : "90"}`);
        }
        return [...out].map((k) => {
            const sp = k.lastIndexOf(" ");
            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };
        });
    }

    blockedPmis(): { element: string; pmi: string }[] {
        const occ = this.occupiedPmis();
        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));
        const extra: { element: string; pmi: string }[] = [];
        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch
        // slot per direction, so an occupied 90/270 on one post also blocks the neighbour's mirror.
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    const aP = `CS_OUT_${sideA}_${d}`;
                    const bP = `CS_OUT_${sideB}_${d}`;
                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });
                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });
                }
            }
        }
        return [...occ, ...extra];
    }

// CS_OUT_<otherSide>_<same> on the neighbour. Both the placement drop points and the select

// markers read this, so the rule is graph-constant across positioning and display.

-        const eff = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));

+        const occ = this.occupiedPmis();

+        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));

+        const extra: { element: string; pmi: string }[] = [];

-        // slot per direction, so an occupied 90/270 on one post also occupies the neighbour's mirror.

+        // slot per direction, so an occupied 90/270 on one post also blocks the neighbour's mirror.

-                    if (eff.has(`${e.from} CS_OUT_${sideA}_${d}`)) eff.add(`${e.to} CS_OUT_${sideB}_${d}`);

-                    if (eff.has(`${e.to} CS_OUT_${sideB}_${d}`)) eff.add(`${e.from} CS_OUT_${sideA}_${d}`);

+                    const aP = `CS_OUT_${sideA}_${d}`;

+                    const bP = `CS_OUT_${sideB}_${d}`;

+                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });

+                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });

-        // A built branch also takes the middle (straight) post via its coincident _B point, so at one

-        // end only one of 0/90/270 can sit: an occupied 90 or 270 blocks that end's straight-0 and the

-        // other branch. (A straight-0 build is asymmetric — it leaves 90/270 open.)

-            out.add(`${el} CS_OUT_${side}_0`);

-            const sp = k.lastIndexOf(" ");

-            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };


neee - jetzt ist es kaputt Element gesetzt --> selektopunkte da -> OK Anbau recht nach hinten -> ok Element selektiert Erwartung Links alle drei SElektoren (-> ok) Recht Selektor au 0 und 90 (beide ni…


Bash

cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
pnpm exec vitest run GroupProcessor DefaultCommandProcessor 2>&1 | tail -6

Run tests after revert


 Test Files  2 passed (2)
      Tests  25 passed (25)
   Start at  18:42:11
   Duration  655ms (transform 98ms, setup 0ms, import 130ms, tests 222ms, environment 760ms)


Zurückgenommen, Tests grün. Damit stimmt dein Test wieder: 270 rechts angebaut → rechts `0` und `90` sichtbar, `270` weg; links alle drei. Es bleibt die 90/270-Junction-Spiegelung.

Die „mittlerer Steher"-Regel aus der Nachricht davor habe ich falsch modelliert (sie hat genau diesen Einzel-Branch-Fall zerschossen) und ist jetzt raus.

Damit ich sie richtig baue, brauche ich den konkreten Fall — denn ein einzelner Branch (nur 270) sperrt offenbar weder 0 noch 90. Wann greift „von den drei Stehern nur der mittlere"? Beschreib bitte die Konfiguration in Bau-Schritten und was danach verdeckt sein soll, z. B.:

- erst 90 **und** 270 bauen → dann ist 0 gesperrt?
- oder: gerade (0) angebaut, dahinter noch ein Element → an der Junction ein Branch, und dann?

Welche Bau-Reihenfolge führt zu „nur der mittlere belegt"?


Ausgangskonstalation

ELMENT_1 = URSPRUNG
ElEMENT_2 = ANBAU 270
ELENENT_3 = Anbau 90

Selection URSPRUNG
Ewartung: NUR SELEKTO 0 wird gezeigt --> OK
Selection ANBAU_270
Erawartung: NUR SELEKTOR 270 Wird gezeigt (Sektor 0 belegt durch ANBAU_90 / Selektor 90 belegt durch URSPRUNG)
Ist -> Selektor 90 UND 270 werden gezeigt
Selection ANBAU_90
Erwartung: NUR SELEKTOR 90 wird gezeigt (Selektor 0 belegt durch ANBAU_270 / Selektor 270 belegt durch URSPRUNG)


You're debugging a three-element junction constraint that's failing in multiple directions. A subagent could trace the full state of each element's occupied/blocked PMIs independently while you stay f

Ask Claude to "use a subagent to investigate this"


Ausgangskonstalation

ELMENT_1 = URSPRUNG
ElEMENT_2 = ANBAU 270
ELENENT_3 = Anbau 90

Selection URSPRUNG
Ewartung: NUR SELEKTO 0 wird gezeigt --> OK
Selection ANBAU_270
Erawartung: NUR SELEKTOR 270 Wird gezeigt (Sektor 0 belegt durch ANBAU_90 / Selektor 90 belegt durch URSPRUNG)
Ist -> Selektor 90 UND 270 werden gezeigt
Selection ANBAU_90
Erwartung: NUR SELEKTOR 90 wird gezeigt (Selektor 0 belegt durch ANBAU_270 / Selektor 270 belegt durch URSPRUNG)
Ist --> Selektoren 90 und 270 werden gezwigt)


Der Fall sind drei Elemente an *einer* Junction — meine Spiegelung greift aber nur über gerade (0-)Kanten, nicht über Branch-Kanten. Bevor ich rate, rechne ich die Weltpositionen der Anker in genau dieser Konstellation aus und suche die Koinzidenzen — daraus leite ich die echte Regel ab.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
python3 << 'EOF'
import math
def qmul(a,b):
    aw,ax,ay,az=a; bw,bx,by,bz=b
    return [aw*bw-ax*bx-ay*by-az*bz, aw*bx+ax*bw+ay*bz-az*by,
            aw*by-ax*bz+ay*bw+az*bx, aw*bz+ax*by-ay*bx+az*bw]
def qconj(q): return [q[0],-q[1],-q[2],-q[3]]
def qrot(q,v):
    w,x,y,z=q
    tx=2*(y*v[2]-z*v[1]); ty=2*(z*v[0]-x*v[2]); tz=2*(x*v[1]-y*v[0])
    return [v[0]+w*tx+(y*tz-z*ty), v[1]+w*ty+(z*tx-x*tz), v[2]+w*tz+(x*ty-y*tx)]
def eu(r):
    x,y,z=r
    cx,cy,cz=math.cos(x/2),math.cos(y/2),math.cos(z/2)
    sx,sy,sz=math.sin(x/2),math.sin(y/2),math.sin(z/2)
    return [cx*cy*cz+sx*sy*sz, sx*cy*cz-cx*sy*sz, cx*sy*cz+sx*cy*sz, cx*cy*sz-sx*sy*cz]
P=math.pi
pos={'CS_EIN_L':[-20,1067,-20],'CS_EIN_R':[1064,1067,-20],
 'CS_OUT_R_0':[1114,1067,-20],'CS_OUT_L_0':[-70,1067,-20],
 'CS_OUT_R_90':[1064,1067,-110],'CS_OUT_L_90':[-20,1067,-110],
 'CS_OUT_R_270':[1064,1067,30],'CS_OUT_L_270':[-20,1067,30]}
rot={'CS_EIN_L':[0,-P/2,0],'CS_EIN_R':[0,P/2,0],
 'CS_OUT_R_0':[0,-P/2,0],'CS_OUT_L_0':[0,P/2,0],
 'CS_OUT_R_90':[0,0,0],'CS_OUT_L_90':[0,0,0],
 'CS_OUT_R_270':[P,0,P],'CS_OUT_L_270':[P,0,P]}
def dock(aP,jP): return qmul(eu(rot[aP]),qconj(eu(rot[jP])))

# world transforms. U at identity. A90 joined U.CS_OUT_R_90 <- A90.CS_EIN_L ; A270 via CS_OUT_R_270
def place(anchorPmi):
    edge=dock(anchorPmi,'CS_EIN_L')      # copy rot rel to U
    at=pos[anchorPmi]; dockp=pos['CS_EIN_L']
    cpos=[at[i]-qrot(edge,dockp)[i] for i in range(3)]
    return {'rot':edge,'pos':cpos}
elems={'U':{'rot':[1,0,0,0],'pos':[0,0,0]},
       'A90':place('CS_OUT_R_90'),
       'A270':place('CS_OUT_R_270')}
anchors=['CS_OUT_R_0','CS_OUT_R_90','CS_OUT_R_270','CS_OUT_L_0','CS_OUT_L_90','CS_OUT_L_270','CS_EIN_L','CS_EIN_R']
world={}
for name,t in elems.items():
    for a in anchors:
        w=[t['pos'][i]+qrot(t['rot'],pos[a])[i] for i in range(3)]
        world[(name,a)]=tuple(round(v) for v in w)
# print each element's anchor world pos
for name in elems:
    print('---',name)
    for a in anchors:
        print(f'  {a:12}', world[(name,a)])
# find coincidences across different elements (same world pos)
print('=== coincident anchors (different elements) ===')
from collections import defaultdict
byp=defaultdict(list)
for k,w in world.items(): byp[w].append(k)
for w,ks in byp.items():
    names=set(n for n,_ in ks)
    if len(names)>1:
        print(w, ks)
EOF

Compute world anchor positions for 3-element junction and find coincidences


Ausgangskonstalation  ELMENT_1 = URSPRUNG ElEMENT_2 = ANBAU 270 ELENENT_3 = Anbau 90  Selection URSPRUNG Ewartung: NUR SELEKTO 0 wird gezeigt --> OK Selection ANBAU_270 Erawartung: NUR SELEKTOR 270 Wi…


--- U
  CS_OUT_R_0   (1114, 1067, -20)
  CS_OUT_R_90  (1064, 1067, -110)
  CS_OUT_R_270 (1064, 1067, 30)
  CS_OUT_L_0   (-70, 1067, -20)
  CS_OUT_L_90  (-20, 1067, -110)
  CS_OUT_L_270 (-20, 1067, 30)
  CS_EIN_L     (-20, 1067, -20)
  CS_EIN_R     (1064, 1067, -20)
--- A90
  CS_OUT_R_0   (1064, 1067, -1244)
  CS_OUT_R_90  (974, 1067, -1194)
  CS_OUT_R_270 (1114, 1067, -1194)
  CS_OUT_L_0   (1064, 1067, -60)
  CS_OUT_L_90  (974, 1067, -110)
  CS_OUT_L_270 (1114, 1067, -110)
  CS_EIN_L     (1064, 1067, -110)
  CS_EIN_R     (1064, 1067, -1194)
--- A270
  CS_OUT_R_0   (1064, 1067, 1164)
  CS_OUT_R_90  (1154, 1067, 1114)
  CS_OUT_R_270 (1014, 1067, 1114)
  CS_OUT_L_0   (1064, 1067, -20)
  CS_OUT_L_90  (1154, 1067, 30)
  CS_OUT_L_270 (1014, 1067, 30)
  CS_EIN_L     (1064, 1067, 30)
  CS_EIN_R     (1064, 1067, 1114)
=== coincident anchors (different elements) ===
(1064, 1067, -110) [('U', 'CS_OUT_R_90'), ('A90', 'CS_EIN_L')]
(1064, 1067, 30) [('U', 'CS_OUT_R_270'), ('A270', 'CS_EIN_L')]
(1064, 1067, -20) [('U', 'CS_EIN_R'), ('A270', 'CS_OUT_L_0')]


Ich hab die Weltpositionen der drei Elemente in genau deiner Konstellation gerechnet. Ergebnis: **nur die Join-Punkte fallen zusammen** — die Branch-Anker der drei Elemente teilen sich *keine* Position:

```
U.CS_OUT_R_90  == A90.CS_EIN_L      (Join)
U.CS_OUT_R_270 == A270.CS_EIN_L     (Join)
A270.CS_OUT_L_0 == U.CS_EIN_R       (einzige Extra-Koinzidenz)
```

Das heißt: „ANBAU_270 Selektor 90 ist belegt durch URSPRUNG" ist **keine** geteilte Ankerposition. Es ist eine **Richtungs-/Kollisionsaussage**: von A270s Junction-Ende zeigt ein Slot auf URSPRUNGs Körper, einer auf ANBAU_90, einer ins Freie — nur der freie soll bleiben.

Genau das kann mein aktuelles Modell nicht: es prüft belegte PMI-Namen bzw. deckungsgleiche Anker, nicht „dieser Slot zeigt in ein Nachbar-Element". Die reine Anker-Koinzidenz fängt bei A270 nur *einen* Slot (`CS_OUT_L_0`), nicht zwei — deshalb bleibt es bei „90 und 270".

Um „jedes Element zeigt nur seinen nach außen freien Slot" zu bekommen, braucht es eine **geometrische Prüfung**: für jeden offenen Slot die Bau-Richtung/Footprint gegen die vorhandenen Nachbar-Elemente testen und bei Überlappung sperren. Das ist ein größerer Umbau als die Namens-Regel.

Zwei Dinge zum Bestätigen, bevor ich das baue:
1. Stehen ANBAU_270 und ANBAU_90 beide am **selben Ende** von URSPRUNG (fächern vorne/hinten aus einem Steher)?
2. Soll ich die Sperre wirklich geometrisch machen (Slot-Bau-Ziel überlappt Nachbar-Element → gesperrt), konsistent für Snap und Selektor-Kugeln?


1 - ja
2 - ja


1 - ja
2 - ja

Ich weiß nicht, was Du gerade tust, aber deie GruppenIdee von vorhin trägt
Wenn steher zusammstossen, sollten sie sich wie EIN steher verhalten, egal wie viele es sind (min 1 / max 4)
das sollte alles sein


Verstanden — **Steher, die zusammenstoßen, sind ein logischer Steher mit 4 Slots** (min 1, max 4 Elemente). Das ist die saubere Abstraktion und deckt alle Fälle ab: 1 Element/Ende → 3 frei, 2 → 2, 3 → 1, 4 → 0.

Ich hab das geometrisch mit deiner 3-Element-Konstellation validiert (physische Richtungen: −x=URSPRUNG, −z=ANBAU_90, +z=ANBAU_270, frei=+x → jedes Element zeigt genau seinen Slot Richtung +x). Passt.

Ich baue `blockedPmis` darauf um: Posts per Union-Find über die Kanten clustern, dann pro Member den Slot sperren, dessen Weltrichtung auf den Körper eines anderen Members zeigt. Zuerst die Imports prüfen:


1	import {
2	    type Transform,
3	    type Vec3,
4	    type Quat,
5	    IDENTITY_QUAT,
6	    quatRotate,
7	    quatMul,
8	    quatConj,
9	    eulerZyxRadToQuat,
10	} from "./transformMath";
11
12	// Single home for the group model. A group is a set of element roots wired by directed dock
13	// edges; the group itself is a scene-graph node, so the engine moves/rotates every member
14	// natively (no relations, no follower propagation). The model is domain-agnostic: an edge
15	// names the two docking points by id, so the relative direction (any axis) falls out of the

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

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


1 - ja 2 - ja  Ich weiß nicht, was Du gerade tust, aber deie GruppenIdee von vorhin trägt Wenn steher zusammstossen, sollten sie sich wie EIN steher verhalten, egal wie viele es sind (min 1 / max 4) d…


`quatRotate`/`Vec3` sind da. Ich ersetze `blockedPmis` durch die geometrische Post-Regel:


Edit

    blockedPmis(): { element: string; pmi: string }[] {
        const occ = this.occupiedPmis();
        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));
        const extra: { element: string; pmi: string }[] = [];
        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch
        // slot per direction, so an occupied 90/270 on one post also blocks the neighbour's mirror.
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    const aP = `CS_OUT_${sideA}_${d}`;
                    const bP = `CS_OUT_${sideB}_${d}`;
                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });
                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });
                }
            }
        }
        return [...occ, ...extra];
    }

    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
    // (the panel directions). All element-ends meeting at one post share it, so a build slot is open
    // only if its direction is free — i.e. it does not point into another element's body. This is
    // read by both the placement drop points and the select markers, so the rule is graph-constant.
    blockedPmis(): { element: string; pmi: string }[] {
        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
        const norm = (v: Vec3): Vec3 => {
            const l = Math.hypot(v[0], v[1], v[2]);
            return l < 1e-6 ? [0, 0, 0] : [v[0] / l, v[1] / l, v[2] / l];
        };
        const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
        const other = (end: string) => (end === "L" ? "R" : "L");
        for (const g of this.groups.values()) {
            const layout = this.computeLayout(g);
            // Cluster the element-ends that meet at one physical post (union-find over the edges).
            const parent = new Map<string, string>();
            const find = (x: string): string => {
                if (!parent.has(x)) parent.set(x, x);
                let r = parent.get(x)!;
                if (r !== x) { r = find(r); parent.set(x, r); }
                return r;
            };
            const union = (a: string, b: string) => parent.set(find(a), find(b));
            const node = (el: string, end: string) => `${el}|${end}`;
            for (const e of g.edges) {
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
                if (!fs || !ts) continue;
                union(node(e.from, fs), node(e.to, ts));
            }
            const clusters = new Map<string, { el: string; end: string }[]>();
            for (const key of parent.keys()) {
                const r = find(key);
                if (!clusters.has(r)) clusters.set(r, []);
                const [el, end] = key.split("|");
                clusters.get(r)!.push({ el, end });
            }
            // World direction (rotation only — one group) of `to − from`, both element-local points.
            const dirOf = (el: string, from: Vec3, to: Vec3): Vec3 => {
                const rot = layout.get(el)?.rot ?? IDENTITY_QUAT;
                const a = quatRotate(rot, from);
                const b = quatRotate(rot, to);
                return norm([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
            };
            for (const members of clusters.values()) {
                if (members.length < 2) continue; // lone free end — nothing blocks it
                // Each member's body extends from its post toward its opposite end.
                const bodies = members.map((m) => ({
                    el: m.el,
                    dir: dirOf(m.el, this.pmiLocal(m.el, `CS_EIN_${m.end}`), this.pmiLocal(m.el, `CS_EIN_${other(m.end)}`)),
                }));
                for (const m of members) {
                    const post = this.pmiLocal(m.el, `CS_EIN_${m.end}`);
                    for (const a of ["0", "90", "270"]) {
                        const slotPmi = `CS_OUT_${m.end}_${a}`;
                        const sdir = dirOf(m.el, post, this.pmiLocal(m.el, slotPmi));
                        if (sdir[0] === 0 && sdir[1] === 0 && sdir[2] === 0) continue;
                        // A slot pointing into another member's body is taken by that member.
                        if (bodies.some((b) => b.el !== m.el && dot(b.dir, sdir) > 0.9)) {
                            blocked.add(`${m.el} ${slotPmi}`);
                        }
                    }
                }
            }
        }
        return [...blocked].map((k) => {
            const sp = k.lastIndexOf(" ");
            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };
        });
    }


    blockedPmis(): { element: string; pmi: string }[] {
        const occ = this.occupiedPmis();
        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));
        const extra: { element: string; pmi: string }[] = [];
        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch
        // slot per direction, so an occupied 90/270 on one post also blocks the neighbour's mirror.
        for (const g of this.groups.values()) {
            for (const e of g.edges) {
                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);
                if (!m) continue;
                const sideA = m[1];
                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";
                for (const d of ["90", "270"]) {
                    const aP = `CS_OUT_${sideA}_${d}`;
                    const bP = `CS_OUT_${sideB}_${d}`;
                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });
                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });
                }
            }
        }
        return [...occ, ...extra];
    }

    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
    // (the panel directions). All element-ends meeting at one post share it, so a build slot is open
    // only if its direction is free — i.e. it does not point into another element's body. This is
    // read by both the placement drop points and the select markers, so the rule is graph-constant.
    blockedPmis(): { element: string; pmi: string }[] {
        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
        const norm = (v: Vec3): Vec3 => {
            const l = Math.hypot(v[0], v[1], v[2]);
            return l < 1e-6 ? [0, 0, 0] : [v[0] / l, v[1] / l, v[2] / l];
        };
        const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
        const other = (end: string) => (end === "L" ? "R" : "L");
        for (const g of this.groups.values()) {
            const layout = this.computeLayout(g);
            // Cluster the element-ends that meet at one physical post (union-find over the edges).
            const parent = new Map<string, string>();
            const find = (x: string): string => {
                if (!parent.has(x)) parent.set(x, x);
                let r = parent.get(x)!;
                if (r !== x) { r = find(r); parent.set(x, r); }
                return r;
            };
            const union = (a: string, b: string) => parent.set(find(a), find(b));
            const node = (el: string, end: string) => `${el}|${end}`;
            for (const e of g.edges) {
                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
                if (!fs || !ts) continue;
                union(node(e.from, fs), node(e.to, ts));
            }
            const clusters = new Map<string, { el: string; end: string }[]>();
            for (const key of parent.keys()) {
                const r = find(key);
                if (!clusters.has(r)) clusters.set(r, []);
                const [el, end] = key.split("|");
                clusters.get(r)!.push({ el, end });
            }
            // World direction (rotation only — one group) of `to − from`, both element-local points.
            const dirOf = (el: string, from: Vec3, to: Vec3): Vec3 => {
                const rot = layout.get(el)?.rot ?? IDENTITY_QUAT;
                const a = quatRotate(rot, from);
                const b = quatRotate(rot, to);
                return norm([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
            };
            for (const members of clusters.values()) {
                if (members.length < 2) continue; // lone free end — nothing blocks it
                // Each member's body extends from its post toward its opposite end.
                const bodies = members.map((m) => ({
                    el: m.el,
                    dir: dirOf(m.el, this.pmiLocal(m.el, `CS_EIN_${m.end}`), this.pmiLocal(m.el, `CS_EIN_${other(m.end)}`)),
                }));
                for (const m of members) {
                    const post = this.pmiLocal(m.el, `CS_EIN_${m.end}`);
                    for (const a of ["0", "90", "270"]) {
                        const slotPmi = `CS_OUT_${m.end}_${a}`;
                        const sdir = dirOf(m.el, post, this.pmiLocal(m.el, slotPmi));
                        if (sdir[0] === 0 && sdir[1] === 0 && sdir[2] === 0) continue;
                        // A slot pointing into another member's body is taken by that member.
                        if (bodies.some((b) => b.el !== m.el && dot(b.dir, sdir) > 0.9)) {
                            blocked.add(`${m.el} ${slotPmi}`);
                        }
                    }
                }
            }
        }
        return [...blocked].map((k) => {
            const sp = k.lastIndexOf(" ");
            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };
        });
    }

// that gap. So a branch occupied on one post's CS_OUT_<side>_<90|270> also blocks the mirror

// CS_OUT_<otherSide>_<same> on the neighbour. Both the placement drop points and the select

// markers read this, so the rule is graph-constant across positioning and display.

+    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots

+    // (the panel directions). All element-ends meeting at one post share it, so a build slot is open

+    // only if its direction is free — i.e. it does not point into another element's body. This is

+    // read by both the placement drop points and the select markers, so the rule is graph-constant.

-        const occ = this.occupiedPmis();

-        const set = new Set(occ.map((o) => `${o.element} ${o.pmi}`));

-        const extra: { element: string; pmi: string }[] = [];

-        // Junction mirror: at a straight element↔element joint the two abutting posts share a branch

-        // slot per direction, so an occupied 90/270 on one post also blocks the neighbour's mirror.

+        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));

+        const norm = (v: Vec3): Vec3 => {

+            const l = Math.hypot(v[0], v[1], v[2]);

+        const other = (end: string) => (end === "L" ? "R" : "L");

+            const layout = this.computeLayout(g);

+            // Cluster the element-ends that meet at one physical post (union-find over the edges).

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

+            const find = (x: string): string => {

+                if (!parent.has(x)) parent.set(x, x);

+                let r = parent.get(x)!;

+                if (r !== x) { r = find(r); parent.set(x, r); }

+            const union = (a: string, b: string) => parent.set(find(a), find(b));

+            const node = (el: string, end: string) => `${el}|${end}`;

-                const m = /^CS_OUT_([LR])_0$/.exec(e.fromPmi);

-                const sideB = e.toPmi.endsWith("_L") ? "L" : "R";

-                    const aP = `CS_OUT_${sideA}_${d}`;

-                    const bP = `CS_OUT_${sideB}_${d}`;

-                    if (set.has(`${e.from} ${aP}`)) extra.push({ element: e.to, pmi: bP });

-                    if (set.has(`${e.to} ${bP}`)) extra.push({ element: e.from, pmi: aP });

+                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];

+                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];

+                if (!fs || !ts) continue;

+                union(node(e.from, fs), node(e.to, ts));

+            const clusters = new Map<string, { el: string; end: string }[]>();

+            for (const key of parent.keys()) {

+                if (!clusters.has(r)) clusters.set(r, []);

+                const [el, end] = key.split("|");

+                clusters.get(r)!.push({ el, end });

+            // World direction (rotation only — one group) of `to − from`, both element-local points.

+            const dirOf = (el: string, from: Vec3, to: Vec3): Vec3 => {

+                const rot = layout.get(el)?.rot ?? IDENTITY_QUAT;

+                const a = quatRotate(rot, from);

+                const b = quatRotate(rot, to);

+            for (const members of clusters.values()) {

+                if (members.length < 2) continue; // lone free end — nothing blocks it

+                // Each member's body extends from its post toward its opposite end.

+                const bodies = members.map((m) => ({

+                    dir: dirOf(m.el, this.pmiLocal(m.el, `CS_EIN_${m.end}`), this.pmiLocal(m.el, `CS_EIN_${other(m.end)}`)),

+                for (const m of members) {

+                    const post = this.pmiLocal(m.el, `CS_EIN_${m.end}`);

+                        const slotPmi = `CS_OUT_${m.end}_${a}`;

+                        const sdir = dirOf(m.el, post, this.pmiLocal(m.el, slotPmi));

+                        // A slot pointing into another member's body is taken by that member.

+                        if (bodies.some((b) => b.el !== m.el && dot(b.dir, sdir) > 0.9)) {

+                            blocked.add(`${m.el} ${slotPmi}`);

+        return [...blocked].map((k) => {

+            const sp = k.lastIndexOf(" ");

+            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };


Jetzt `withSelectionMarkers` vereinfachen — die Geometrie steckt jetzt in `blockedPmis`, also nur noch der Anker-Check:


                // Only the select PMIs (CS_SELECT_<side>_<angle>) get a marker, not the base points.
                if (!String(c.id).startsWith("CS_SELECT")) continue;
                const parts = String(c.id).split("_"); // [CS, SELECT, R|L, 0|90|270]
                const side = parts[2];
                const angle = parts.slice(3).join("_");
                // A slot is open only if a copy can dock there. The straight slot (0) is taken by either
                // its CS_OUT anchor OR the end's CS_EIN join point (this element docked onto a neighbour
                // there); the branch slots (90/270) only by their own CS_OUT anchor.
                const anchorPmi = `CS_OUT_${side}_${angle}`;
                const blocked = occupied.has(`${h.id} ${anchorPmi}`)
                    || (angle === "0" && occupied.has(`${h.id} CS_EIN_${side}`));
                if (blocked) continue;
                const pos = c.properties?.position?.value ?? [0, 0, 0];

                // Only the select PMIs (CS_SELECT_<side>_<angle>) get a marker, not the base points.
                if (!String(c.id).startsWith("CS_SELECT")) continue;
                // Show only where a copy can dock: the matching CS_OUT slot must be open per the post
                // rule (blockedPmis already folds in occupancy and shared-post directions).
                const anchorPmi = String(c.id).replace("CS_SELECT_", "CS_OUT_");
                if (occupied.has(`${h.id} ${anchorPmi}`)) continue;
                const pos = c.properties?.position?.value ?? [0, 0, 0];


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

                // Only the select PMIs (CS_SELECT_<side>_<angle>) get a marker, not the base points.
                if (!String(c.id).startsWith("CS_SELECT")) continue;
                const parts = String(c.id).split("_"); // [CS, SELECT, R|L, 0|90|270]
                const side = parts[2];
                const angle = parts.slice(3).join("_");
                // A slot is open only if a copy can dock there. The straight slot (0) is taken by either
                // its CS_OUT anchor OR the end's CS_EIN join point (this element docked onto a neighbour
                // there); the branch slots (90/270) only by their own CS_OUT anchor.
                const anchorPmi = `CS_OUT_${side}_${angle}`;
                const blocked = occupied.has(`${h.id} ${anchorPmi}`)
                    || (angle === "0" && occupied.has(`${h.id} CS_EIN_${side}`));
                if (blocked) continue;
                const pos = c.properties?.position?.value ?? [0, 0, 0];

                // Only the select PMIs (CS_SELECT_<side>_<angle>) get a marker, not the base points.
                if (!String(c.id).startsWith("CS_SELECT")) continue;
                // Show only where a copy can dock: the matching CS_OUT slot must be open per the post
                // rule (blockedPmis already folds in occupancy and shared-post directions).
                const anchorPmi = String(c.id).replace("CS_SELECT_", "CS_OUT_");
                if (occupied.has(`${h.id} ${anchorPmi}`)) continue;
                const pos = c.properties?.position?.value ?? [0, 0, 0];

if (c._resourceClass !== "component_pmi") continue;

-                const parts = String(c.id).split("_"); // [CS, SELECT, R|L, 0|90|270]

-                const angle = parts.slice(3).join("_");

-                // A slot is open only if a copy can dock there. The straight slot (0) is taken by either

-                // its CS_OUT anchor OR the end's CS_EIN join point (this element docked onto a neighbour

-                // there); the branch slots (90/270) only by their own CS_OUT anchor.

-                const anchorPmi = `CS_OUT_${side}_${angle}`;

-                const blocked = occupied.has(`${h.id} ${anchorPmi}`)

-                    || (angle === "0" && occupied.has(`${h.id} CS_EIN_${side}`));

+                // Show only where a copy can dock: the matching CS_OUT slot must be open per the post

+                // rule (blockedPmis already folds in occupancy and shared-post directions).

+                const anchorPmi = String(c.id).replace("CS_SELECT_", "CS_OUT_");

+                if (occupied.has(`${h.id} ${anchorPmi}`)) continue;


Bash

cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
pnpm exec vue-tsc --noEmit -p tsconfig.json 2>&1 | grep -E "GroupProcessor|DefaultCommandProcessor" | head
echo "--- tests ---"
pnpm exec vitest run GroupProcessor DefaultCommandProcessor 2>&1 | tail -8

Typecheck and run tests


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

 Test Files  2 passed (2)
      Tests  25 passed (25)
   Start at  19:07:53
   Duration  644ms (transform 96ms, setup 0ms, import 119ms, tests 209ms, environment 764ms)


Kurzer End-to-End-Check der Logik gegen die 3-Element-Konstellation:


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
python3 << 'EOF'
import math
def qmul(a,b):
    aw,ax,ay,az=a; bw,bx,by,bz=b
    return [aw*bw-ax*bx-ay*by-az*bz, aw*bx+ax*bw+ay*bz-az*by,
            aw*by-ax*bz+ay*bw+az*bx, aw*bz+ax*by-ay*bx+az*bw]
def qconj(q): return [q[0],-q[1],-q[2],-q[3]]
def qrot(q,v):
    w,x,y,z=q
    tx=2*(y*v[2]-z*v[1]); ty=2*(z*v[0]-x*v[2]); tz=2*(x*v[1]-y*v[0])
    return [v[0]+w*tx+(y*tz-z*ty), v[1]+w*ty+(z*tx-x*tz), v[2]+w*tz+(x*ty-y*tx)]
def eu(r):
    x,y,z=r; cx,cy,cz=math.cos(x/2),math.cos(y/2),math.cos(z/2); sx,sy,sz=math.sin(x/2),math.sin(y/2),math.sin(z/2)
    return [cx*cy*cz+sx*sy*sz, sx*cy*cz-cx*sy*sz, cx*sy*cz+sx*cy*sz, cx*cy*sz-sx*sy*cz]
P=math.pi
pos={'CS_EIN_L':[-20,1067,-20],'CS_EIN_R':[1064,1067,-20],
 'CS_OUT_R_0':[1114,1067,-20],'CS_OUT_L_0':[-70,1067,-20],
 'CS_OUT_R_90':[1064,1067,-110],'CS_OUT_L_90':[-20,1067,-110],
 'CS_OUT_R_270':[1064,1067,30],'CS_OUT_L_270':[-20,1067,30]}
rot={'CS_OUT_R_90':[0,0,0],'CS_OUT_R_270':[P,0,P],'CS_EIN_L':[0,-P/2,0]}
def dock(aP): return qmul(eu(rot[aP]),qconj(eu(rot['CS_EIN_L'])))
def place(aP):
    e=dock(aP); at=pos[aP]; d=qrot(e,pos['CS_EIN_L'])
    return {'rot':e,'pos':[at[i]-d[i] for i in range(3)]}
elems={'U':{'rot':[1,0,0,0],'pos':[0,0,0]},'A90':place('CS_OUT_R_90'),'A270':place('CS_OUT_R_270')}
def norm(v):
    l=math.hypot(*v); return [0,0,0] if l<1e-6 else [c/l for c in v]
def dot(a,b): return sum(a[i]*b[i] for i in range(3))
def dirOf(el,frm,to):
    r=elems[el]['rot']; a=qrot(r,frm); b=qrot(r,to); return norm([b[i]-a[i] for i in range(3)])
# post cluster: (U,R),(A90,L),(A270,L)
members=[('U','R'),('A90','L'),('A270','L')]
oth=lambda e:'R' if e=='L' else 'L'
bodies=[(el,dirOf(el,pos[f'CS_EIN_{end}'],pos[f'CS_EIN_{oth(end)}'])) for el,end in members]
for el,end in members:
    postp=pos[f'CS_EIN_{end}']; free=[]; blk=[]
    for a in ['0','90','270']:
        s=f'CS_OUT_{end}_{a}'; sd=dirOf(el,postp,pos[s])
        hit=any(be!=el and dot(bd,sd)>0.9 for be,bd in bodies)
        (blk if hit else free).append(a)
    print(f'{el:5} end {end}: frei={free}  gesperrt={blk}')
EOF

Emulate blockedPmis on 3-element fan


U     end R: frei=['0']  gesperrt=['90', '270']
A90   end L: frei=['270']  gesperrt=['0', '90']
A270  end L: frei=['90']  gesperrt=['0', '270']


Bestätigt — jede der drei Elemente zeigt an der geteilten Junction **genau einen** freien Slot, und zwar den, der in die freie Richtung (+x, nach außen) zeigt:

```
URSPRUNG  frei: 0
ANBAU_90  frei: (der nach außen zeigende)
ANBAU_270 frei: (der nach außen zeigende)
```

Umgesetzt als Post-Regel in `blockedPmis()`:
- Element-Enden, die an einem Steher zusammenstoßen, werden per Union-Find über die Kanten zu **einem logischen Steher** geclustert (min 1, max 4 Member).
- Ein Slot ist gesperrt, wenn seine Weltrichtung in den Körper eines anderen Members zeigt.

Deckt alle Fälle konsistent ab: 1 Member → 3 frei, 2 → 2, 3 → 1, 4 → 0. Snap-Points und Selektor-Kugeln lesen dieselbe Regel.

Typecheck sauber, 25 Tests grün.

Hinweis zur Benennung: der freie Slot heißt lokal nicht zwingend „270"/„90" wie in deiner Intuition — weil die Anbauten gedreht sind, weicht der lokale PMI-Name ab. Die Kugel sitzt aber an der physikalisch freien Richtung, und das ist das Entscheidende.


Sieht gut aus und ist - denke ich - eine brauchabere logik?


Ja, tragfähig. Die Stärke: eine einzige graph-abgeleitete Quelle (`blockedPmis`), aus der Snap-Points und Selektoren lesen — keine Doppel-Logik, die auseinanderläuft. Die Kernregel (Slot gesperrt, wenn seine Weltrichtung in einen Nachbar-Körper zeigt) ist rotationsrobust und skaliert von 1 bis 4 Membern ohne Sonderfälle.

Wo sie Annahmen trifft, die man kennen sollte:

- **Richtungs-Schwelle `dot > 0.9`** (~25°) setzt voraus, dass die Post-Richtungen grob orthogonal sind (0/90/270). Bei Bauteilen mit enger beieinander liegenden Slots könnte sie falsch sperren.
- **Körperrichtung = `CS_EIN_L→CS_EIN_R`** nimmt gerade Panels mit beiden Einbaupunkten an. Typen ohne diese PMIs fallen auf `[0,0,0]` und werden übersprungen → eher unter- als überblockt.
- **Cluster nur über Kanten**, nicht über echte Welt-Koinzidenz: zwei physisch anstoßende, aber graph-technisch unverbundene Steher würden nicht verschmelzen. Bei verbundenen Junctions irrelevant.
- **„max 4" ist nicht hart erzwungen** — es ergibt sich aus der Geometrie (3 Branch-Richtungen + gerade), nicht aus einer Prüfung.

Für den aktuellen Schutzzaun-Katalog (orthogonale Slots, gerade Elemente) greift das sauber. Kritisch würde es erst bei Bauteilen mit anderer Steher-Geometrie.


Die kommen noch
kannst Du dazu ein ADR in der doku (design)ß?) schreiben?


cd /Users/marcus.hinz/projects/adesso-cpq
find . -type d \( -iname "adr" -o -iname "docs" -o -iname "design" -o -iname "decisions" \) -not -path "*/node_modules/*" 2>/dev/null | head -20
echo "--- existing ADR-like files ---"
find . -type f \( -iname "*adr*" -o -iname "*ADR*" -o -iname "0001*" \) -not -path "*/node_modules/*" 2>/dev/null | head

Find docs/design/ADR directories


./design
--- existing ADR-like files ---


Die kommen noch kannst Du dazu ein ADR in der doku (design)ß?) schreiben?


cd /Users/marcus.hinz/projects/adesso-cpq/design
ls -la
echo "--- structure ---"
find . -maxdepth 2 -type f | head -40

Inspect design folder contents


total 1832
drwxr-xr-x   8 marcus.hinz  staff     256 Jun 24 19:05 .
drwx------@ 24 marcus.hinz  staff     768 Jul  6 11:03 ..
-rw-r--r--   1 marcus.hinz  staff   11624 Jun 24 15:12 BLAST-Constraint-Modell.drawio
-rw-r--r--   1 marcus.hinz  staff   13492 Jun 24 19:05 BLAST-Graph-Constraints.md
-rw-r--r--   1 marcus.hinz  staff    7911 Jun 24 19:05 Fire3DCommandProzessor.md
-rw-r--r--   1 marcus.hinz  staff    9818 Jun 24 19:05 Fire3DEventSourcing.md
-rw-r--r--   1 marcus.hinz  staff  863721 Jun 22 14:45 GitHub CICD to OpenShift.pdf
-rw-r--r--   1 marcus.hinz  staff   20640 Jun 24 15:12 Robo Profilconstraints.drawio
--- structure ---
./Robo Profilconstraints.drawio
./GitHub CICD to OpenShift.pdf
./BLAST-Graph-Constraints.md
./Fire3DCommandProzessor.md
./Fire3DEventSourcing.md
./BLAST-Constraint-Modell.drawio


cd /Users/marcus.hinz/projects/adesso-cpq/design
head -40 Fire3DCommandProzessor.md
echo "==============="
head -15 BLAST-Graph-Constraints.md

Check doc style/conventions


# Fire3D CommandProcessor – Architecture Specification

## Overview

The CommandProcessor is a framework-agnostic, stateless mediator between a 3D rendering widget (Fire3D) and a Vue UI layer. It receives events and returns a list of UI reactions. No internal queue, no state between events.

## Core Concept: UIReaction

A `UIReaction` is a discriminated union describing what the UI should do. All reaction types are equal – none is special. A context menu is simply a UI element that lets the user choose between different reaction lists.

```typescript
type UIReaction =
    | { type: "contextMenu"; entries: { label: string; icon?: string; reactions: UIReaction[] }[] }
    | { type: "sendCommand"; cmd: string; args: unknown[] }
    | { type: "notification"; message: string }
    | { type: "dialog"; title: string; message: string };
```

### How Context Menus Work

A context menu is not a special case. It is a reaction that presents the user with a choice of reaction lists. When the user clicks an entry, Vue processes that entry's `reactions` list – exactly the same way it would process any other reaction list.

### Examples

A right-click on an object might return:

```typescript
[
    {
        type: "contextMenu",
        entries: [
            {
                label: "Move Object",
                icon: "move",
                reactions: [{ type: "sendCommand", cmd: "start_move_mode", args: [] }],
            },
            {
                label: "Delete Object",
                icon: "trash",
                reactions: [{ type: "dialog", title: "Confirm", message: "Delete this object?" }],
===============
# BLAST, Graph & Constraints – Modell-Stand

Festhalten des erarbeiteten Modells: wann etwas ein Constraint ist, wo Regeln
wohnen, was geliefert wird, und wie eine konkrete Regel abgelegt wird.
Begriffe deutsch, Typen/Code englisch.

---

## 1. Zwei Paradigmen

Eine Forderung an die Geometrie kann auf zwei Arten durchgesetzt werden:

- **Prüfen** (Check): erst frei bewegen, dann nachmessen, ob die Forderung gilt.
- **Mate/DOF**: den verletzenden Freiheitsgrad gar nicht erst zulassen — die
  Forderung gilt per Bauart.


Es gibt `design/` mit MD-Docs (deutsch, Code englisch), aber noch keine ADRs. Ich lege eine ADR im Standardformat dort ab.


Write

# ADR-0001: Belegung geteilter Steher beim Seiten-Verbau

- **Status:** Angenommen (2026-07-09)
- **Kontext:** Frontend-Konfigurator, Seiten-Verbau von Zaunelementen
- **Betroffener Code:** `GroupProcessor.blockedPmis()`, `DefaultCommandProcessor.withSelectionMarkers()`,
  `DefaultCommandProcessor.placementDragPoints()`

Begriffe deutsch, Typen/Code englisch.

---

## Kontext

Ein selektiertes Element zeigt an seinen offenen Enden grüne Marker-Kugeln auf den
`CS_SELECT_<side>_<angle>`-PMIs. Ein Klick darauf baut eine Kopie an den zugehörigen
`CS_OUT_<side>_<angle>`-Anker an. Dieselben offenen Punkte sind beim Positionieren die
Snap-/Drop-Points.

Die Frage „welche Slots sind offen?" ist nicht lokal pro Element beantwortbar: An einem
Steher stoßen die Enden mehrerer Elemente zusammen. Theoretisch stehen dort bis zu vier
Panel-Richtungen offen (gerade, vorne, hinten, plus die Richtung ins Element selbst).
Physisch dürfen sie sich aber nicht doppelt belegen.

Beispiel (drei Elemente an einem Steher, alle am selben Ende von URSPRUNG):

```
        ANBAU_90 (vorne)
             │
URSPRUNG ────┼──── (0, frei)
             │
        ANBAU_270 (hinten)
```

Erwartung: Jedes der drei Elemente zeigt an dieser Junction genau **einen** freien Slot —
den, der in die freie Richtung zeigt. Die anderen zwei zeigen jeweils in einen Nachbar-Körper
und sind gesperrt.

Frühere Ansätze (PMI-Namens-Spiegelung nur über gerade Kanten; Sonderregel „mittlerer Steher"
über Suffix-Mapping; Auswertung der `_B`-PMI-Koinzidenzen) scheiterten an genau diesem
Drei-Element-Fächer: Sie sind rein namensbasiert und kennen die tatsächliche Bau-Richtung nicht.

## Leitgedanke

Steher, die zusammenstoßen, verhalten sich wie **ein** logischer Steher — egal wie viele
Element-Enden dort ankommen (min 1, max 4). Ein logischer Steher hat vier Slots (die
Panel-Richtungen); ein Slot ist offen, solange seine Richtung frei ist, d. h. nicht in den
Körper eines anderen Members zeigt.

## Entscheidung

Eine einzige graph-abgeleitete Quelle, `GroupProcessor.blockedPmis()`, aus der sowohl die
Selektor-Anzeige als auch die Snap-/Drop-Points lesen. Damit ist die Regel über den ganzen
Graphen konstant und kann nicht zwischen den beiden Verbrauchern auseinanderlaufen.

Ableitung:

1. **Cluster** die Element-Enden zu logischen Stehern per Union-Find über die Kanten. Ein
   Knoten ist `(element, end)` mit `end ∈ {L, R}`; jede Kante verbindet `from`-`CS_OUT_<S>_…`
   mit `to`-`CS_EIN_<S>`.
2. Pro Member die **Körperrichtung** bestimmen: Weltrichtung von `CS_EIN_<end>` nach
   `CS_EIN_<other end>` (das Panel erstreckt sich vom Steher weg in den eigenen Körper).
3. Pro Member je Slot `CS_OUT_<end>_<0|90|270>` die **Slot-Richtung** bestimmen (Weltrichtung
   von `CS_EIN_<end>` zum Slot-Anker). Der Slot ist gesperrt, wenn er in den Körper eines
   *anderen* Members zeigt (`dot(slotDir, bodyDir) > 0.9`).

Weltrichtungen kommen aus `computeLayout` (Rotation je Member); es zählt nur die Richtung,
daher ist die Gruppen-Welttransformation irrelevant.

Verhalten je Steher: 1 Member → 3 Slots frei, 2 → 2, 3 → 1, 4 → 0.

## Konsequenzen

- **Positiv:** Rotationsrobust (kein Suffix-/Namens-Mapping), skaliert 1–4 Member ohne
  Sonderfälle, eine Quelle für Anzeige und Snap. `withSelectionMarkers` reduziert sich auf
  „zeige `CS_SELECT_*`, wenn der gleichnamige `CS_OUT`-Anker nicht in `blockedPmis` steht".
- **Kosten:** `blockedPmis()` läuft `computeLayout` je Gruppe plus O(Kanten + Member·Slots).
  Für die aktuellen Konfigurationsgrößen unkritisch.

## Annahmen und Grenzen

Explizit festgehalten, weil neue Bauteil-Geometrien anstehen:

- **Schwelle `dot > 0.9`** (~25°) setzt grob orthogonale Slot-Richtungen voraus (0/90/270).
  Bauteile mit enger beieinanderliegenden Slots können falsch sperren.
- **Körperrichtung über `CS_EIN_L → CS_EIN_R`** nimmt gerade Panels mit beiden Einbaupunkten
  an. Typen ohne diese PMIs liefern `[0,0,0]` und werden übersprungen → eher unter- als
  überblockt.
- **Cluster nur über Kanten**, nicht über echte Welt-Koinzidenz: zwei physisch anstoßende,
  aber graph-technisch unverbundene Steher verschmelzen nicht. Bei verbundenen Junctions
  irrelevant.
- **„max 4" ist nicht hart erzwungen** — es ergibt sich aus der Geometrie (drei
  Branch-Richtungen + gerade), nicht aus einer Prüfung.

Bei neuen Steher-Geometrien sind diese vier Punkte zuerst zu prüfen.

## Verworfene Alternativen

- **PMI-Namens-Spiegelung** (90↔270-Nachbar über gerade Kanten): kennt nur gerade Junctions,
  nicht den Branch-Fächer; bricht bei drei Elementen.
- **Suffix-Regel „mittlerer Steher"** (`_B`-Koinzidenz → 0 sperrt): asymmetrisch und
  widersprüchlich zum Einzel-Branch-Verhalten.

Beide sind namensbasiert und können „dieser Slot zeigt in ein Nachbar-Element" grundsätzlich
nicht ausdrücken — deshalb die geometrische Lösung.


File created successfully at: /Users/marcus.hinz/projects/adesso-cpq/design/ADR-0001-Steher-Belegung-Verbau.md (file state is current in your context — no need to Read it back)

# ADR-0001: Belegung geteilter Steher beim Seiten-Verbau

- **Status:** Angenommen (2026-07-09)
- **Kontext:** Frontend-Konfigurator, Seiten-Verbau von Zaunelementen
- **Betroffener Code:** `GroupProcessor.blockedPmis()`, `DefaultCommandProcessor.withSelectionMarkers()`,
  `DefaultCommandProcessor.placementDragPoints()`

Begriffe deutsch, Typen/Code englisch.

---

## Kontext

Ein selektiertes Element zeigt an seinen offenen Enden grüne Marker-Kugeln auf den
`CS_SELECT_<side>_<angle>`-PMIs. Ein Klick darauf baut eine Kopie an den zugehörigen
`CS_OUT_<side>_<angle>`-Anker an. Dieselben offenen Punkte sind beim Positionieren die
Snap-/Drop-Points.

Die Frage „welche Slots sind offen?" ist nicht lokal pro Element beantwortbar: An einem
Steher stoßen die Enden mehrerer Elemente zusammen. Theoretisch stehen dort bis zu vier
Panel-Richtungen offen (gerade, vorne, hinten, plus die Richtung ins Element selbst).
Physisch dürfen sie sich aber nicht doppelt belegen.

Beispiel (drei Elemente an einem Steher, alle am selben Ende von URSPRUNG):

```
        ANBAU_90 (vorne)
             │
URSPRUNG ────┼──── (0, frei)
             │
        ANBAU_270 (hinten)
```

Erwartung: Jedes der drei Elemente zeigt an dieser Junction genau **einen** freien Slot —
den, der in die freie Richtung zeigt. Die anderen zwei zeigen jeweils in einen Nachbar-Körper
und sind gesperrt.

Frühere Ansätze (PMI-Namens-Spiegelung nur über gerade Kanten; Sonderregel „mittlerer Steher"
über Suffix-Mapping; Auswertung der `_B`-PMI-Koinzidenzen) scheiterten an genau diesem
Drei-Element-Fächer: Sie sind rein namensbasiert und kennen die tatsächliche Bau-Richtung nicht.

## Leitgedanke

Steher, die zusammenstoßen, verhalten sich wie **ein** logischer Steher — egal wie viele
Element-Enden dort ankommen (min 1, max 4). Ein logischer Steher hat vier Slots (die
Panel-Richtungen); ein Slot ist offen, solange seine Richtung frei ist, d. h. nicht in den
Körper eines anderen Members zeigt.

## Entscheidung

Eine einzige graph-abgeleitete Quelle, `GroupProcessor.blockedPmis()`, aus der sowohl die
Selektor-Anzeige als auch die Snap-/Drop-Points lesen. Damit ist die Regel über den ganzen
Graphen konstant und kann nicht zwischen den beiden Verbrauchern auseinanderlaufen.

Ableitung:

1. **Cluster** die Element-Enden zu logischen Stehern per Union-Find über die Kanten. Ein
   Knoten ist `(element, end)` mit `end ∈ {L, R}`; jede Kante verbindet `from`-`CS_OUT_<S>_…`
   mit `to`-`CS_EIN_<S>`.
2. Pro Member die **Körperrichtung** bestimmen: Weltrichtung von `CS_EIN_<end>` nach
   `CS_EIN_<other end>` (das Panel erstreckt sich vom Steher weg in den eigenen Körper).
3. Pro Member je Slot `CS_OUT_<end>_<0|90|270>` die **Slot-Richtung** bestimmen (Weltrichtung
   von `CS_EIN_<end>` zum Slot-Anker). Der Slot ist gesperrt, wenn er in den Körper eines
   *anderen* Members zeigt (`dot(slotDir, bodyDir) > 0.9`).

Weltrichtungen kommen aus `computeLayout` (Rotation je Member); es zählt nur die Richtung,
daher ist die Gruppen-Welttransformation irrelevant.

Verhalten je Steher: 1 Member → 3 Slots frei, 2 → 2, 3 → 1, 4 → 0.

## Konsequenzen

- **Positiv:** Rotationsrobust (kein Suffix-/Namens-Mapping), skaliert 1–4 Member ohne
  Sonderfälle, eine Quelle für Anzeige und Snap. `withSelectionMarkers` reduziert sich auf
  „zeige `CS_SELECT_*`, wenn der gleichnamige `CS_OUT`-Anker nicht in `blockedPmis` steht".
- **Kosten:** `blockedPmis()` läuft `computeLayout` je Gruppe plus O(Kanten + Member·Slots).
  Für die aktuellen Konfigurationsgrößen unkritisch.

## Annahmen und Grenzen

Explizit festgehalten, weil neue Bauteil-Geometrien anstehen:

- **Schwelle `dot > 0.9`** (~25°) setzt grob orthogonale Slot-Richtungen voraus (0/90/270).
  Bauteile mit enger beieinanderliegenden Slots können falsch sperren.
- **Körperrichtung über `CS_EIN_L → CS_EIN_R`** nimmt gerade Panels mit beiden Einbaupunkten
  an. Typen ohne diese PMIs liefern `[0,0,0]` und werden übersprungen → eher unter- als
  überblockt.
- **Cluster nur über Kanten**, nicht über echte Welt-Koinzidenz: zwei physisch anstoßende,
  aber graph-technisch unverbundene Steher verschmelzen nicht. Bei verbundenen Junctions
  irrelevant.
- **„max 4" ist nicht hart erzwungen** — es ergibt sich aus der Geometrie (drei
  Branch-Richtungen + gerade), nicht aus einer Prüfung.

Bei neuen Steher-Geometrien sind diese vier Punkte zuerst zu prüfen.

## Verworfene Alternativen

- **PMI-Namens-Spiegelung** (90↔270-Nachbar über gerade Kanten): kennt nur gerade Junctions,
  nicht den Branch-Fächer; bricht bei drei Elementen.
- **Suffix-Regel „mittlerer Steher"** (`_B`-Koinzidenz → 0 sperrt): asymmetrisch und
  widersprüchlich zum Einzel-Branch-Verhalten.

Beide sind namensbasiert und können „dieser Slot zeigt in ein Nachbar-Element" grundsätzlich
nicht ausdrücken — deshalb die geometrische Lösung.


ADR liegt unter `design/ADR-0001-Steher-Belegung-Verbau.md` (Standardformat: Status/Kontext/Entscheidung/Konsequenzen, deutsch, Code englisch — passend zu `BLAST-Graph-Constraints.md`).

Inhalt: Leitgedanke (zusammenstoßende Steher = ein logischer Steher, 4 Slots), die Entscheidung (`blockedPmis()` als einzige graph-abgeleitete Quelle, Union-Find-Clustering, Richtungs-Test `dot > 0.9`), das Drei-Element-Beispiel, die verworfenen namensbasierten Ansätze — und die vier Annahmen/Grenzen explizit festgehalten, damit sie bei den kommenden Bauteil-Geometrien zuerst geprüft werden.


gut commit und push


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
git status --short
echo "--- branch ---"; git branch --show-current
echo "--- remotes ---"; git remote -v
echo "--- recent ---"; git log --oneline -3

Check status, branch, remote


 D packages/frontend/.storybook/assets/glb/R5R_ROLLENFOERDERER_790_410.glb
 D packages/frontend/.storybook/assets/glb/SBD2083PLA.glb
 M packages/frontend/.storybook/products/docking-rules.json
 M packages/frontend/src/components/visual/Fire3D.vue
 M packages/frontend/src/components/visual/ProductEditorDemo.vue
 M packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts
 M packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
 M packages/frontend/src/configurator/model/GroupProcessor.ts
 M packages/frontend/src/services/product-service.ts
?? design/ADR-0001-Steher-Belegung-Verbau.md
?? packages/frontend/.storybook/products/rollenfoerderer/R5M_300x400.json
?? packages/frontend/.storybook/products/rollenfoerderer/R5S/
?? packages/frontend/.storybook/products/rollenfoerderer/R5S_600x400.json
?? packages/frontend/.storybook/products/rollenfoerderer/R5T/
?? packages/frontend/.storybook/products/rollenfoerderer/R5T050_ANFANG_600x400.json
--- branch ---
Produktmanagement
--- remotes ---
origin	https://git.heron.at/pzm/geko/adesso-cpq.git (fetch)
origin	https://git.heron.at/pzm/geko/adesso-cpq.git (push)
--- recent ---
d448cdb ProductConfigurator-Story: Docking-Regeln aus JSON laden
9fbadce GroupProcessor: Rotation beim Auflösen erhalten + Tests auf Platzierungslogik
b05bebf Graph-Export-Button + Keyboard-Prototyp fürs Anbauen


Aktueller Branch ist `Produktmanagement` (nicht main), Remote vorhanden. Im Working Tree liegen aber auch Änderungen, die **nicht** zu dieser Verbau-Arbeit gehören — u. a. gelöschte `.glb`-Dateien und die `rollenfoerderer`-Produkt-JSONs (das war das zurückgerollte Experiment). Die committe ich nicht blind mit.

Ich committe die kohärente Verbau-/Steher-Arbeit + ADR:


cd /Users/marcus.hinz/projects/adesso-cpq
git add packages/frontend/src/configurator/model/GroupProcessor.ts \
        packages/frontend/src/configurator/events/DefaultCommandProcessor.ts \
        design/ADR-0001-Steher-Belegung-Verbau.md
git status --short

Stage Verbau logic + ADR


A  design/ADR-0001-Steher-Belegung-Verbau.md
 D packages/frontend/.storybook/assets/glb/R5R_ROLLENFOERDERER_790_410.glb
 D packages/frontend/.storybook/assets/glb/SBD2083PLA.glb
 M packages/frontend/.storybook/products/docking-rules.json
 M packages/frontend/src/components/visual/Fire3D.vue
 M packages/frontend/src/components/visual/ProductEditorDemo.vue
 M packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts
M  packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
M  packages/frontend/src/configurator/model/GroupProcessor.ts
 M packages/frontend/src/services/product-service.ts
?? packages/frontend/.storybook/products/rollenfoerderer/R5M_300x400.json
?? packages/frontend/.storybook/products/rollenfoerderer/R5S/
?? packages/frontend/.storybook/products/rollenfoerderer/R5S_600x400.json
?? packages/frontend/.storybook/products/rollenfoerderer/R5T/
?? packages/frontend/.storybook/products/rollenfoerderer/R5T050_ANFANG_600x400.json


cd /Users/marcus.hinz/projects/adesso-cpq
git commit -q -F - <<'MSG'
Verbau: Select-Marker + geteilte Steher-Belegung (blockedPmis)

- Select-Marker (grüne Kugel: parametric sphere + box collider, depth-tested)
  an CS_SELECT-PMIs; Klick baut Kopie am CS_OUT-Anker an, Orientierung aus dockRotation
- blockedPmis: zusammenstoßende Steher als ein logischer Post (Union-Find ueber
  Kanten); Slot gesperrt, wenn seine Weltrichtung in einen Nachbar-Koerper zeigt.
  Snap-Points und Marker lesen dieselbe Regel
- ADR-0001 dokumentiert Entscheidung, Annahmen und verworfene Alternativen
MSG
echo "--- push ---"
git push origin Produktmanagement 2>&1 | tail -8

Commit and push to Produktmanagement branch


--- push ---
remote:
remote: To create a merge request for Produktmanagement, visit:
remote:   https://git.heron.at/pzm/geko/adesso-cpq/-/merge_requests/new?merge_request%5Bsource_branch%5D=Produktmanagement
remote:
To https://git.heron.at/pzm/geko/adesso-cpq.git
   d448cdb..cf44b2c  Produktmanagement -> Produktmanagement


Committet und gepusht (`cf44b2c`, Fast-Forward auf `Produktmanagement`).

Enthalten:
- `GroupProcessor.ts` — `blockedPmis()` (Post-Clustering + Richtungs-Regel)
- `DefaultCommandProcessor.ts` — Select-Marker, Marker-Klick-Anbau
- `design/ADR-0001-Steher-Belegung-Verbau.md`

Bewusst **nicht** committet (unverändert im Working Tree gelassen):
- gelöschte `.glb` (`R5R_…`, `SBD2083PLA`)
- `rollenfoerderer`-Produkt-JSONs (das zurückgerollte Experiment)
- `docking-rules.json`, `Fire3D.vue`, `ProductEditorDemo.vue`, `DefaultCommandProcessor.test.ts`, `product-service.ts` — andere Baustellen

Wenn davon etwas mit rein soll, sag welche.


das ist gut


Ok.