{   "_resourceClass": "sceneGraph_entity",   "handles": [     {       "id": "root",       "tags": [         "role-scene"       ],       "components": [         {           "_resourceClass": "component…


Rollenförderer Visualisierung und Fehler beheben


Conversation compacted


This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.

Summary:
1. Primary Request and Intent:
   The overarching work is a fence configurator (Schutzzaun) 3D editor. The session's requests, in order:
   - Restore a working green sphere "select marker" using the geometry+collider approach (not glb) that was crashing.
   - Restrict markers to PMIs starting with "select" (CS_SELECT_*).
   - On clicking a marker, copy the element and dock the copy in the corresponding orientation onto the element.
   - Fix incorrect angle assignment; then fix "_0 builds toward front".
   - Cleanup: (1) remove the marker spheres once something is attached, (2) only show markers on selectors where an element can actually be built.
   - Extend occupancy rules across junctions: when two elements are connected there are two posts between them, only one 90 and one 270 may be occupied (mirror rule); this must be graph-constant for BOTH snap/drop points AND selector display.
   - The "middle Steher" 0-direction rule (reverted, then superseded).
   - Final architecture: "Wenn steher zusammstossen, sollten sie sich wie EIN steher verhalten, egal wie viele es sind (min 1 / max 4)" — treat abutting posts as one logical post with 4 slots.
   - Write an ADR in design/ documenting this.
   - Commit and push.

2. Key Technical Concepts:
   - @ad/fire (fire-visual) reverse-engineered third-party 3D engine (NOT our code to modify); built package geko-fire from git.heron.at/pzm/geko.
   - Scene graph: handles with component_transform, component_pmi, component_collider, component_renderer, sceneGraph_reference (glb), material_basic/material_reference, geometry_parametric.
   - Collider ShapeType enum: sphere=2, box=4, mesh=8, capsule=16. This build registers ONLY boxShapeDescriptor, capsuleShapeDescriptor, meshShapeDescriptor — NO sphere descriptor (crashes shapeType 2).
   - geometry_parametric factory: sphere factory key = "sphere-3d", param `diameter`; grid = "grid".
   - PMIs: CS_EIN_L/R (entry), CS_OUT_L/R_{0,90,270} (outputs; 0=straight, 90=front/−z, 270=back/+z), CS_OUT_*_{90,270}_B (coincide with CS_OUT_*_0 position), CS_SELECT_L/R_{0,90,270} (marker points). PMI id IS the name; rotation in properties.rotation.value (Euler ZYX radians).
   - GroupProcessor event-sourced model: group-create/merge/detach/regroup/move/rotate; replay()→computeLayout()→materialize().
   - Edge: {from, to, fromPmi, toPmi, rot}; computeLayout positions via pmiLocal + edge.rot; dockRotation = quatMul(pmiRotation(anchor), quatConj(pmiRotation(join))).
   - blockedPmis() = graph-derived occupancy read by both placementDragPoints (snap) and withSelectionMarkers (markers).
   - Union-find post-clustering: element-ends (element, L/R) meeting at a post are unioned via edges; a slot is blocked if its world direction points into another member's body (dot>0.9).
   - transformMath: quatMul, quatConj, quatRotate, eulerZyxRadToQuat, Vec3, Quat=[w,x,y,z], IDENTITY_QUAT.

3. Files and Code Sections:
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts` (has a stray non-ASCII byte making grep treat it binary; use `grep -a`)
     - `withSelectionMarkers(scene)`: adds green sphere markers at CS_SELECT PMIs of selected element roots, only where the CS_OUT anchor is not in blockedPmis. Final marker structure:
       ```js
       markers.push({
           id: `sel-marker-${h.id}-${c.id}`,
           tags: ["name-select-marker"],
           components: [
               { _resourceClass: "component_transform", parentKey: tr.key, translation: pos, rotation: [0, 0, 0], scale: [1, 1, 1] },
               { _resourceClass: "component_renderer", castShadow: false, receiveShadow: false,
                 geometry: { _resourceClass: "geometry_parametric", parameters: { factoryName: "sphere-3d", diameter: 120 } },
                 material: { _resourceClass: "material_basic", diffuse: [0, 1, 0], depthTest: true } },
               { _resourceClass: "component_collider", type: 2, shapeType: 4, center: [0, 0, 0], dimension: [80, 80, 80] },
           ],
       });
       ```
       Occupancy check inside loop: `const occupied = new Set(this.groups.blockedPmis().map((o) => \`${o.element} ${o.pmi}\`));` then per CS_SELECT pmi: `const anchorPmi = String(c.id).replace("CS_SELECT_", "CS_OUT_"); if (occupied.has(\`${h.id} ${anchorPmi}\`)) continue;`
     - `handleSelectionClickEvent`: intercepts marker clicks — `if (typeof targetId === "string" && targetId.startsWith("sel-marker-")) { const pmi = targetId.slice(targetId.lastIndexOf("-") + 1); const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-")); return this.handleMarkerBuild(element, pmi, stop); }`
     - `handleMarkerBuild(element, pmi, stop)`:
       ```js
       this.replay();
       const anchorPmi = pmi.replace("CS_SELECT_", "CS_OUT_");
       const joinPmi = pmi.includes("_R_") ? "CS_EIN_L" : "CS_EIN_R";
       const groupId = this.groups.groupIdOfElement(element);
       const scene = this.groups.sceneOfElement(element);
       if (!groupId || !scene) return [stop];
       if (this.groups.occupiedPmis().some((o) => o.element === element && o.pmi === anchorPmi)) return [stop];
       const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);
       this.eventStore.push(createEvent);
       this.eventStore.push({ type: "group-merge", timestamp: Date.now(), targetGroupId: groupId, movingGroupId: createEvent.groupId, anchorRootId: element, joinRootId: createEvent.rootHandleId, anchorPmiId: anchorPmi, joinPmiId: joinPmi,
           rot: this.groups.dockRotation(element, anchorPmi, element, joinPmi) } as GroupEvent);  // NOTE: element used for BOTH roots (copy not yet replayed)
       this.selection = [];  // markers disappear after build
       return this.selectionReactions(stop);
       ```
     - Two consumers use blockedPmis (lines ~265 withSelectionMarkers, ~620 placementDragPoints). Two `.some()` guards (lines ~132 keyboard-build, ~203 handleMarkerBuild) still use occupiedPmis intentionally.
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.ts`
     - `occupiedPmis()`: literal edge occupancy (from/fromPmi, to/toPmi). Unchanged.
     - `blockedPmis()` (FINAL geometric version):
       ```js
       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);
               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 }); }
               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;
                   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;
                           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) }; });
       }
       ```
     - `dockRotation(anchorRoot, anchorPmi, joinRoot, joinPmi)` = quatMul(pmiRotation(anchorRoot,anchorPmi), quatConj(pmiRotation(joinRoot,joinPmi))). pmiRotation reads from this.handles map (populated during replay); returns IDENTITY if handle/pmi not found.
   - `/Users/marcus.hinz/projects/adesso-cpq/design/ADR-0001-Steher-Belegung-Verbau.md` (CREATED)
     - New ADR documenting the shared-post occupancy decision. Standard format (Status: Angenommen 2026-07-09, Kontext, Leitgedanke, Entscheidung, Konsequenzen, Annahmen und Grenzen, Verworfene Alternativen). German prose, English code terms, matching design/BLAST-Graph-Constraints.md style. Documents the 3-element fan example, union-find clustering, dot>0.9 rule, and the 4 caveats verbatim.
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products/schutzzaun_basic/SBS1044PRA.json` — reference part; PMI positions/rotations used for all simulations. CS_OUT_R_0=[1114,1067,-20] rot[0,-1.5708,0]; CS_OUT_R_90=[1064,1067,-110] rot[0,0,0]; CS_OUT_R_270=[1064,1067,30] rot[π,0,π]; CS_OUT_R_90_B & CS_OUT_R_270_B both =[1114,1067,-20] (coincide with CS_OUT_R_0). Material via material_reference (pil_eloxiert_natur.json etc.); geometry via sceneGraph_reference glb (dimensions baked, no free height/width params).

4. Errors and fixes:
   - getBounds crash on select (both glb and geometry versions): ROOT CAUSE = collider shapeType 2 (sphere) has no registered shapeDescriptor in this @ad/fire build (only box/capsule/mesh). Fixed by using shapeType 4 (box) with `dimension: [80,80,80]`. User was iterating and frustrated; the box collider is the fix.
   - "Winkel zuornung passt nicht" → I added a 90↔270 swap based on rotation-frame reasoning. This was WRONG. User feedback "Wenn ich auf _0 klicke, baut er nach vorn" led me to simulate computeLayout, which proved CS_SELECT_*_x → CS_OUT_*_x (no swap) is geometrically correct (0=straight, 90=front, 270=back). Reverted the swap.
   - "_0 baut nach vorn" (real cause): `dockRotation(element, anchorPmi, createEvent.rootHandleId, joinPmi)` passed the fresh copy (not yet replayed into this.handles) as joinRoot → pmiRotation fell back to IDENTITY → dockRotation = R(−90°Y) instead of identity → 90° tilt. Shortcut avoided it via `rot: IDENTITY_QUAT`. Fixed by using `element` for BOTH roots (copy is identical clone).
   - Marker occupancy "shows all three even when CS_OUT occupied": the copy's LEFT end is occupied via CS_EIN_L (join point), not CS_OUT_L_0. Fixed with straight-0 blocked by CS_OUT_side_0 OR CS_EIN_side.
   - "Macht es nicht Sinn, Rotation aus dem Ziel zu bestimmen?": I simulated — using CS_SELECT frame as dock anchor makes the copy overlap the original (CS_SELECT_R_0 → copy center x≈842). Answered no.
   - "Middle Steher" rule (branch blocks 0 and other branch): user reported "neee jetzt ist es kaputt" — a single 270 build wrongly hid 0 and 90. Reverted to junction-mirror-only, then superseded entirely by the geometric post rule.
   - Junction-mirror insufficient for 3-element fan: only mirrored across straight (CS_OUT_*_0) edges, not branch junctions. Superseded by union-find geometric clustering.
   - File has a stray byte (grep treats DefaultCommandProcessor.ts as binary); replace_all only matched one of two identical lines. Fixed with a targeted shorter-substring Edit; use `grep -a`.

5. Problem Solving:
   Solved: geometry+box-collider marker rendering (no crash); CS_SELECT filtering; marker-click copy building; dockRotation copy-not-replayed bug; occupancy-based marker hiding; graph-constant junction/post occupancy via geometric union-find clustering. The final blockedPmis geometric rule was validated by Python emulation confirming the 3-element fan gives exactly 1 free slot per element (URSPRUNG frei=0, others frei=outward direction). All 25 tests (GroupProcessor + DefaultCommandProcessor) pass; typecheck clean (only pre-existing ProductBrowser.vue errors excluded).

6. All user messages:
   - "du hattest vorhin einee Lösung mkit Geometry und colider Nimm die" (with pasted getBounds crash trace)
   - "read properties of undefined (reading 'getBounds')..." (pasted crash after geometry version)
   - "perfekt jetzt schränke den Einbau auf die PMIs ein, die mit select beginnen"
   - "Aslo depend vom gelickten pmi kopiere die komponente und baue die Kopie in entsprechender orintierung an die Komonente an"
   - "Die Winkel zuornung passt nicht"
   - "Wenn ich auf _0 klicke, baout er nach vorn aber genau das meinte ich kannst du mal des Select PMI und out_PMI loggenn?"
   - "Macht es nicht viel mehr Sinn, die Rotation der Kopie einfach aus dem Ziel zu bestimmen?"
   - "Erbaut nach vorn aber genau das meinte ich kannst du mal des Select PMI und out_PMI loggen?"
   - "also SELECT_R_0 geklicke :: passt / CS_OUT_R_O als Ziel --> passt / Einbau auf C_EIN_L --> passt auch / er rendert aber nach vorn / obwohl scheibnabr der richrige Slot Bessetzt wird / das Problem gibt es mit dem Shoartćut nicht / ich gebe die mal alle deri Stadien des Graphen?"
   - "ok das gleice gilt natürlich auch in 0-Richtung... [reverted context] jetzt ein wenig aufräumen 1 - Die kugeln auch ausbauen, wenn etwas angebaut wird 2 - Nur auf die Selektoren einbauen, auf die man auch ein Element einbauen kann"
   - "er zeigt immer noch alle drei Marker an, auch wenn C_OUT schon belegt ist / Einfaches Element: ich baue rechts an --> erwartung Recht wird nur noch 90 und 270 gezeigt IST 0 erscheint weiterhin"
   - "Stimm nicht / Einfaches Element / einbua rechts / dann Rechtes element wählen / Erwartung: am rechten Element RECHTS alle drei Selectpunkte Am rechtene Element LINKS nur 90 und 270: 0 ist ja durch das Unsprungelement belegt"
   - "die kugeln werden gezeigt, auch wenn sie eigentlich vom element verdeckt sein müssten"
   - "sehr gut jetzt nur noch ein dumme idee: Element an element macht Sinn Tür an Tür nicht könnte man in der DockingRules eine Elementypen für das Weiterbauen festlegen?"
   - "autoContinueType oder so wäre gut ABER - es müssten MÖGLICHST VIELE eigenschaften Übernommen werden (maetrial / hähe / Breite -> die könnten auch spezifisch konfiguriert sein"
   - "dann lassen wir das jetzt mal"
   - "wir habe mit dem seitenanbau noch ein thema wenn zwei Element verbunden sind, gibt es der Theorie nach ZWEI Steher dazwischen, na denen nch vorne oder hinten Verbaut werden könnte davon DARF aber nur einer belegt sein und es macht Sinn dafür eine über den Graphen konstante Regel zu haben Sowohl, was mögliche SnapPoint (Droppints) beinm Positionieren als auch die Anzeige der EinbauSelektoren angeht"
   - "Aslo von den beiden steher darf nur einmal 90 und einaml 270 belegt sein"
   - "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 Selektore Kugeln"
   - "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)"
   - "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)"
   - "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"
   - "Sieht gut aus und ist - denke ich - eine brauchabere logik?"
   - "Die kommen noch kannst Du dazu ein ADR in der doku (design)ß?) schreiben?"
   - "gut commit und push"
   - "das ist gut" (approval)

7. Pending Tasks:
   - None explicitly open. The autoContinueType feature was discussed but user said "dann lassen wir das jetzt mal". New Bauteil geometries "kommen noch" (future) — the ADR flags the 4 assumptions to re-check when they arrive.

8. Current Work:
   Immediately before this summary, the user said "gut commit und push" then "das ist gut". I committed the coherent Verbau/Steher work and pushed. Branch is `Produktmanagement` (not main; remote https://git.heron.at/pzm/geko/adesso-cpq.git). Staged and committed ONLY: `packages/frontend/src/configurator/model/GroupProcessor.ts`, `packages/frontend/src/configurator/events/DefaultCommandProcessor.ts`, `design/ADR-0001-Steher-Belegung-Verbau.md`. Commit `cf44b2c` message: "Verbau: Select-Marker + geteilte Steher-Belegung (blockedPmis)" with bullets (no Claude mention per CLAUDE.md). Pushed fast-forward (d448cdb..cf44b2c). Deliberately left UNSTAGED and uncommitted: deleted .glb files (R5R_ROLLENFOERDERER_790_410.glb, SBD2083PLA.glb), rollenfoerderer product JSONs (the reverted experiment), and modified docking-rules.json, Fire3D.vue, ProductEditorDemo.vue, DefaultCommandProcessor.test.ts, product-service.ts (other unrelated concerns). I told the user which files were included/excluded and said "Wenn davon etwas mit rein soll, sag welche."

9. Optional Next Step:
   None. The last task (commit and push) is complete and the user confirmed "das ist gut". No further action should be taken without a new explicit request. If the user wants the deliberately-excluded files (glb deletions, rollenfoerderer JSONs, docking-rules.json, Fire3D.vue, ProductEditorDemo.vue, DefaultCommandProcessor.test.ts, product-service.ts) committed, they would need to specify which — as I stated: "Wenn davon etwas mit rein soll, sag welche."

If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/marcus.hinz/.config/ai-control/pools/private/projects/-Users-marcus-hinz-claude-projects-robotunits/a6be7e36-bff5-4be8-8bb5-8165855b6118.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.


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


<template>

            <FireControls :class="['fire3d__controls', { visible: controlsVisible }]" />

        <FireContextMenu
            v-if="contextMenu?.entries?.length"
            :entries="contextMenu.entries"
            @select="onContextMenuSelect"
            @close="closeContextMenu"
        />

            <slot name="editor" />

            <slot name="bottom" />

        <ToastNotification ref="toast" />
        <AppDialog ref="dialog" />

</template>


CronCreate CronDelete CronList DesignSync EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Monitor NotebookEdit PushNotification RemoteTrigger SendMessage TaskCreate TaskGet TaskList TaskOutput TaskStop TaskUpdate WebFetch WebSearch


claude claude-code-guide Explore general-purpose Plan statusline-setup

- claude: Catch-all for any task that doesn't fit a more specific agent. FleetView's default when no agent name is typed. (Tools: *)

- claude-code-guide: Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - Messages API for directly passing messages to Claude, Tool Runner (`client.beta.messages.tool_runner`) for running an agentic loop over your own tools, manual tool-use loops, Managed Agents for server-hosted agents with a managed sandbox, prompt caching, and general Anthropic SDK usage; (4) Claude Tag (Claude in Slack) - what it is, setting it up for a Slack workspace, `/install-slack-app`. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage. (Tools: Bash, Read, WebFetch, WebSearch)

- Explore: Read-only search agent for broad fan-out searches — when answering means sweeping many files, directories, or naming conventions and you only need the conclusion, not the file dumps. It reads excerpts rather than whole files, so it locates code; it doesn't review or audit it. Specify search breadth: "medium" for moderate exploration, "very thorough" for multiple locations and naming conventions. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)

- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)


# Offene Punkte — bei jedem Start prüfen und abhaken

Keine offenen Punkte.

jq -Rs '{systemMessage: ., hookSpecificOutput:{hookEventName:"SessionStart", additionalContext: .}}' /Users/marcus.hinz/claude-projects/robotunits/OFFENE-PUNKTE.md


# Offene Punkte — bei jedem Start prüfen und abhaken

Keine offenen Punkte.


I need to look at the actual `blockedPmis`/`computeLayout` code to confirm the cause before saying anything.


Bash

cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend && grep -n "blockedPmis\|occupiedPmis\|computeLayout\|pmiLocal\|groupIdOfElement\|clusters\|union" src/configurator/model/GroupProcessor.ts | head -80

Locate relevant functions in GroupProcessor


184:    groupIdOfElement(rootHandleId: string): string | null {
203:    occupiedPmis(): { element: string; pmi: string }[] {
214:    // occupiedPmis plus the junction rule: where two elements meet straight (an edge on CS_OUT_*_0),
219:    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
223:    blockedPmis(): { element: string; pmi: string }[] {
224:        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
232:            const layout = this.computeLayout(g);
233:            // Cluster the element-ends that meet at one physical post (union-find over the edges).
241:            const union = (a: string, b: string) => parent.set(find(a), find(b));
247:                union(node(e.from, fs), node(e.to, ts));
249:            const clusters = new Map<string, { el: string; end: string }[]>();
252:                if (!clusters.has(r)) clusters.set(r, []);
254:                clusters.get(r)!.push({ el, end });
263:            for (const members of clusters.values()) {
268:                    dir: dirOf(m.el, this.pmiLocal(m.el, `CS_EIN_${m.end}`), this.pmiLocal(m.el, `CS_EIN_${other(m.end)}`)),
271:                    const post = this.pmiLocal(m.el, `CS_EIN_${m.end}`);
274:                        const sdir = dirOf(m.el, post, this.pmiLocal(m.el, slotPmi));
346:        const layout = this.computeLayout(g);
396:            const layout = this.computeLayout(g);
527:                const occupied = this.occupiedPmis();
588:            const layout = this.computeLayout(g);
620:    private computeLayout(g: Group): Map<string, Transform> {
647:                const at = this.pmiLocal(fromRoot, s.atPmi);
648:                const dock = this.pmiLocal(toRoot, s.dockPmi);
736:    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {


180	            rotation: IDENTITY_QUAT,
181	        };
182	    }
183
184	    groupIdOfElement(rootHandleId: string): string | null {
185	        return this.elementToGroup.get(rootHandleId) ?? null;
186	    }
187
188	    groupByKey(key: string): string | null {
189	        for (const g of this.groups.values()) if (g.key === key) return g.id;
190	        return null;
191	    }
192
193	    groupKeyOf(groupId: string): string | null {
194	        return this.groups.get(groupId)?.key ?? null;
195	    }
196
197	    transformOf(groupId: string): Transform | null {
198	        return this.groups.get(groupId)?.transform ?? null;
199	    }
200
201	    // Docking points already in use, derived purely from the edges (no stored state). Each edge
202	    // occupies its `fromPmi` on `from` and its `toPmi` on `to`. Open ends are everything else.
203	    occupiedPmis(): { element: string; pmi: string }[] {
204	        const out: { element: string; pmi: string }[] = [];
205	        for (const g of this.groups.values()) {
206	            for (const e of g.edges) {
207	                out.push({ element: e.from, pmi: e.fromPmi });
208	                out.push({ element: e.to, pmi: e.toPmi });
209	            }
210	        }
211	        return out;
212	    }
213
214	    // occupiedPmis plus the junction rule: where two elements meet straight (an edge on CS_OUT_*_0),
215	    // the two abutting posts share a branch slot per direction — only one 90 and one 270 may sit in
216	    // that gap. So a branch occupied on one post's CS_OUT_<side>_<90|270> also blocks the mirror
217	    // CS_OUT_<otherSide>_<same> on the neighbour. Both the placement drop points and the select
218	    // markers read this, so the rule is graph-constant across positioning and display.
219	    // occupiedPmis plus the junction rule: posts that abut act as one logical post with four slots
220	    // (the panel directions). All element-ends meeting at one post share it, so a build slot is open
221	    // only if its direction is free — i.e. it does not point into another element's body. This is
222	    // read by both the placement drop points and the select markers, so the rule is graph-constant.
223	    blockedPmis(): { element: string; pmi: string }[] {
224	        const blocked = new Set(this.occupiedPmis().map((o) => `${o.element} ${o.pmi}`));
225	        const norm = (v: Vec3): Vec3 => {
226	            const l = Math.hypot(v[0], v[1], v[2]);
227	            return l < 1e-6 ? [0, 0, 0] : [v[0] / l, v[1] / l, v[2] / l];
228	        };
229	        const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
230	        const other = (end: string) => (end === "L" ? "R" : "L");
231	        for (const g of this.groups.values()) {
232	            const layout = this.computeLayout(g);
233	            // Cluster the element-ends that meet at one physical post (union-find over the edges).
234	            const parent = new Map<string, string>();
235	            const find = (x: string): string => {
236	                if (!parent.has(x)) parent.set(x, x);
237	                let r = parent.get(x)!;
238	                if (r !== x) { r = find(r); parent.set(x, r); }
239	                return r;
240	            };
241	            const union = (a: string, b: string) => parent.set(find(a), find(b));
242	            const node = (el: string, end: string) => `${el}|${end}`;
243	            for (const e of g.edges) {
244	                const fs = /^CS_OUT_([LR])_/.exec(e.fromPmi)?.[1];
245	                const ts = /^CS_EIN_([LR])$/.exec(e.toPmi)?.[1];
246	                if (!fs || !ts) continue;
247	                union(node(e.from, fs), node(e.to, ts));
248	            }
249	            const clusters = new Map<string, { el: string; end: string }[]>();
250	            for (const key of parent.keys()) {
251	                const r = find(key);
252	                if (!clusters.has(r)) clusters.set(r, []);
253	                const [el, end] = key.split("|");
254	                clusters.get(r)!.push({ el, end });
255	            }
256	            // World direction (rotation only — one group) of `to − from`, both element-local points.
257	            const dirOf = (el: string, from: Vec3, to: Vec3): Vec3 => {
258	                const rot = layout.get(el)?.rot ?? IDENTITY_QUAT;
259	                const a = quatRotate(rot, from);
260	                const b = quatRotate(rot, to);
261	                return norm([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
262	            };
263	            for (const members of clusters.values()) {
264	                if (members.length < 2) continue; // lone free end — nothing blocks it
265	                // Each member's body extends from its post toward its opposite end.
266	                const bodies = members.map((m) => ({
267	                    el: m.el,
268	                    dir: dirOf(m.el, this.pmiLocal(m.el, `CS_EIN_${m.end}`), this.pmiLocal(m.el, `CS_EIN_${other(m.end)}`)),
269	                }));
270	                for (const m of members) {
271	                    const post = this.pmiLocal(m.el, `CS_EIN_${m.end}`);
272	                    for (const a of ["0", "90", "270"]) {
273	                        const slotPmi = `CS_OUT_${m.end}_${a}`;
274	                        const sdir = dirOf(m.el, post, this.pmiLocal(m.el, slotPmi));
275	                        if (sdir[0] === 0 && sdir[1] === 0 && sdir[2] === 0) continue;
276	                        // A slot pointing into another member's body is taken by that member.
277	                        if (bodies.some((b) => b.el !== m.el && dot(b.dir, sdir) > 0.9)) {
278	                            blocked.add(`${m.el} ${slotPmi}`);
279	                        }
280	                    }
281	                }
282	            }
283	        }
284	        return [...blocked].map((k) => {
285	            const sp = k.lastIndexOf(" ");
286	            return { element: k.slice(0, sp), pmi: k.slice(sp + 1) };
287	        });
288	    }
289
290	    groupCount(): number {
291	        return this.groups.size;
292	    }
293
294	    roleOfId(handleId: string): NodeRole | null {
295	        return this.nodeIndex.get(handleId)?.role ?? null;
296	    }
297
298	    // Rebuild a standalone product scene from a placed element's subtree — for duplicating it. The
299	    // element root's parentKey is stripped so buildCreateEvent (via instantiateScene) treats it as a
300	    // fresh element root and re-ids the copy. (Prototype helper for the keyboard build shortcut.)
301	    sceneOfElement(elementRoot: string): { _resourceClass: string; handles: Handle[] } | null {
302	        const handles = this.elementHandles.get(elementRoot);
303	        if (!handles) return null;
304	        const clone = structuredClone(handles);
305	        for (const h of clone) {
306	            if (h.id !== elementRoot) continue;
307	            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
308	            if (tr) delete tr.parentKey;
309	        }

            rotation: IDENTITY_QUAT,
        };
    }

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

    // occupiedPmis plus the junction rule: where two elements meet straight (an edge on CS_OUT_*_0),
    // the two abutting posts share a branch slot per direction — only one 90 and one 270 may sit in
    // 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.
    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) };
        });
    }

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

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

    // Rebuild a standalone product scene from a placed element's subtree — for duplicating it. The
    // element root's parentKey is stripped so buildCreateEvent (via instantiateScene) treats it as a
    // fresh element root and re-ids the copy. (Prototype helper for the keyboard build shortcut.)
    sceneOfElement(elementRoot: string): { _resourceClass: string; handles: Handle[] } | null {
        const handles = this.elementHandles.get(elementRoot);
        if (!handles) return null;
        const clone = structuredClone(handles);
        for (const h of clone) {
            if (h.id !== elementRoot) continue;
            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
            if (tr) delete tr.parentKey;
        }


Bug is confirmed at the rule level; let me check whether the placement path already has any cross-element collision filter I'd extend.


620	    private computeLayout(g: Group): Map<string, Transform> {
621	        const out = new Map<string, Transform>();
622	        if (!g.members.length) return out;
623
624	        type Step = { neighbour: string; atPmi: string; dockPmi: string; reverse: boolean; rot: Quat };
625	        const adj = new Map<string, Step[]>();
626	        const link = (node: string, step: Step) => {
627	            if (!adj.has(node)) adj.set(node, []);
628	            adj.get(node)!.push(step);
629	        };
630	        for (const e of g.edges) {
631	            const rot = e.rot ?? IDENTITY_QUAT;
632	            link(e.from, { neighbour: e.to, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: false, rot });
633	            link(e.to, { neighbour: e.from, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: true, rot });
634	        }
635
636	        const origin = g.members[0];
637	        out.set(origin, { pos: [0, 0, 0], rot: IDENTITY_QUAT });
638	        const queue = [origin];
639	        while (queue.length) {
640	            const cur = queue.shift()!;
641	            const t = out.get(cur)!;
642	            for (const s of adj.get(cur) ?? []) {
643	                if (out.has(s.neighbour)) continue;
644	                // `at` is on the `from` side, `dock` on the `to` side.
645	                const fromRoot = s.reverse ? s.neighbour : cur;
646	                const toRoot = s.reverse ? cur : s.neighbour;
647	                const at = this.pmiLocal(fromRoot, s.atPmi);
648	                const dock = this.pmiLocal(toRoot, s.dockPmi);
649	                // Neighbour's accumulated rotation: compose edge.rot forward, its inverse on reverse.
650	                const rotN = s.reverse ? quatMul(t.rot, quatConj(s.rot)) : quatMul(t.rot, s.rot);
651	                const fromRot = s.reverse ? rotN : t.rot;
652	                const toRot = s.reverse ? t.rot : rotN;
653	                const a = quatRotate(fromRot, at);
654	                const d = quatRotate(toRot, dock);
655	                // pos(to) = pos(from) + R_from·at − R_to·dock. On reverse, `cur` IS `to`, so solve for
656	                // `from`: pos(from) = pos(to) + R_to·dock − R_from·at — same vector, opposite sign.
657	                const sign = s.reverse ? -1 : 1;
658	                out.set(s.neighbour, {
659	                    pos: [
660	                        t.pos[0] + sign * (a[0] - d[0]),
661	                        t.pos[1] + sign * (a[1] - d[1]),
662	                        t.pos[2] + sign * (a[2] - d[2]),
663	                    ],
664	                    rot: rotN,
665	                });
666	                queue.push(s.neighbour);
667	            }
668	        }
669	        return out;
670	    }
671
672	    // Connected components of `members` under `edges` (undirected), via BFS.
673	    private connectedComponents(members: string[], edges: Edge[]): string[][] {
674	        const adj = new Map<string, string[]>();
675	        for (const m of members) adj.set(m, []);
676	        for (const e of edges) {
677	            adj.get(e.from)?.push(e.to);
678	            adj.get(e.to)?.push(e.from);
679	        }
680	        const seen = new Set<string>();
681	        const comps: string[][] = [];
682	        for (const m of members) {
683	            if (seen.has(m)) continue;
684	            const comp: string[] = [];
685	            const queue = [m];
686	            seen.add(m);
687	            while (queue.length) {
688	                const cur = queue.shift()!;
689	                comp.push(cur);
690	                for (const nb of adj.get(cur) ?? [])
691	                    if (!seen.has(nb)) {
692	                        seen.add(nb);
693	                        queue.push(nb);
694	                    }
695	            }
696	            comps.push(comp);
697	        }
698	        return comps;
699	    }
700
701	    private groupNodeHandle(g: Group): Handle {
702	        return {
703	            id: g.key,
704	            tags: [roleTag(ROLE.assembly)],
705	            components: [
706	                {
707	                    _resourceClass: "component_transform",
708	                    key: g.key,
709	                    parentKey: ROOT_KEY,
710	                    translation: g.transform.pos,
711	                    rotation: g.transform.rot,
712	                    scale: [1, 1, 1],
713	                },
714	            ],
715	        };
716	    }
717
718	    private sceneRootHandle(): Handle {
719	        return {
720	            id: ROOT_KEY,
721	            tags: [roleTag(ROLE.scene)],
722	            components: [
723	                {
724	                    _resourceClass: "component_transform",
725	                    key: ROOT_KEY,
726	                    translation: [0, 0, 0],
727	                    rotation: [0, 0, 0],
728	                    scale: [1, 1, 1],
729	                },
730	            ],
731	        };
732	    }
733
734	    // ---- helpers -----------------------------------------------------------
735
736	    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
737	        const handle = this.handles.get(rootHandleId);
738	        for (const comp of handle?.components ?? []) {
739	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {

    private computeLayout(g: Group): Map<string, Transform> {
        const out = new Map<string, Transform>();
        if (!g.members.length) return out;

        type Step = { neighbour: string; atPmi: string; dockPmi: string; reverse: boolean; rot: Quat };
        const adj = new Map<string, Step[]>();
        const link = (node: string, step: Step) => {
            if (!adj.has(node)) adj.set(node, []);
            adj.get(node)!.push(step);
        };
        for (const e of g.edges) {
            const rot = e.rot ?? IDENTITY_QUAT;
            link(e.from, { neighbour: e.to, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: false, rot });
            link(e.to, { neighbour: e.from, atPmi: e.fromPmi, dockPmi: e.toPmi, reverse: true, rot });
        }

        const origin = g.members[0];
        out.set(origin, { pos: [0, 0, 0], rot: IDENTITY_QUAT });
        const queue = [origin];
        while (queue.length) {
            const cur = queue.shift()!;
            const t = out.get(cur)!;
            for (const s of adj.get(cur) ?? []) {
                if (out.has(s.neighbour)) continue;
                // `at` is on the `from` side, `dock` on the `to` side.
                const fromRoot = s.reverse ? s.neighbour : cur;
                const toRoot = s.reverse ? cur : s.neighbour;
                const at = this.pmiLocal(fromRoot, s.atPmi);
                const dock = this.pmiLocal(toRoot, s.dockPmi);
                // Neighbour's accumulated rotation: compose edge.rot forward, its inverse on reverse.
                const rotN = s.reverse ? quatMul(t.rot, quatConj(s.rot)) : quatMul(t.rot, s.rot);
                const fromRot = s.reverse ? rotN : t.rot;
                const toRot = s.reverse ? t.rot : rotN;
                const a = quatRotate(fromRot, at);
                const d = quatRotate(toRot, dock);
                // pos(to) = pos(from) + R_from·at − R_to·dock. On reverse, `cur` IS `to`, so solve for
                // `from`: pos(from) = pos(to) + R_to·dock − R_from·at — same vector, opposite sign.
                const sign = s.reverse ? -1 : 1;
                out.set(s.neighbour, {
                    pos: [
                        t.pos[0] + sign * (a[0] - d[0]),
                        t.pos[1] + sign * (a[1] - d[1]),
                        t.pos[2] + sign * (a[2] - d[2]),
                    ],
                    rot: rotN,
                });
                queue.push(s.neighbour);
            }
        }
        return out;
    }

    // Connected components of `members` under `edges` (undirected), via BFS.
    private connectedComponents(members: string[], edges: Edge[]): string[][] {
        const adj = new Map<string, string[]>();
        for (const m of members) adj.set(m, []);
        for (const e of edges) {
            adj.get(e.from)?.push(e.to);
            adj.get(e.to)?.push(e.from);
        }
        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],
                },
            ],
        };
    }

    // ---- helpers -----------------------------------------------------------

    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
        const handle = this.handles.get(rootHandleId);
        for (const comp of handle?.components ?? []) {
            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {


Ursache bestätigt — im Code, nicht in den Daten.

`blockedPmis()` (GroupProcessor.ts:223) sperrt einen Slot nur, wenn er innerhalb **eines** aneinanderstoßenden Steher-Clusters in den Körper eines *anderen Cluster-Members* zeigt (`dot(bodyDir, slotDir) > 0.9`, Zeile 277). Die Cluster entstehen ausschließlich über Kanten am selben Steher (Union-Find, Zeile 243–247). Eine Kollisionsprüfung **über Steher hinweg** gibt es nicht.

Im geschlossenen Quadrat läuft die Schleife über die 90/270-Abzweige; der gerade Slot `CS_OUT_<side>_0` des vorderen Elements ist an *seinem* Steher in eine freie Richtung → nicht gesperrt → Marker erscheint → Bau erlaubt.

Diff der beiden Graphen: das gebaute Element ist `h-a4d63a2a` / `k-e431ad9c` bei Welt `[-1557, 0, -1557]`, 180° um Y. Es liegt parallel und ~50 mm neben dem bestehenden `h-2b3dede6` / `k-d9aa7770` bei `[-1607, 0, -1557]` (identische Orientierung, X-Überlappung ~1450 mm) — praktisch ein Duplikat auf der Nachbarseite des Quadrats. Genau die Kollision, die die Cluster-lokale Regel nicht sehen kann, weil das kollidierende Element zu einem anderen Steher gehört.

Fix-Richtung (Plan):
- In `blockedPmis()` zusätzlich die **Welt-Steher-Positionen** aller Elemente aus `computeLayout` (pos+rot) × `pmiLocal(CS_EIN_L/R)` bilden.
- Pro offenem Slot den **Ziel-Fernsteher** der Kopie schätzen: `postWorld + L · slotWorldDir` (L = Elementlänge ≈ `|CS_EIN_L − CS_EIN_R|`, `slotWorldDir` liegt via `dirOf` schon vor).
- Slot sperren, wenn dieser Ziel-Steher mit einem bereits belegten Welt-Steher zusammenfällt (Epsilon). Damit sieht die Regel den Loop-Schluss und bleibt graph-konstant für Marker und Snap.
- Neue Annahme (`L`, Epsilon) in ADR-0001 unter „Annahmen und Grenzen" ergänzen.

Ich warte auf Go, bevor ich das umsetze.


warum nur bei diesem Quadrat?


Weil die Kollision nur dort auftritt, wo ein **offener** `CS_OUT_*_0`-Slot in bereits belegten Raum zeigt — und das ist geometrisch genau die geschlossene, enge Schleife.

Bedingungen, die zusammenkommen müssen:
1. Der gerade Slot ist offen: die Schleife biegt über 90/270 ab, nicht über 0. An einem geschlossenen Eck ist `CS_OUT_side_0` also frei (keine Kante darauf).
2. Die gerade Verlängerung landet auf einem vorhandenen Element: bei Seitenlänge = ein Element (Rasterabstand ~1517) läuft die Fortsetzung der Vorderseite direkt neben die Nachbarseite → Überlappung.

Überall sonst im Graphen ist eine dieser Bedingungen nicht erfüllt:
- **Freie Enden / offene Läufe:** der gerade Slot zeigt nach außen ins Leere → Bau korrekt erlaubt.
- **Gerade Stöße (inline):** `CS_OUT_side_0` ist bereits durch eine Kante belegt → schon über `occupiedPmis` gesperrt.
- **Größere Schleife:** die gerade Fortsetzung liefe in den leeren Innenhof, nicht auf ein Element → wäre real frei.

Nur beim kleinen Quadrat fällt „Slot offen" mit „Fortsetzung trifft Nachbarseite" zusammen. Die Cluster-lokale Regel prüft nur den geteilten Steher, sieht die parallele Nachbarseite (anderer Steher) nicht — deshalb bleibt genau dieser eine Slot fälschlich offen.


ok -
problem bei der Lage schätzung
ich kan zwei unabhängige Zäune im Graph haben und es darf / soll KEINE Kollisionsprüfung stattfinden
Die würdest du über Deiene REgel aber implizit einfügen


Stimmt. Mein `postWorld + L·slotDir` ist genau eine Kollisionsvorhersage — „wo landet der Körper der Kopie, überlappt er einen vorhandenen Körper?". Das ist eine Kollisionsprüfung und gehört nicht in diese Schicht.

Kern: Belegt-Sein ist topologisch (Kanten / geteilter Verbinder), nicht räumlich. Räumliche Überlagerung ist ausdrücklich erlaubt — zwei unabhängige Zäune dürfen sich decken. Identische Weltpositionen dürfen also gerade **nicht** „gesperrt" heißen; meine Regel würde das umdrehen.

Präzisierung: meine Schätzung lief pro Gruppe, hätte zwei *separate* Zäune (separate Gruppen aus `connectedComponents`) also nicht verglichen. Aber schon innerhalb einer Gruppe macht sie aus Koinzidenz eine harte Sperre — genau die Kollisionssemantik, die du ablehnst. Fällt raus.

Konsequenz, unbequem: der offene Gerade-Slot im Quadrat ist eine reine Körper-Kollision. Die Kopie landet als leicht schräges Duplikat (~70 mm) neben einem Element an einem **unverbundenen** Steher — keine koinzidenten Verbinder, keine Kante. Topologisch ist dieser Slot von einem legitim freien Gerade-Slot nicht unterscheidbar. Auch Steher-Identität (Union koinzidenter Posten) greift nicht, weil die Posten nicht zusammenfallen.

Damit bleibt `blockedPmis` rein topologisch, und dieser eine Fall ist als graph-konstante Regel nicht abfangbar. Verhindern ließe er sich nur als explizite Kollisionsvalidierung der konkreten Bau-Aktion — also genau die Prüfung, die du ausschließt. So wie es steht, bietet der Marker den Bau weiter an.


ok - problem bei der Lage schätzung ich kan zwei unabhängige Zäune im Graph haben und es darf / soll KEINE Kollisionsprüfung stattfinden Die würdest du über Deiene REgel aber implizit einfügen


NEIN
ich denke, es wäre besser das Group Maangement noch mal zu betrachten
Ich habe es irgendwie geschafft, zei Steher zueinader zu stellen, ohne dass ein Gruppe daraus gemacht worden ist
das ist der Bug, oder?


Ja. Das ist die bessere Spur, und sie passt.

`blockedPmis` ist **kanten-getrieben**: Junctions kennt es nur über Edges (`group-merge`). Stehen zwei Steher geometrisch zusammen, ohne dass an diesem Stoß eine Kante entstanden ist, ist die Junction für die Regel unsichtbar → beide Enden zählen als lose (`members.length < 2`) → Slot bleibt offen. Meine Kollisionsidee hätte nur eine fehlende Kante kaschiert, statt sie zu erzeugen.

Warum es genau beim Quadrat auftritt, passt jetzt auch: das Schließen der Schleife bringt den letzten Post auf einen bestehenden, aber der Bau-/Snap-Flow legt dafür keine Kante an. `handleMarkerBuild` macht `create` + **genau einen** `group-merge` an **einen** Anker. Ein zweiter Post, der beim selben Zug auf einen vorhandenen trifft (Loop-Schluss), erzeugt keine zweite Kante — also geometrischer Stoß ohne topologische Verbindung. Dasselbe kann im Positionier-/Snap-Pfad passieren, wenn beim Andocken nur ein Post als Junction registriert wird.

Damit ist mein blockedPmis prinzipiell korrekt; der Fehler sitzt im Group-Management (fehlende Kante an der zweiten Berührung).

Zum Festnageln brauche ich die **Edges / den Event-Store**, nicht den materialisierten Graphen — der zeigt Geometrie, aber nicht, welche Stöße als Kante verbucht sind. Der Ort ist der Merge-Pfad (Marker-Bau und Positionier-Snap): wird beim Andocken jeder koinzidente Post als eigene Kante registriert, oder nur der eine Anker.


heißt?


Heißt: kein Kollisionscode. Stattdessen im Merge-Pfad dafür sorgen, dass **jeder** koinzidente Post eine Kante bekommt, nicht nur der eine Anker.

Konkret:
- Beim Bau/Snap nach dem `group-merge` prüfen, ob ein **weiterer** Post des neuen Elements mit einem bestehenden Post der Gruppe zusammenfällt (Loop-Schluss). Wenn ja, eine zweite Kante emittieren.
- Damit ist die Junction topologisch vollständig; `blockedPmis` clustert sie korrekt und sperrt den Gerade-Slot von selbst — unverändert.
- Unabhängige Zäune bleiben unberührt: es entsteht nur dann eine Kante, wenn Posten tatsächlich koinzidieren und dieselbe Gruppe schließen; separate Gruppen fassen wir nicht an.

Offen bleibt eine Design-Entscheidung: Post-Koinzidenz braucht ein Kriterium (gleicher Welt-Post innerhalb der Gruppe, Epsilon). Das ist Verbinder-Identität, keine Körper-Kollision — sauber abgegrenzt von dem, was du ausgeschlossen hast.

Zum Umsetzen brauche ich den Event-Store/Merge-Code; sag Go, dann sehe ich mir den zweiten-Kante-Fall im Merge-Pfad an.


ok - bin raus
willst du epsilon umsetzen ioder nicht?


Ja. Setze ich um.

Ich schaue mir jetzt den Merge-Pfad an und ergänze beim Docken die zweite Kante, wenn ein weiterer Post innerhalb der Gruppe koinzidiert (Epsilon-Kriterium). Danach Typecheck + Tests. Kein Commit ohne Ansage.


grep -n "group-merge\|group-create\|Edge\|edges\|type Group\|interface Group\|buildCreateEvent\|dockRotation\|pmiRotation\|EPS\|epsilon\|1e-" src/configurator/model/GroupProcessor.ts | head -60

Find merge/edge handling in GroupProcessor


13:// edges; the group itself is a scene-graph node, so the engine moves/rotates every member
36:// one place identity is minted — buildCreateEvent (which both product-add and copy funnel through)
70:export interface Edge {
81:interface Group {
84:    members: string[]; // element root handle ids, no implied order — order lives in `edges`
85:    edges: Edge[];
89:export interface GroupCreateEvent {
90:    type: "group-create";
100:export interface GroupMergeEvent {
101:    type: "group-merge";
112:export interface GroupTransformEvent {
120:export interface GroupDetachEvent {
127:    // group carries its edges and a world-preserving transform for its origin so nothing jumps.
128:    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
131:export interface GroupRegroupEvent {
137:    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
140:export type GroupEvent =
170:    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
173:            type: "group-create",
201:    // Docking points already in use, derived purely from the edges (no stored state). Each edge
206:            for (const e of g.edges) {
227:            return l < 1e-6 ? [0, 0, 0] : [v[0] / l, v[1] / l, v[2] / l];
233:            // Cluster the element-ends that meet at one physical post (union-find over the edges).
243:            for (const e of g.edges) {
299:    // element root's parentKey is stripped so buildCreateEvent (via instantiateScene) treats it as a
354:        // Drop the edges that link the element to a left neighbour (smaller coordinate); keep its
355:        // right-side edges. The element's connected component is then "element + everything right".
356:        const remEdges = g.edges.filter((e) => {
362:        const components = this.connectedComponents(g.members, remEdges);
363:        const newGroups = components.map((members) => this.makeComponentGroup(g, members, remEdges, layout));
388:            const selInduced = g.edges.filter((e) => selSet.has(e.from) && selSet.has(e.to));
398:            const restInduced = g.edges.filter((e) => !selSet.has(e.from) && !selSet.has(e.to));
403:                const ng = this.makeComponentGroup(g, comp, g.edges, layout);
408:                newGroups.push(this.makeComponentGroup(g, comp, g.edges, layout));
420:    // snap back to the source origin's orientation. Edges restricted to it.
424:        edges: Edge[],
426:    ): { members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform } {
428:        const ownEdges = edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to));
433:            edges: ownEdges,
467:    // selection-induced subgraph (edges with both ends selected) is a single component. For a chain
475:        const induced = g.edges.filter((e) => sel.has(e.from) && sel.has(e.to));
506:            case "group-create": {
514:                    edges: [],
520:            case "group-merge": {
532:                target.edges.push(...moving.edges);
533:                target.edges.push({
558:                        edges: ng.edges.map((e) => ({ ...e })),
573:                        edges: ng.edges.map((e) => ({ ...e })),
630:        for (const e of g.edges) {
672:    // Connected components of `members` under `edges` (undirected), via BFS.
673:    private connectedComponents(members: string[], edges: Edge[]): string[][] {
676:        for (const e of edges) {
749:    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
762:    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
763:    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
764:        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));


[Request interrupted by user]


Ich hatte nicht gesagt, dass du sollst
habe nicht mal verstanden, ob du wollstest

Ich verstehe nichzt, warum du nachmessen musst
Die Gruppe wird auf Basis eines Events angepasst
Ich verstehe richtig: Im falle des snaps liegfert das event quasi beide Enden / oder 2 events
bein uns kommt nur eines, die nähe zum andeen Steher wird nicht identifiziert?


Sorry — ich hatte „willst du" als Startsignal gelesen. War es nicht. Ich messe nichts, ich fasse nichts an.

Und ja, dein Verständnis ist richtig, und es ist der bessere Blick:

Eine `group-merge` kodiert **genau eine** Junction: `anchorRoot/anchorPmi` ↔ `joinRoot/joinPmi` = eine Kante. Das ist das Modell — ein Event, ein Stoß, eine Kante.

Ein Snap, der eine Schleife schließt, berührt aber **zwei** Posten gleichzeitig: der Andock-Post *und* der zweite Post, der auf einen bestehenden trifft. Das wären zwei Junctions → zwei Kanten (zwei Merges oder ein Merge mit zwei Verbindungen). Bei uns kommt nur die eine, die der Anwender/der Anker vorgibt. Die zweite Berührung wird nicht identifiziert und landet in keinem Event.

Deshalb „nachmessen": das ist nur nötig, *weil* das Event die zweite Junction nicht mitliefert. Trägt das Event sie, muss man geometrisch nichts rekonstruieren. Das Messen ist also das Symptom eines unterspezifizierten Events, nicht der Fix.

Frage ist damit nur: wo wird die zweite Junction erkannt — schon beim Erzeugen des Events (Snap/Bau kennt beide getroffenen Posten) oder erst beim Verarbeiten. Ersteres ist sauberer und bleibt im Event-Modell.


Ich hatte nicht gesagt, dass du sollst habe nicht mal verstanden, ob du wollstest  Ich verstehe nichzt, warum du nachmessen musst Die Gruppe wird auf Basis eines Events angepasst Ich verstehe richtig:…


der Snap ist in FireVisual --> nicht unser Code
Wir bauen etwas daneben --> Aber bevor wir jetzt Code und Logik noch mal bauen, die der Snap schon kann, lass mich lieber mit dem Team diskutiueren, ob es da eine Lösung gibt

Insofern:
das bitte als offenen Punbkt für morgen Früh


grep -rl "Keine offenen Punkte\|Offene Punkte" /Users/marcus.hinz/projects/adesso-cpq/.claude /Users/marcus.hinz/claude-projects/robotunits ~/.claude 2>/dev/null

Locate the open-points file


Error: Exit code 2
/Users/marcus.hinz/claude-projects/robotunits/OFFENE-PUNKTE.md


1	# Offene Punkte — bei jedem Start prüfen und abhaken
2
3	Keine offenen Punkte.
4


Write

# Offene Punkte — bei jedem Start prüfen und abhaken

- **Verbau: fehlende zweite Kante beim Loop-Schluss (mit Team klären).**
  Symptom: Im kleinen, geschlossenen Quadrat bleibt ein Gerade-Slot (`CS_OUT_*_0`) fälschlich
  offen; ein Bau darauf erzeugt ein überlappendes Duplikat.
  Ursache: `group-merge` kodiert genau eine Junction (eine Kante). Ein Snap, der zwei Posten
  gleichzeitig berührt (Schleifenschluss), erzeugt bei uns nur die eine Kante am Anker; die
  zweite Berührung wird nicht als Event/Kante erfasst. `blockedPmis` ist kanten-getrieben und
  sieht die Junction ohne Kante nicht.
  Kein Kollisionscode, kein geometrisches Nachmessen (das wäre nur Symptom eines
  unterspezifizierten Events).
  Der Snap liegt in FireVisual (nicht unser Code); wir bauen nur daneben. Vor Eigenbau erst
  mit dem Team klären, ob FireVisual die zweite Junction bereits liefert / es dort eine Lösung
  gibt.


The file /Users/marcus.hinz/claude-projects/robotunits/OFFENE-PUNKTE.md has been updated successfully. (file state is current in your context — no need to Read it back)

# Offene Punkte — bei jedem Start prüfen und abhaken

- **Verbau: fehlende zweite Kante beim Loop-Schluss (mit Team klären).**
  Symptom: Im kleinen, geschlossenen Quadrat bleibt ein Gerade-Slot (`CS_OUT_*_0`) fälschlich
  offen; ein Bau darauf erzeugt ein überlappendes Duplikat.
  Ursache: `group-merge` kodiert genau eine Junction (eine Kante). Ein Snap, der zwei Posten
  gleichzeitig berührt (Schleifenschluss), erzeugt bei uns nur die eine Kante am Anker; die
  zweite Berührung wird nicht als Event/Kante erfasst. `blockedPmis` ist kanten-getrieben und
  sieht die Junction ohne Kante nicht.
  Kein Kollisionscode, kein geometrisches Nachmessen (das wäre nur Symptom eines
  unterspezifizierten Events).
  Der Snap liegt in FireVisual (nicht unser Code); wir bauen nur daneben. Vor Eigenbau erst
  mit dem Team klären, ob FireVisual die zweite Junction bereits liefert / es dort eine Lösung
  gibt.

+- **Verbau: fehlende zweite Kante beim Loop-Schluss (mit Team klären).**

+  Symptom: Im kleinen, geschlossenen Quadrat bleibt ein Gerade-Slot (`CS_OUT_*_0`) fälschlich

+  offen; ein Bau darauf erzeugt ein überlappendes Duplikat.

+  Ursache: `group-merge` kodiert genau eine Junction (eine Kante). Ein Snap, der zwei Posten

+  gleichzeitig berührt (Schleifenschluss), erzeugt bei uns nur die eine Kante am Anker; die

+  zweite Berührung wird nicht als Event/Kante erfasst. `blockedPmis` ist kanten-getrieben und

+  Kein Kollisionscode, kein geometrisches Nachmessen (das wäre nur Symptom eines

+  Der Snap liegt in FireVisual (nicht unser Code); wir bauen nur daneben. Vor Eigenbau erst

+  mit dem Team klären, ob FireVisual die zweite Junction bereits liefert / es dort eine Lösung

# Offene Punkte — bei jedem Start prüfen und abhaken

Keine offenen Punkte.


Eingetragen in `OFFENE-PUNKTE.md` — erscheint morgen beim Start. Nichts am Code geändert.


war ne gute session


Danke. Bis morgen.


Alle Tests hängen an einem Produkt mit `CS_LINKS` (lokal 0) und `CS_RECHTS` (lokal [2.168,0,0]). Die aktuelle Fixture hat diese Punkte nicht mehr. Ich prüfe die genauen Strukturanforderungen des Codes (PMI-Lesen, Rollen, Collider) und die Git-Historie der Fixture, um zu entscheiden, wie ich fixe.


641	    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
642	        const handle = this.handles.get(rootHandleId);
643	        for (const comp of handle?.components ?? []) {
644	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
645	                return comp.properties.position.value;
646	            }
647	        }
648	        return [0, 0, 0];
649	    }
650
651	    // The docking point's frame orientation, from its `properties.rotation` (Euler radians, ZYX).
652	    // Most points carry no rotation (CS_LINKS/CS_RECHTS = [0,0,0] → identity); the post/branch points
653	    // (CS_EIN_*) carry the angle that turns a part onto them.
654	    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
655	        const handle = this.handles.get(rootHandleId);
656	        for (const comp of handle?.components ?? []) {
657	            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
658	                const rot = comp.properties?.rotation?.value;
659	                return rot ? eulerZyxRadToQuat(rot as Vec3) : IDENTITY_QUAT;
660	            }
661	        }
662	        return IDENTITY_QUAT;
663	    }
664
665	    // Relative rotation of the join part w.r.t. the anchor part for a dock: aligning the join point's
666	    // frame onto the anchor point's turns the join part by R_anchorPmi ∘ R_joinPmi⁻¹. Identity when
667	    // both points carry no rotation (the plain left/right snap). This is what fills `Edge.rot`.
668	    dockRotation(anchorRoot: string, anchorPmi: string, joinRoot: string, joinPmi: string): Quat {
669	        return quatMul(this.pmiRotation(anchorRoot, anchorPmi), quatConj(this.pmiRotation(joinRoot, joinPmi)));
670	    }
671
672	    private rootTransform(handle: Handle): any {
673	        return handle?.components?.find((c: any) => c._resourceClass === "component_transform") ?? null;
674	    }
675

    private pmiLocal(rootHandleId: string, pmiId: string): Vec3 {
        const handle = this.handles.get(rootHandleId);
        for (const comp of handle?.components ?? []) {
            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
                return comp.properties.position.value;
            }
        }
        return [0, 0, 0];
    }

    // The docking point's frame orientation, from its `properties.rotation` (Euler radians, ZYX).
    // Most points carry no rotation (CS_LINKS/CS_RECHTS = [0,0,0] → identity); the post/branch points
    // (CS_EIN_*) carry the angle that turns a part onto them.
    private pmiRotation(rootHandleId: string, pmiId: string): Quat {
        const handle = this.handles.get(rootHandleId);
        for (const comp of handle?.components ?? []) {
            if (comp._resourceClass === "component_pmi" && comp.id === pmiId) {
                const rot = comp.properties?.rotation?.value;
                return rot ? eulerZyxRadToQuat(rot as Vec3) : IDENTITY_QUAT;
            }
        }
        return IDENTITY_QUAT;
    }

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

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


Bash

cd /home/marcuh/projects/adesso-cpq
f=packages/frontend/.storybook/products/schutzzaun_basic/SBS1044PRA.json
echo "=== Historie der Fixture ==="
git log --oneline -- "$f" | head
echo
echo "=== Hatte eine frühere Version CS_LINKS/CS_RECHTS? (letzte 6 Commits der Datei) ==="
for c in $(git log --format=%h -6 -- "$f"); do
  n=$(git show "$c:$f" 2>/dev/null | grep -c 'CS_LINKS\|CS_RECHTS')
  echo "$c : CS_LINKS/RECHTS-Treffer = $n"
done
echo
echo "=== Gibt es IRGENDEIN Produkt-JSON mit CS_LINKS und CS_RECHTS? ==="
grep -rl 'CS_LINKS' packages/frontend/.storybook/products 2>/dev/null | head

Fixture-Historie und CS_LINKS-Vorkommen