1	.value {
2	    color: var(--cpq-field-color, var(--cpq-color-text, #111));
3	    font: var(--cpq-field-font, var(--cpq-font-body, 14px/1.4 system-ui, sans-serif));
4	}
5
6	.value-multiline {
7	    white-space: pre-wrap;
8	}
9
10	.edit-wrapper {
11	    display: grid;
12	    gap: var(--cpq-space-2);
13	}
14
15	.edit-label {
16	    color: var(--cpq-field-label-color, var(--cpq-color-label, #444));
17	    font: var(--cpq-field-label-font, var(--cpq-font-label, 12px/1.2 system-ui, sans-serif));
18	}
19	.edit-label.required .required-mark {
20	    margin-left: var(--cpq-space-1);
21	    color: var(--cpq-field-error-color, var(--cpq-color-danger, #b00020));
22	    font: var(--cpq-field-label-font, var(--cpq-font-label, 12px/1.2 system-ui, sans-serif));
23	}
24
25	.value-input {
26	    width: 100%;
27	    box-sizing: border-box;
28	    color: var(--cpq-field-color, var(--cpq-color-text, #111));
29	    background: var(--cpq-field-bg, var(--cpq-color-surface, #fff));
30	    border: var(--cpq-field-border-width, var(--cpq-border-width, 1px)) solid
31	        var(--cpq-field-border-color, var(--cpq-color-border, #cfcfcf));
32	    border-radius: var(--cpq-field-radius, var(--cpq-radius-base, 8px));
33	    padding: var(--cpq-space-2) var(--cpq-space-3);
34	    font: var(--cpq-field-font, var(--cpq-font-body, 14px/1.4 system-ui, sans-serif));
35	}
36
37	.value-input:hover:not(:focus):not(:disabled) {
38	    border-color: var(--cpq-field-hover-border-color, var(--cpq-color-label, #444));
39	}
40
41	.value-input:focus {
42	    outline: 2px solid var(--cpq-field-focus-color, var(--cpq-color-focus, #2563eb));
43	    outline-offset: 1px;
44	}
45
46	.value-input.invalid {
47	    border-color: var(--cpq-field-error-color, var(--cpq-color-danger, #b00020));
48	    outline: 2px solid var(--cpq-field-error-color, var(--cpq-color-danger, #b00020));
49	    outline-offset: 1px;
50	}
51
52	.value-input-multiline {
53	    resize: vertical;
54	    min-height: calc(var(--cpq-control-height, 36px) * 2);
55	}
56
57	.error-text {
58	    margin-top: var(--cpq-space-1);
59	    color: var(--cpq-field-error-color, var(--cpq-color-danger, #b00020));
60	    font: var(--cpq-field-error-font, var(--cpq-font-error, 12px/1.2 system-ui, sans-serif));
61	}
62	.spacer {
63	    height: var(--cpq-space-2);
64	}
65

.value {
    color: var(--cpq-field-color, var(--cpq-color-text, #111));
    font: var(--cpq-field-font, var(--cpq-font-body, 14px/1.4 system-ui, sans-serif));
}

.value-multiline {
    white-space: pre-wrap;
}

.edit-wrapper {
    display: grid;
    gap: var(--cpq-space-2);
}

.edit-label {
    color: var(--cpq-field-label-color, var(--cpq-color-label, #444));
    font: var(--cpq-field-label-font, var(--cpq-font-label, 12px/1.2 system-ui, sans-serif));
}
.edit-label.required .required-mark {
    margin-left: var(--cpq-space-1);
    color: var(--cpq-field-error-color, var(--cpq-color-danger, #b00020));
    font: var(--cpq-field-label-font, var(--cpq-font-label, 12px/1.2 system-ui, sans-serif));
}

.value-input {
    width: 100%;
    box-sizing: border-box;
    color: var(--cpq-field-color, var(--cpq-color-text, #111));
    background: var(--cpq-field-bg, var(--cpq-color-surface, #fff));
    border: var(--cpq-field-border-width, var(--cpq-border-width, 1px)) solid
        var(--cpq-field-border-color, var(--cpq-color-border, #cfcfcf));
    border-radius: var(--cpq-field-radius, var(--cpq-radius-base, 8px));
    padding: var(--cpq-space-2) var(--cpq-space-3);
    font: var(--cpq-field-font, var(--cpq-font-body, 14px/1.4 system-ui, sans-serif));
}

.value-input:hover:not(:focus):not(:disabled) {
    border-color: var(--cpq-field-hover-border-color, var(--cpq-color-label, #444));
}

.value-input:focus {
    outline: 2px solid var(--cpq-field-focus-color, var(--cpq-color-focus, #2563eb));
    outline-offset: 1px;
}

.value-input.invalid {
    border-color: var(--cpq-field-error-color, var(--cpq-color-danger, #b00020));
    outline: 2px solid var(--cpq-field-error-color, var(--cpq-color-danger, #b00020));
    outline-offset: 1px;
}

.value-input-multiline {
    resize: vertical;
    min-height: calc(var(--cpq-control-height, 36px) * 2);
}

.error-text {
    margin-top: var(--cpq-space-1);
    color: var(--cpq-field-error-color, var(--cpq-color-danger, #b00020));
    font: var(--cpq-field-error-font, var(--cpq-font-error, 12px/1.2 system-ui, sans-serif));
}
.spacer {
    height: var(--cpq-space-2);
}


Ehrliche Zwischenlage: In den Layout-Quellen finde ich **keine** kürzliche Änderung, die einen Scroll-Container einführt — Fire3D- und Configurator-CSS, der Story-Decorator (rendert die Story ohne Wrapper-Padding) und die Story-Wrapper sind alt. Die Wrapper sind `height:100vh`, und base.css hat keinen `body{margin:0}`-Reset (Storybook setzt den im Fullscreen-Layout aber selbst).

Damit bleibt als Ursache ein **Kind, das die 100vh/100vw überläuft**, nicht ein neuer Container. Der typische Kandidat hier: das fire-`<canvas>`. Ein `<canvas>` ist per Default `display:inline` → es hat eine Zeilen-Descender-Lücke von ein paar Pixeln, oder fire setzt es auf eine feste Pixelgröße, die minimal über den Container hinausgeht → vertikaler Scrollbalken. Genau dieser Scroll-Bereich frisst dann das Mausrad.

Welches Element es konkret ist, sehe ich nur am lebenden DOM, nicht am Code. Schnelltest in den DevTools:
1. Element-Inspector über den 3D-Bereich, das Element suchen, das höher/breiter als der Viewport ist (Storybook markiert Overflow oft am `#storybook-root` oder am `<canvas>`).
2. Prüfen, ob das `<canvas>` `display: block` hat. Wenn nicht → das ist die Inline-Lücke, Fix ist `canvas { display:block }`.

Sag mir, was der Inspector als überlaufendes Element zeigt (bzw. ob das Canvas `display:block` ist) — dann weiß ich, ob es das Canvas, ein Margin oder fires eigene Größensetzung ist. Ins `@ad/fire`/`visual-runtime`-Mounting schaue ich auf dein Wort.


dann erst mal gerade nicht
kannst du das material wieder auf draht umstellen?
nur für den Schutzzaun basic / oder für alles, je nach edem, was einfacher ist


Das darf ich ändern. Ich suche, wo Material/Draht (Wireframe) gesetzt wird — in der fire-Config, den Produkt-Graphen oder einer kürzlichen Umstellung.


Bash

echo "=== grep wireframe/draht/material render settings ===" && grep -rniE 'wireframe|draht|renderMode|material_(basic|standard|physical|wire)|"material"' packages/frontend/.storybook/fire/ packages/frontend/src/ 2>/dev/null | grep -vE 'node_modules' | head -30; echo "=== fire config files ==="; ls packages/frontend/.storybook/fire/

Search for wireframe/material settings


=== grep wireframe/draht/material render settings ===
packages/frontend/.storybook/fire/environment.config.json:63:                        "material": {
packages/frontend/.storybook/fire/environment.config.json:206:                        "material": {
packages/frontend/.storybook/fire/environment.config.json:207:                            "_resourceClass": "material_basic",
packages/frontend/.storybook/fire/environment.config.json:234:                        "material": {
packages/frontend/.storybook/fire/environment.config.json:235:                            "_resourceClass": "material_basic",
packages/frontend/.storybook/fire/environment.config.json:261:                        "material": {
packages/frontend/.storybook/fire/environment.config.json:262:                            "_resourceClass": "material_basic",
packages/frontend/.storybook/fire/environment.config.json:431:                            "typeKey": "material"
packages/frontend/.storybook/fire/environment.config.json:1083:                        "typeKey": "material"
packages/frontend/src/configurator/modules/GekoPlacementModule.ts:150:                            material: { _resourceClass: "material_basic", diffuse: [0, 1, 0], depthTest: true },
=== fire config files ===
environment.config.json
images
shader


echo "=== schutzzaun_basic product files ===" && ls packages/frontend/.storybook/products/schutzzaun_basic/ | head; echo "=== material/wireframe/renderer in schutzzaun_basic JSONs ==="; grep -rniE 'wireframe|"material"|material_|renderMode|geometry_' packages/frontend/.storybook/products/schutzzaun_basic/ 2>/dev/null | sed -E 's/:.*(wireframe|material_[a-z]+|renderMode|geometry_[a-z]+).*/ -> \1/' | sort | uniq -c | head -20

Inspect schutzzaun_basic materials


=== schutzzaun_basic product files ===
SBD2083PLA.json
SBD2083PLA.png
SBD2083PRA.json
SBD2083PRA.png
SBD2083SLA.json
SBD2083SLA.png
SBD2083SRA.json
SBD2083SRA.png
SBE0131SNA.json
SBE0131SNA.png
=== material/wireframe/renderer in schutzzaun_basic JSONs ===
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBD2083PLA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBD2083PRA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBD2083SLA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBD2083SRA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0174PNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0174SNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0217PNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0217SNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0260PNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0260SNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0303PNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0303SNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0346PNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0346SNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0389PNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0389SNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0432PNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0432SNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0475PNA.json -> material_reference
   1 packages/frontend/.storybook/products/schutzzaun_basic/SBE0475SNA.json -> material_reference


Jede Schutzzaun-Datei nutzt ein `material_reference` (geteiltes Material per Key). Ich sehe mir an, worauf es zeigt und wo dieses Material definiert ist.


cd packages/frontend/.storybook/products/schutzzaun_basic && echo "=== material_reference block in one file ===" && grep -n -A4 'material_reference' SBD2083PLA.json | head -12; echo "=== where are materials defined? search products + fire for material_standard/physical/lambert + wireframe ===" && grep -rlniE 'material_(standard|physical|lambert|phong|wire)|"wireframe"' packages/frontend/.storybook/ 2>/dev/null | head

Find referenced material definition


dann erst mal gerade nicht kannst du das material wieder auf draht umstellen? nur für den Schutzzaun basic / oder für alles, je nach edem, was einfacher ist


JSON ist minifiziert. Ich extrahiere die Renderer/Material-Struktur mit jq.


cd packages/frontend/.storybook/products/schutzzaun_basic && echo "=== component_renderer.material of one part ===" && jq -c '[.handles[].components[]? | select(._resourceClass=="component_renderer") | .material] | unique' SBD2083PLA.json 2>&1 | head; echo "=== all distinct material blocks across schutzzaun_basic ==="; jq -c '.handles[].components[]? | select(._resourceClass=="component_renderer") | .material' *.json 2>/dev/null | sort | uniq -c | head

Extract material blocks via jq


(eval):cd:1: no such file or directory: packages/frontend/.storybook/products/schutzzaun_basic
=== all distinct material blocks across schutzzaun_basic ===


[Request interrupted by user]


Das müssen anmdere ghlbs aus dem Katlog sein


Verstanden — das Material steckt in den referenzierten glbs, nicht im Material-Block. Ich schaue, welche glbs die Teile referenzieren und ob es eine Draht-Variante gibt.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook && echo "=== geometry/asset refs of one schutzzaun part ===" && jq -c '[.handles[].components[]? | select(._resourceClass=="component_renderer") | .geometry] ' products/schutzzaun_basic/SBD2083PLA.json 2>&1 | head -c 800; echo; echo "=== glb assets present ==="; ls assets/glb/ 2>/dev/null | head; echo "count:"; ls assets/glb/ 2>/dev/null | wc -l

Inspect geometry refs and glb assets


=== geometry/asset refs of one schutzzaun part ===
[]

=== glb assets present ===
count:
       0


echo "=== component types in one part ===" && jq -c '[.handles[].components[]?._resourceClass] | unique' products/schutzzaun_basic/SBD2083PLA.json; echo "=== full renderer component (geometry+material) ==="; jq -c '.handles[].components[]? | select(._resourceClass=="component_renderer")' products/schutzzaun_basic/SBD2083PLA.json | head -c 1200; echo; echo "=== any asset/glb/uri/reference-key strings? ==="; grep -oiE '"[^"]*\.glb"|"(assetKey|reference|resourceKey|uri|src|url)"[^,]*' products/schutzzaun_basic/SBD2083PLA.json | head

Dump renderer and asset references


=== component types in one part ===
["component_collider","component_pmi","component_transform"]
=== full renderer component (geometry+material) ===

=== any asset/glb/uri/reference-key strings? ===
"katalog/schutzzaun_basic/SBD2083PLA/clone_27579617.glb"
"katalog/schutzzaun_basic/SBD2083PLA/clone_27579617.glb"
"katalog/schutzzaun_basic/SBD2083PLA/clone_23480530.glb"
"katalog/schutzzaun_basic/SBD2083PLA/clone_23480530.glb"
"katalog/schutzzaun_basic/SBD2083PLA/clone_23480530.glb"
"katalog/schutzzaun_basic/SBD2083PLA/clone_23480530.glb"
"katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb"
"katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb"
"katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb"
"katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb"


Die Teile laden echte glbs aus `katalog/schutzzaun_basic/<TEIL>/*.glb`. „Draht" ist also entweder ein Wireframe-Render-Modus/Material-Override oder eine andere glb-Variante. Ich prüfe die Git-Historie und ob es einen Wireframe-Schalter gibt.


cd /Users/marcus.hinz/projects/adesso-cpq && echo "=== git log mentioning draht/wireframe/glb/material/mesh ===" && git log --oneline -20 | grep -iE 'draht|wirefr|glb|material|mesh|render' ; echo "=== recent commits (context) ==="; git log --oneline -12; echo "=== wireframe option in visual-runtime/fire src? ==="; grep -rniE 'wireframe|drahtmodell|"draht"' packages/visual-runtime/src packages/frontend/src 2>/dev/null | grep -vE 'node_modules' | head

Git history and wireframe option search


=== git log mentioning draht/wireframe/glb/material/mesh ===
=== recent commits (context) ===
5564353 Storybook: Rollenfoerderer-Produkte und Docking-Regeln, Fire3D/Service-Anpassungen
f6b0546 Verbau: Dreipunkt-/Anbau-Logik als GekoPlacementModule auslagern
cf44b2c Verbau: Select-Marker + geteilte Steher-Belegung (blockedPmis)
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
744326c Docking: Doppelbelegung von Andockpunkten verhindern
be58fd3 Klassen-basierte Platzierung: Docking-Regeln pro Bauteilklasse
61e0548 PMI-Handling für fire: Nicht-Root-PMIs entfernt, Rotationen auf Radiant
129ffb9 Placement: Start-Punkte aus DragPoints entfernt, dragPointRadius korrigiert
debdb4e Neue Ordnerstruktur: Assets/Szenegraphen umstrukturiert
ea77fdc Neue Einbaupunkte: GroupProcessor/DefaultCommandProcessor + ProductConfigurator/ProductBrowser/Fire3D
=== wireframe option in visual-runtime/fire src? ===


Kein Wireframe-Schalter im Code/Config, nichts in den Commit-Messages. Ich prüfe die Katalog-glbs selbst — ob dort neben den Solid-glbs eine Draht-Variante liegt.


echo "=== find katalog dir(s) ===" && find . -type d -name katalog -not -path '*/node_modules/*' 2>/dev/null; echo "=== list one part's glb folder ==="; find . -type d -path '*katalog/schutzzaun_basic/SBD2083PLA' -not -path '*/node_modules/*' 2>/dev/null -exec ls {} \; ; echo "=== any draht/wire/line variant glbs anywhere ==="; find . -iname '*draht*' -o -iname '*wire*' -o -iname '*line*' 2>/dev/null | grep -viE 'node_modules|deadline|inline|outline|pipeline|guideline' | head

Locate katalog glbs and any wireframe variants


=== find katalog dir(s) ===
./ressources/katalog
=== list one part's glb folder ===
BAP2051.json
CAN4501.json
CAP4040.json
DOR4501.json
DOR4511.json
DOR4553.json
DOR4555.json
DOR4561.json
SBD2083PLA.json
TIN4511.json
clone_05844884.json
clone_10220265.json
clone_17520558.json
clone_23480530.json
clone_27579617.json
clone_36821863.json
clone_52672722.json
clone_77648683.json
clone_82147188.json
=== any draht/wire/line variant glbs anywhere ===


Ich komme hier nicht sauber zum Ziel und will nicht ins Blaue weitersuchen. Stand:

- Die Schutzzaun-Teile referenzieren **Solid-glbs** über `katalog/schutzzaun_basic/<TEIL>/*.glb`.
- Der Katalog (`ressources/katalog/...`) enthält `.json`-Deskriptoren, nicht die glbs; `assets/glb/` ist leer (die glbs wurden im letzten Commit gelöscht).
- Kein Wireframe-/Draht-Schalter im Code (`visual-runtime`/`frontend`) oder in der fire-Config, nichts dazu in den Commit-Messages.

Ich finde also nicht, wodurch der Draht-Look vorher entstand. Damit ich das gezielt umstelle, brauche ich von dir das eine:

War „Draht" ein **Render-/Material-Modus** (Wireframe-Material bzw. Override, das ich wieder aktiviere), oder eine **andere glb-/Asset-Menge** (Draht-Variante mit eigenem Pfad/Namensschema, auf die die Teile zeigen sollen)? Im zweiten Fall: wie heißt/liegt diese Variante?


You're investigating a complex catalog structure across many files and directories. Fanning out subagents — one per part or directory — would let you explore the glb variants and material definitions

Ask Claude to "fan out subagents, one per item"


Das einfache Schutzuzauń eklement heißt derrzeit?


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook && echo "=== class- tags of SBS1044PLA (das in den Stories geladene Element) ===" && grep -oE '"class-[a-zA-Z0-9_]+"' products/schutzzaun_basic/SBS1044PLA.json | sort -u; echo "=== root glb it references ==="; grep -oE '"katalog/[^"]*\.glb"' products/schutzzaun_basic/SBS1044PLA.json | sort -u | head

Class tag and glb of the simple element


=== class- tags of SBS1044PLA (das in den Stories geladene Element) ===
=== root glb it references ===
"katalog/schutzzaun_basic/SBS1044PLA/clone_13941978.glb"
"katalog/schutzzaun_basic/SBS1044PLA/clone_16181215.glb"
"katalog/schutzzaun_basic/SBS1044PLA/clone_27133175.glb"
"katalog/schutzzaun_basic/SBS1044PLA/clone_38239278.glb"
"katalog/schutzzaun_basic/SBS1044PLA/clone_59182232.glb"
"katalog/schutzzaun_basic/SBS1044PLA/clone_68051095.glb"
"katalog/schutzzaun_basic/SBS1044PLA/clone_83160222.glb"
"katalog/schutzzaun_basic/SBS1044PRA/CAN4501.glb"
"katalog/schutzzaun_basic/SBS1044SRA/BAP2051.glb"
"katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb"


Das einfache Schutzzaun-Element, das die Stories laden, heißt **`SBS1044PLA`** (Datei `products/schutzzaun_basic/SBS1044PLA.json`), mit Geometrie aus `katalog/schutzzaun_basic/SBS1044PLA/`.


gibt es eins, das mit SLA endet?


ls products/schutzzaun_basic/ | grep -iE 'SLA\.(json|png)$'

Find SLA-ending schutzzaun_basic products


SBD2083SLA.json
SBD2083SLA.png
SBF0872SLA.json
SBF0872SLA.png
SBS1044SLA.json
SBS1044SLA.png


Ja, drei: `SBD2083SLA`, `SBF0872SLA` und `SBS1044SLA`. Das Gegenstück zum einfachen Element (`SBS1044PLA`) ist **`SBS1044SLA`**.


versuch mal das


Ich stelle die Fire3d-Stories testweise auf `SBS1044SLA` um (beide Stellen: Default und MultipleZaun), damit du siehst, ob das der Draht-Look ist.


cd /Users/marcus.hinz/projects/adesso-cpq && grep -n 'SBS1044PLA' packages/frontend/src/components/visual/Fire3d.stories.ts

Locate SBS1044PLA references in story


26:                const scene = await fetch("/products/schutzzaun_basic/SBS1044PLA.json").then((r) => r.json());
47:                zaunScene = await fetch("/products/schutzzaun_basic/SBS1044PLA.json").then((r) => r.json());


sed -i '' 's#schutzzaun_basic/SBS1044PLA.json#schutzzaun_basic/SBS1044SLA.json#g' packages/frontend/src/components/visual/Fire3d.stories.ts && grep -n 'SBS1044' packages/frontend/src/components/visual/Fire3d.stories.ts

Swap story to SBS1044SLA


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


[Request interrupted by user for tool use]


LASS DIE IN RUHE
es geht um die Configurator Strie und da um die Auswahl im Produkkatalog catalo.json


Verstanden — Fire3d-Stories bleiben unangetastet. Es geht um `catalog.json` und den Eintrag des einfachen Elements. Ich schaue, worauf der zeigt.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products && echo "=== SBS1044 / schutzzaun_basic entries in catalog.json ===" && grep -nE 'SBS1044|scenegraph' catalog.json | grep -iE 'SBS1044|schutzzaun_basic' | head

Find simple element entry in catalog.json


=== SBS1044 / schutzzaun_basic entries in catalog.json ===
28:                    "scenegraph": "schutzzaun_basic/SBE1507PNA.json",
46:                    "scenegraph": "schutzzaun_basic/SBE0217PNA.json",
64:                    "scenegraph": "schutzzaun_basic/SBE0260PNA.json",
82:                    "scenegraph": "schutzzaun_basic/SBE0303PNA.json",
100:                    "scenegraph": "schutzzaun_basic/SBE0346PNA.json",
118:                    "scenegraph": "schutzzaun_basic/SBE0389PNA.json",
136:                    "scenegraph": "schutzzaun_basic/SBE0432PNA.json",
154:                    "scenegraph": "schutzzaun_basic/SBE0475PNA.json",
172:                    "scenegraph": "schutzzaun_basic/SBE0518PNA.json",
190:                    "scenegraph": "schutzzaun_basic/SBE0561PNA.json",


`catalog.json` referenziert `SBE####PNA`-Elemente, kein `SBS1044`. Ich liste die Katalog-Einträge mit Namen, um das einfache Element und seinen aktuellen Dateibezug zu finden.


jq -r '.. | objects | select(.scenegraph) | "\(.name // .label // .title // "?")  ->  \(.scenegraph)"' catalog.json 2>/dev/null | head -40; echo "=== total entries ==="; jq -r '[.. | objects | select(.scenegraph)] | length' catalog.json

List catalog entries with names


?  ->  schutzzaun_basic/SBE1507PNA.json
?  ->  schutzzaun_basic/SBE0217PNA.json
?  ->  schutzzaun_basic/SBE0260PNA.json
?  ->  schutzzaun_basic/SBE0303PNA.json
?  ->  schutzzaun_basic/SBE0346PNA.json
?  ->  schutzzaun_basic/SBE0389PNA.json
?  ->  schutzzaun_basic/SBE0432PNA.json
?  ->  schutzzaun_basic/SBE0475PNA.json
?  ->  schutzzaun_basic/SBE0518PNA.json
?  ->  schutzzaun_basic/SBE0561PNA.json
?  ->  schutzzaun_basic/SBE0604PNA.json
?  ->  schutzzaun_basic/SBE0647PNA.json
?  ->  schutzzaun_basic/SBE0690PNA.json
?  ->  schutzzaun_basic/SBE0733PNA.json
?  ->  schutzzaun_basic/SBE0776PNA.json
?  ->  schutzzaun_basic/SBE0819PNA.json
?  ->  schutzzaun_basic/SBE0862PNA.json
?  ->  schutzzaun_basic/SBE0905PNA.json
?  ->  schutzzaun_basic/SBE0948PNA.json
?  ->  schutzzaun_basic/SBE0991PNA.json
?  ->  schutzzaun_basic/SBE1034PNA.json
?  ->  schutzzaun_basic/SBE1077PNA.json
?  ->  schutzzaun_basic/SBE1120PNA.json
?  ->  schutzzaun_basic/SBE1163PNA.json
?  ->  schutzzaun_basic/SBE1206PNA.json
?  ->  schutzzaun_basic/SBE1249PNA.json
?  ->  schutzzaun_basic/SBE1292PNA.json
?  ->  schutzzaun_basic/SBE1335PNA.json
?  ->  schutzzaun_basic/SBE1378PNA.json
?  ->  schutzzaun_basic/SBE1421PNA.json
?  ->  schutzzaun_basic/SBE1464PNA.json
?  ->  schutzzaun_basic/SBE1507PNA.json
?  ->  schutzzaun_basic/SBE0131SNA.json
?  ->  schutzzaun_basic/SBE0174SNA.json
?  ->  schutzzaun_basic/SBE0217SNA.json
?  ->  schutzzaun_basic/SBE0260SNA.json
?  ->  schutzzaun_basic/SBE0303SNA.json
?  ->  schutzzaun_basic/SBE0346SNA.json
?  ->  schutzzaun_basic/SBE0389SNA.json
?  ->  schutzzaun_basic/SBE0432SNA.json
=== total entries ===
183


LASS DIE IN RUHE es geht um die Configurator Strie und da um die Auswahl im Produkkatalog catalo.json


Der Katalog hat `SBE####PNA`- und `SBE####SNA`-Einträge — kein `SBS1044`. Die Namen kamen als „?". Ich sehe mir die echte Struktur eines Eintrags an, um das „einfache Element" zu identifizieren.


echo "=== one leaf entry (full) ===" && jq -c 'first(.. | objects | select(.scenegraph))' catalog.json; echo "=== top-level structure / group names ==="; jq -r 'paths(objects) as $p | getpath($p) | (.name // .label // .title // empty)' catalog.json 2>/dev/null | grep -iE 'zaun|basic|element|einfach|feld' | head

Inspect catalog entry keys and group names


=== one leaf entry (full) ===
{"scenegraph":"schutzzaun_basic/SBE1507PNA.json","bild":"schutzzaun_basic/SBE0174PNA.png"}
=== top-level structure / group names ===
Element
Element


`catalog.json` enthält **kein** `SBS1044` — die schutzzaun_basic-Einträge sind `SBE####PNA` und `SBE####SNA` (je nur `scenegraph` + `bild`, keine Namen). Das „einfache Element = SBS1044" aus vorhin steckt nur in den Fire3d-Stories, nicht im Katalog.

Damit ich das Richtige treffe, eine Sache:

- Meinst du einen der vorhandenen `SBE####`-Katalogeinträge (dann: welchen / welche Breite), den ich auf seine Draht-Variante umbiege?
- Oder soll ich `SBS1044SLA` als neuen auswählbaren Eintrag in den Katalog aufnehmen?


Ich verstehe nicht, wie ich den zu breiten hohen Frame finden soll?


So findest du ihn in der Konsole (im `storybook-preview-iframe`-Kontext). Zwei Snippets:

**1. Wer scrollt überhaupt** (der Container mit dem Scrollbalken):
```js
for (const el of document.querySelectorAll('*')) {
  if (el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1)
    console.log('SCROLLT', `${el.scrollWidth}×${el.scrollHeight} vs ${el.clientWidth}×${el.clientHeight}`, el);
}
```

**2. Wer über den Viewport hinausragt** (das zu große Element):
```js
const W = innerWidth, H = innerHeight;
for (const el of document.querySelectorAll('*')) {
  const r = el.getBoundingClientRect();
  if (r.right > W + 1 || r.bottom > H + 1)
    console.log('RAGT RAUS', `${Math.round(r.right)}×${Math.round(r.bottom)} > ${W}×${H}`, el);
}
```

Auf das geloggte Element klicken → DevTools springt in den Elements-Tab, dort siehst du direkt, welches es ist.

**Schnell-Sichtprüfung** (alles umranden, das Herausragende sticht hervor):
```js
document.querySelectorAll('*').forEach(e => e.style.outline = '1px solid red');
```
Rückgängig: Seite neu laden.

Poste, was Snippet 1/2 ausgibt (die `console.log`-Zeile mit dem Element), dann sehe ich, ob es das Canvas, ein Margin oder fires Größensetzung ist.


UNSERE VisualKomponente hat in der Story Default und Configurator Scrolbalken recht und links, das sie gesamt KOMPONENTE scrollt


„Unsere Komponente scrollt komplett" heißt: der Canvas/Container ist größer als der 100vh-Wrapper, in beide Richtungen. Beide Stories rendern `Fire3D.vue` → gemeinsame Ursache. Ich sehe mir an, wie `visual-runtime` den Canvas anlegt/dimensioniert und was unsere Container-CSS macht.


cd /Users/marcus.hinz/projects/adesso-cpq && echo "=== visual-runtime: canvas creation / sizing / style ===" && grep -rniE 'canvas|appendChild|\.style|clientWidth|offsetWidth|resize|setSize|devicePixelRatio|width\s*=|height\s*=' packages/visual-runtime/src 2>/dev/null | grep -viE 'node_modules' | head -30; echo "=== visual-runtime files ==="; ls packages/visual-runtime/src 2>/dev/null

Inspect visual-runtime canvas sizing


Error: Exit code 1
=== visual-runtime: canvas creation / sizing / style ===
=== visual-runtime files ===


Das ist eine Containment-Sache in unserer Komponente: der Canvas (bzw. sein Host) überläuft `.fire3d__viewport` in beide Richtungen, und weil nichts das clippt, scrollt die ganze Komponente. Beide Stories rendern `Fire3D.vue`, daher überall.

Ich klemme das im Viewport ab und zwinge den Canvas auf `display:block` + auf die Container-Größe:


238	    handleSaveProduct,
239	});
240	</script>
241
242	<style scoped>
243	.fire3d {
244	    display: grid;
245	    grid-template-rows: 1fr auto;
246	    height: 100%;
247	    min-height: 0;
248	}
249
250	.fire3d__viewport {
251	    position: relative;
252	    width: 100%;
253	    height: 100%;
254	    min-height: 0;
255	}
256
257	.fire3d__controls {
258	    position: absolute;
259	    top: 0.75rem;
260	    left: 50%;
261	    transform: translateX(-50%);
262	    z-index: 10;
263	    opacity: 0;
264	    transition: opacity 0.2s;
265	    pointer-events: none;

    handleSaveProduct,
});
</script>

<style scoped>
.fire3d {
    display: grid;
    grid-template-rows: 1fr auto;
    height: 100%;
    min-height: 0;
}

.fire3d__viewport {
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 0;
}

.fire3d__controls {
    position: absolute;
    top: 0.75rem;
    left: 50%;
    transform: translateX(-50%);
    z-index: 10;
    opacity: 0;
    transition: opacity 0.2s;
    pointer-events: none;


Edit

.fire3d {
    display: grid;
    grid-template-rows: 1fr auto;
    height: 100%;
    min-height: 0;
}

.fire3d__viewport {
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 0;
}

.fire3d {
    display: grid;
    grid-template-rows: 1fr auto;
    height: 100%;
    min-height: 0;
    min-width: 0;
    overflow: hidden;
}

.fire3d__viewport {
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 0;
    min-width: 0;
    overflow: hidden;
}

/* The engine's canvas is appended into the viewport (not in the template). Force it to a block box
   sized to the container so it never adds an inline-descender gap or overruns width/height and pushes
   the whole component into a scroll. */
.fire3d__viewport :deep(canvas) {
    display: block;
    max-width: 100%;
    max-height: 100%;
}


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

.fire3d {
    display: grid;
    grid-template-rows: 1fr auto;
    height: 100%;
    min-height: 0;
}

.fire3d__viewport {
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 0;
}

.fire3d {
    display: grid;
    grid-template-rows: 1fr auto;
    height: 100%;
    min-height: 0;
    min-width: 0;
    overflow: hidden;
}

.fire3d__viewport {
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 0;
    min-width: 0;
    overflow: hidden;
}

/* The engine's canvas is appended into the viewport (not in the template). Force it to a block box
   sized to the container so it never adds an inline-descender gap or overruns width/height and pushes
   the whole component into a scroll. */
.fire3d__viewport :deep(canvas) {
    display: block;
    max-width: 100%;
    max-height: 100%;
}

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

grid-template-rows: 1fr auto;
height: 100%;
min-height: 0;
+    min-width: 0;
+    overflow: hidden;
}
.fire3d__viewport {

+/* The engine's canvas is appended into the viewport (not in the template). Force it to a block box

+   sized to the container so it never adds an inline-descender gap or overruns width/height and pushes

+   the whole component into a scroll. */


Angewandt. Nach dem HMR-Reload sollten die Scrollbalken in **Default** und **Configurator** weg sein und die Komponente exakt im 100vh-Wrapper sitzen.

Falls doch noch ein Balken bleibt, ist die Quelle nicht der Canvas, sondern ein Container darüber — dann sag mir, was Snippet 1 (`SCROLLT …`) für ein Element ausgibt, dann klemme ich es dort ab. Und der Mausrad-Zoom sollte wieder gehen, sobald kein Scroll-Bereich mehr über dem Canvas liegt.


hat funktioniert
kannst Du mal schaun, wer role-part auf den Handlen setzt?
sind wir das oder FireVisual?


Direkte Frage — ich suche, wo `role-part` gesetzt wird (unser Code vs. Eingabe-Graphen/FireVisual).


Bash

cd /Users/marcus.hinz/projects/adesso-cpq && echo "=== role- in our source ===" && grep -rnE 'role-|role-part|"role"|ROLE' packages/frontend/src/configurator 2>/dev/null | grep -iE 'role' | head -20; echo "=== role- in input product graphs (a sample) ==="; grep -oE '"role-[a-z]+"' packages/frontend/.storybook/products/schutzzaun_basic/SBS1044PLA.json 2>/dev/null | sort -u | head

Search role-part origin


weiter


cd /Users/marcus.hinz/projects/adesso-cpq && grep -rn 'role-' packages/frontend/src/configurator/model/GroupProcessor.ts | head -20

Find role- tag assignment in GroupProcessor


packages/frontend/src/configurator/model/GroupProcessor.ts:24:// Every materialised node carries an explicit role tag (`role-<x>`). Identifying the subject of an
packages/frontend/src/configurator/model/GroupProcessor.ts:30:const roleTag = (role: NodeRole) => `role-${role}`;
packages/frontend/src/configurator/model/GroupProcessor.ts:773:            if (typeof t === "string" && t.startsWith("role-")) return t.slice(5) as NodeRole;


echo "=== roleTag( usages ===" && grep -n 'roleTag(' packages/frontend/src/configurator/model/GroupProcessor.ts; echo "=== context around ROLE.part / roleTag assignment ==="; grep -nE 'roleTag\(|ROLE\.part|tags.*role|role.*tags' packages/frontend/src/configurator/model/GroupProcessor.ts | head

Find where role tags are attached


=== roleTag( usages ===
600:                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
704:            tags: [roleTag(ROLE.assembly)],
721:            tags: [roleTag(ROLE.scene)],
=== context around ROLE.part / roleTag assignment ===
463:        return this.nearestAncestorOfRole(handleId, ROLE.part);
487:            if (node.role === ROLE.part || node.role === ROLE.assembly) out.push(cur);
600:                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
704:            tags: [roleTag(ROLE.assembly)],
721:            tags: [roleTag(ROLE.scene)],


585	        if (this.groups.size > 0) handles.push(this.sceneRootHandle());
586
587	        for (const g of this.groups.values()) {
588	            const layout = this.computeLayout(g);
589	            for (const member of g.members) {
590	                const subtree = this.elementHandles.get(member) ?? [];
591	                const root = this.handles.get(member);
592	                const tr = this.rootTransform(root);
593	                const lay = layout.get(member) ?? { pos: [0, 0, 0] as Vec3, rot: IDENTITY_QUAT };
594	                // Parent the element root to the group node. The member carries its group-local rotation
595	                // (identity for a plain left/right chain; non-identity for a branch docked at an angle);
596	                // the group node carries the group's world orientation on top.
597	                tr.parentKey = g.key;
598	                tr.translation = lay.pos;
599	                tr.rotation = lay.rot;
600	                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
601	                handles.push(...subtree);
602	            }
603	            handles.push(this.groupNodeHandle(g));
604	        }
605	        this.buildNodeIndex(handles);
606	        return { ...baseScene, handles };

        if (this.groups.size > 0) handles.push(this.sceneRootHandle());

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


Wir. `role-part` wird in **unserem** Code gesetzt, im `GroupProcessor` während `replay()` — `GroupProcessor.ts:600`:

```ts
root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];
```

Beim Materialisieren jeder Gruppe bekommt jeder Element-Root das Tag `role-part` angehängt (analog `role-assembly` für Gruppenknoten Z. 704, `role-scene` für den Szenen-Root Z. 721). Die Rollen sind unsere Konvention (`roleTag = role-${role}`, Z. 30), nicht aus den Eingabe-Graphen von FireVisual — die liefern die PMIs/Geometrie, die Rollen-Tags hängen wir dran.


seit wann? und das sit buggy
die role-parts werden mehrfach gesetzt


Zwei Sachen: seit wann (git blame) und ob `replay()` dieselben Handle-Objekte wiederverwendet und bei jedem Lauf erneut taggt. Ich schaue beides an.


echo "=== blame line 600 (role-part) ===" && git log -1 --format='%h %ai %s' -S 'roleTag(ROLE.part)' -- packages/frontend/src/configurator/model/GroupProcessor.ts; echo "=== does replay build handles fresh? read replay head + create apply ==="; sed -n '495,520p' packages/frontend/src/configurator/model/GroupProcessor.ts

Blame role-part and inspect replay handle building


=== blame line 600 (role-part) ===
b961242 2026-06-30 11:27:17 +0200 refactor
=== does replay build handles fresh? read replay head + create apply ===
    replay(baseScene: Scene, events: GroupEvent[]): Scene {
        this.groups = new Map();
        this.handles = new Map();
        this.elementHandles = new Map();
        this.elementToGroup = new Map();
        for (const event of events) this.apply(event);
        return this.materialize(baseScene);
    }

    private apply(event: GroupEvent): void {
        switch (event.type) {
            case "group-create": {
                const cloned = event.handles.map((h) => structuredClone(h));
                for (const handle of cloned) this.handles.set(handle.id, handle);
                this.elementHandles.set(event.rootHandleId, cloned);
                this.groups.set(event.groupId, {
                    id: event.groupId,
                    key: event.groupKey,
                    members: [event.rootHandleId],
                    edges: [],
                    transform: { pos: event.position, rot: event.rotation },
                });
                this.elementToGroup.set(event.rootHandleId, event.groupId);
                break;
            }
            case "group-merge": {


Aufschlussreich: replay clont die Handles pro Lauf aus `event.handles`. Der Verdacht: eine Quelle liefert bereits getaggte Handles in ein neues create-Event. Ich sehe mir `sceneOfElement` (Quelle für Kopien) und `buildCreateEvent` an.


170	    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
171	        const { handles, rootHandleId } = instantiateScene(productScene);
172	        return {
173	            type: "group-create",
174	            timestamp: Date.now(),
175	            groupId: this.nextId("g"),
176	            groupKey: this.nextId("gk"),
177	            rootHandleId,
178	            handles,
179	            position,
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;

    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = instantiateScene(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            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;


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	        }
310	        return { _resourceClass: "sceneGraph_entity", handles: clone };
311	    }
312
313	    // Docking class of a group, read from the `class-…` tag of its element root(s). Null if none of
314	    // the members carry a class tag. Used to look the group up in the client-supplied docking rules.

    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;
        }
        return { _resourceClass: "sceneGraph_entity", handles: clone };
    }

    // Docking class of a group, read from the `class-…` tag of its element root(s). Null if none of
    // the members carry a class tag. Used to look the group up in the client-supplied docking rules.


Antwort auf beides:

**Seit wann:** Die aktuelle Append-Form der Zeile stammt aus Commit `b961242` („refactor", 2026‑06‑30).

**Warum mehrfach (Root Cause):** `materialize` mutiert die *persistenten* Handle-Objekte — Zeile 600 hängt `role-part` an `root.tags` an (`[...(root.tags ?? []), roleTag(...)]`, also **append**, nicht set). `this.handles` und `this.elementHandles` zeigen auf dieselben Objekte. Beim Bau einer Kopie liefert `sceneOfElement` genau diese *materialisierten, bereits getaggten* Handles, und `buildCreateEvent` backt sie in ein neues `group-create`-Event. Beim nächsten Replay klont `apply` diese Handles (mit `role-part` drin) und `materialize` hängt **noch eins** an → doppelt. Entlang einer Bau-Kette (Kopie einer Kopie) wächst es weiter: 2, 3, …

`add-product`-Elemente sind sauber (das Event trägt die rohen Produkt-Handles ohne `role-part`); nur **kopierte** Elemente (Marker-Anbau, Keyboard-Build) duplizieren, weil das Präsentations-Tag über `sceneOfElement` in die Event-Daten leckt.

Nebenbei: die Transform-Zeilen darüber (597–599) mutieren dieselben Objekte, aber per **Zuweisung** (idempotent) — deshalb fällt nur das Tag auf.

**Fix (eine Zeile, idempotent):** vorhandene `role-*` erst entfernen, dann setzen:
```ts
root.tags = [...(root.tags ?? []).filter((t) => !t.startsWith("role-")), roleTag(ROLE.part)];
```
Damit trägt jeder Element-Root nach `materialize` genau ein `role-part`, egal wie oft repliziert oder wie tief die Kopier-Kette ist.

Soll ich das so setzen?


seit wann? und das sit buggy die role-parts werden mehrfach gesetzt


ja


Edit

root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];

                // Idempotent: strip any prior role-* (materialize mutates the persistent handles, and a
                // copied element's create event may already carry a baked-in role tag via sceneOfElement)
                // so each root ends up with exactly one role-part instead of accumulating duplicates.
                root.tags = [...(root.tags ?? []).filter((t) => !t.startsWith("role-")), roleTag(ROLE.part)];


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)

                // Idempotent: strip any prior role-* (materialize mutates the persistent handles, and a
                // copied element's create event may already carry a baked-in role tag via sceneOfElement)
                // so each root ends up with exactly one role-part instead of accumulating duplicates.
                root.tags = [...(root.tags ?? []).filter((t) => !t.startsWith("role-")), roleTag(ROLE.part)];

-                root.tags = [...(root.tags ?? []), roleTag(ROLE.part)];

+                // Idempotent: strip any prior role-* (materialize mutates the persistent handles, and a

+                // copied element's create event may already carry a baked-in role tag via sceneOfElement)

+                // so each root ends up with exactly one role-part instead of accumulating duplicates.

+                root.tags = [...(root.tags ?? []).filter((t) => !t.startsWith("role-")), roleTag(ROLE.part)];


Bash

pnpm --filter @ad-cpq/common-frontend test 2>&1 | grep -E 'Test Files|Tests '; pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Run tests and typecheck


 Test Files  8 passed (8)
      Tests  112 passed (112)
src/configurator/model/GroupProcessor.ts(603,59): error TS7006: Parameter 't' implicitly has an 'any' type.
---done---


Tests grün; nur ein Typ-Annotationsfehler (`t` implizit any). Ich typisiere ihn.


Edit

root.tags = [...(root.tags ?? []).filter((t) => !t.startsWith("role-")), roleTag(ROLE.part)];

root.tags = [...(root.tags ?? []).filter((t: string) => !t.startsWith("role-")), roleTag(ROLE.part)];


// Idempotent: strip any prior role-* (materialize mutates the persistent handles, and a

// copied element's create event may already carry a baked-in role tag via sceneOfElement)

// so each root ends up with exactly one role-part instead of accumulating duplicates.

-                root.tags = [...(root.tags ?? []).filter((t) => !t.startsWith("role-")), roleTag(ROLE.part)];

+                root.tags = [...(root.tags ?? []).filter((t: string) => !t.startsWith("role-")), roleTag(ROLE.part)];


Bash

pnpm --filter @ad-cpq/common-frontend typecheck 2>&1 | grep -vE 'ProductBrowser\.vue' | grep -E 'error TS' | head; echo "---done---"

Re-run typecheck


---done---


Erledigt. `role-part` wird jetzt idempotent gesetzt — jeder Element-Root trägt nach `materialize` genau eins, auch bei Kopien und entlang der Bau-Kette. Tests 112 grün, Typecheck sauber (nur die vorbestehenden ProductBrowser-Fehler).


ok -- dann reden wir jetzt über das steher thema

wurum eine andere Art von Hook?


Weil der Auslöser, der Lebenszyklus und die Sichtbarkeit fundamental anders sind als bei den Command-Hooks.

Die Command-Hooks (`prepare`/`replace`/`refine`) beantworten: **„Der User hat Event X ausgelöst → reagiere."** Sie hängen an einem *Event-Typ* und produzieren Reactions.

Der Steher beantwortet: **„Das Modell *enthält* ein Advanced-Element → dort *gehört* ein Steher hin."** Das ist keine Reaktion auf ein Event, sondern eine Eigenschaft des *Modellzustands*. Drei Konsequenzen, die die Command-Hooks nicht leisten:

**1. Auslöser = Zustand, nicht Event.** Der Steher muss nach *jedem* Weg auftauchen, der diesen Zustand erzeugt: `add-product`, Placement, Marker-Anbau, **Undo, Redo, initiales Laden**. Als Command-Hook müsstest du ihn an jeden Event-Typ hängen (und Undo/Redo/Load laufen da teils gar nicht sauber durch) — Dopplung und Lücken vorprogrammiert. Als Replay-Ableitung hängst du ihn an *eine* Stelle: den Modellaufbau.

**2. Ableitung, nicht aufgezeichnete Aktion.** Ein Command-Hook schreibt entweder Events (`ctx.record`) oder gibt Reactions. Schriebst du den Steher als Event:
- liegt bei jeder Platzierung ein Steher-Create im **Undo-Stack**,
- ist er **eingefroren** — verschiebst du später das Basiselement, wandert er nicht mit,
- **dupliziert** bei Redo/Replay.

Er muss bei *jedem* `replay()` aus dem aktuellen Modell neu abgeleitet werden — nie in den EventStore. Command-Hooks laufen aber *um* `handleEvent`, nicht *in* `replay()`.

**3. Er muss strukturell sein, nicht nur sichtbar.** Der Steher belegt PMIs, und man baut an ihn an — die *nächste* Platzierung muss ihn sehen (`occupiedPmis`/`blockedPmis`/`dockRotation`). Also muss er im **replayten Modell** stehen, nicht nur in der ausgehenden Szene. Genau hier trennt er sich von den Markern: Marker sind rein *visuell* und selektions-gebunden → `refine` auf die Reactions reicht. Der Steher ist *strukturelle Wahrheit* → er gehört in den Modellaufbau, damit Docking und Folge-Operationen ihn kennen.

Kurz: Marker = „wie sieht die Auswahl aus" (Präsentation, refine). Steher = „was ist im Modell, weil etwas anderes im Modell ist" (Ableitung, replay). Zwei verschiedene Fragen → zwei verschiedene Hook-Arten.


ok -- dann reden wir jetzt über das steher thema  wurum eine andere Art von Hook?


nun - was machen wir, wenn der Steher nach Abschluss der Planung von - sagen wir - 2.10m auf 3.80 geändert wird (manuel)


Der manuelle Eingriff ist kein Ableitungsfall mehr — er ist ein **Override**. Und Overrides sind genau das *Eine*, das aufgezeichnet wird (undo-bar), im Gegensatz zur Ableitung. Das ist die Trennung aus dem Registry-Konzept: Ableitung nie in den EventStore, Overrides schon.

**Mechanik**

Die Ableitung erzeugt pro abgeleitetem Element eine **stabile, deterministische Slot-Id** aus dem Modell — z. B. `steher@<AnkerElementId>:<PMI>`. Nicht aus einem Create-Event (das gibt es nicht), sondern reproduzierbar aus dem, was den Steher induziert.

Der manuelle Eingriff schreibt dann ein **Override-Event, das auf diese Slot-Id keyt**: „Slot `steher@…` → Länge 3,80" (bzw. Artikel Y, oder „unterdrückt"). Kein neues Element, ein *Patch*.

Replay wird dann zweistufig:
1. **Ableiten** — aus dem Modell die Default-Steher (2,10) inkl. Slot-Ids.
2. **Overrides drauf** — für jede Slot-Id, die ein Override hat, den Patch anwenden (3,80).

**Verhalten, das daraus fällt**
- **Existenz & Position bleiben abgeleitet:** verschiebst du das Basiselement, folgt der Steher weiter — die 3,80 bleibt.
- **Undo** nimmt den Override zurück → zurück auf 2,10 (das ist der undo-bare Teil).
- **Basis gelöscht** → Slot verschwindet → das Override verwaist. Es liegt harmlos im Store (kein passender Slot beim Ableiten = No-Op), erzeugt also keinen Zombie-Steher, und taucht wieder auf, wenn der Slot zurückkommt (Redo).

**Der Knackpunkt** ist die deterministische Slot-Id. Ohne sie kann ein Override nichts binden — sobald sich die Id bei Modelländerungen verschiebt, springt das Override auf den falschen Steher oder verwaist fälschlich. Das ist die eigentliche Arbeit an diesem Feature, nicht die Ableitung selbst.

**Eine Gabelung, die deine Frage „nach Abschluss der Planung" aufmacht:**

- **(a) Modell bleibt live:** Override patcht die lebende Ableitung (oben beschrieben). Der Steher bleibt an die Basis gekoppelt, nur die Länge ist übersteuert.
- **(b) Einfrieren beim Abschluss:** Bei „Planung fertig" werden alle abgeleiteten + überschriebenen Elemente einmal zu **konkreten** Elementen materialisiert (→ Stückliste). Danach ist der Steher ein normales Element, und die 3,80 ist keine Ableitung+Override mehr, sondern schlicht das Teil.

Meine Empfehlung: **(a) als Fundament** — Override-Patch auf die live Ableitung, weil es während der Planung konsistent mit Undo/Move/Docking bleibt. **(b) als getrennter, expliziter Abschluss-Schritt** obendrauf, wenn aus der Planung eine Bestellung/BOM wird. Für das Override selbst brauchst du (b) nicht; es ist eine Finalisierung, keine Voraussetzung.

Welche der beiden es sein soll, hängt daran, ob „nach Abschluss" bei euch heißt *weiter editierbar mit gekoppelter Basis* (a) oder *festgeschrieben fürs Angebot* (b).


a geht nicht - wir keigen ein abweichendes GLB


Stimmt — dann ist der Override **kein Property-Patch** auf das abgeleitete Element. Ein anderes GLB heißt: anderes Mesh, andere PMIs, andere Frames. Das abgeleitete 2,10‑Teil und das 3,80‑Teil sind **zwei verschiedene Objekte**, nicht dasselbe Objekt mit geänderter Länge. Ein „patch die lebende Ableitung" kann das nicht abbilden — (a) fällt raus.

Die richtige Form ist damit: der manuelle Eingriff **promotet den Slot zu einem konkreten, aufgezeichneten Element**.

**Mechanik (korrigiert)**
- Die Ableitung erzeugt weiter deterministische Slot-Ids (`steher@<Anker>:<PMI>`) und die Default-Steher.
- Der manuelle Eingriff schreibt ein **Claim/Promote-Event**: „Slot K → konkretes Teil mit GLB Y" — aufgezeichnet, undo-bar.
- Replay: die Ableitung leitet alle Slots ab **außer den beanspruchten**; für Slot K steht stattdessen das konkrete Teil aus dem Event (mit seinem eigenen GLB/PMIs).

So bleibt: Undo → zurück auf den abgeleiteten 2,10 (Claim weg); kein Phantom‑Steher unter dem konkreten (Ableitung überspringt den Slot); kein Duplikat.

Das ist nicht (a) und nicht das Bulk‑Einfrieren (b), sondern **(c): Ableitung mit Promotion einzelner Slots auf konkrete Elemente bei manuellem Eingriff**. (b) bleibt weiterhin der separate Abschluss‑Schritt, der *alle* verbliebenen Ableitungen materialisiert.

**Die eine Entscheidung, die (c) offen lässt:** wie ist das promotete 3,80‑Teil verankert?
- **weiter slot-gekoppelt** — Position kommt vom Slot-Anker (folgt der Basis beim Verschieben), Geometrie vom neuen GLB. Erhält das „der Steher gehört zu Element X"-Verhalten, aber die Kopplungs-Mathematik muss die Frames des *neuen* GLB nehmen.
- **freies Teil** — das konkrete Teil ist ein normales Element mit fixem Transform; entkoppelt von der Basis, verhält sich ab da wie ein von Hand gebautes.

Meine Empfehlung: **slot-gekoppelt**, weil die fachliche Aussage „ein (jetzt größerer) Steher an genau diesem Anschluss" erhalten bleibt und Move/Undo konsistent sind. Das freie Teil ist einfacher, verliert aber die Kopplung, die den Steher überhaupt erst zum Steher macht.

Welche Verankerung — gekoppelt oder frei?


natürlich geopplet
wie würdest du es konret implementieren?


Konkret, auf unsere Architektur gemappt:

**1. Zwei-Phasen-Replay (die Naht)**

`GroupProcessor.replay` aufteilen: `applyEvents` und `materialize` trennen. `DefaultCommandProcessor.replay()` wird dann:

```
groups.reset()
groups.applyEvents([...storedEvents, ...pendingRegroup?])   // Phase 1: reales Modell
const derived = this.runDerivations(groups)                 // Phase 2: Ableitung liest Modell
groups.applyEvents(derived)                                 // ephemer, nie im Store
return groups.materialize(initialState)
```

Die Ableitung läuft *in* `replay()` → sie sieht `occupiedPmis`/Frames, ihre Steher landen im Modell (Docking, `set_selection`, Materialisierung). Und sie hängt am Cache: `replayDirty` steuert, dass sie nur bei Modelländerung neu läuft. Override-Events gehen über `ctx.record` → invalidieren den Cache. Passt zusammen.

**2. Der Derive-Hook**

`PlacementModule` bekommt eine optionale vierte Fähigkeit — genau die „andere Art von Hook":

```ts
derive?(model: GroupProcessor, overrides: ModuleEvent[]): GroupEvent[]
```

Gibt ephemere `group-create`/`group-merge`-Events zurück. Kein `record`, kein Store.

**3. Deterministische Ids (der Knackpunkt)**

Die ephemeren Events dürfen **nicht** `nextId()` nutzen — sonst neue Ids pro Replay, Selektion bricht, Overrides binden nicht. Stattdessen aus dem Slot ableiten: `rootHandleId = "steher@" + anchorElementId + ":" + anchorPmi`, Gruppen-Key analog. Reproduzierbar über Replays hinweg.

**4. Overrides als generische, undo-bare Modul-Events**

Ein neuer Event-Typ im Store, den der **Core nicht interpretiert**:

```ts
{ type: "module-event", moduleId, key: slotId, payload: { part: "SBS...3800", glb } }
```

`applyEvents` legt ihn nur in eine Map `this.moduleState[moduleId]` — erzeugt selbst keine Geometrie. Undo/Redo trägt ihn wie jedes andere Event. Das Modul liest seine Scheibe in `derive()`. Damit bleibt Core domänen-agnostisch, und „Overrides = undo-bare Modul-Events" aus dem Registry-Konzept ist wörtlich umgesetzt.

**5. Ableitungs-Algorithmus (in `GekoPlacementModule.derive`)**

```
für jedes Advanced-Element E im Modell:
  für jeden Steher-Anker-PMI p an E:
    slotId = `steher@${E}:${p}`
    part = overrides[slotId]?.part ?? defaultSteherPart      // 2.10 vs 3.80-GLB
    createEv = buildCreateEventDeterministic(sceneOf(part), slotId)
    mergeEv  = group-merge{ anchorRoot: E, anchorPmi: p,
                            joinRoot: createEv.root, joinPmi: STEHER_EIN,
                            rot: dockRotation(E, p, createEv.root, STEHER_EIN) }
    emit createEv, mergeEv
```

Override tauscht nur, *welches* Teil (GLB) instanziiert wird — Anker, Merge und `dockRotation` laufen über die PMIs des **konkreten** Teils, das der Create mitliefert. Daher: anderes GLB, aber weiterhin am Slot gedockt.

**6. Kopplung fällt raus**

Weil der Merge den Steher in die **Gruppe des Ankers** hängt, positioniert `computeLayout` ihn relativ zum Anker-PMI. `group-move` auf die Gruppe bewegt alle Member → der Steher folgt der Basis. Genau die gekoppelte Semantik, ohne Extra-Logik.

**7. Zwei Regeln, die die Ordnung sauber halten**

- **Ableitung ist einstufig:** Steher werden von Advanced-*Katalog*-Elementen induziert, nie von anderen Stehern → kein Iterieren, kein Feedback-Loop.
- **An einen abgeleiteten Steher kann man nicht direkt bauen:** ein abgeleiteter Steher existiert nur in Phase 2 (ephemer), ein gespeichertes Merge in Phase 1 könnte seinen Anker nicht finden. Also: **Interaktion promotet.** Baut oder ändert der User an einem abgeleiteten Steher, schreibt das erst das `module-event` (Promotion) — ab da ist der Slot beansprucht, die Ableitung überspringt ihn bzw. nutzt das konkrete Teil, und gespeicherte Merges referenzieren nur konkrete/deterministische Ids.

**8. Wo im Code**
- `GroupProcessor`: `replay` → `applyEvents` + `materialize` splitten; `moduleState`-Map + `module-event` in `apply`; deterministischer Create-Pfad (`buildCreateEvent` ohne `nextId`).
- `EventStore`: nichts Neues — `module-event` ist ein `DomainEvent` wie die anderen.
- `DefaultCommandProcessor.replay()`: die drei Phasen + `runDerivations` über registrierte Module.
- `PlacementModule`: `derive?()` ergänzen.
- `GekoPlacementModule`: `derive()` + ein Command-Hook, der die manuelle Größenänderung als `module-event` aufzeichnet.

Der einzige Teil, der wirklich Sorgfalt braucht, ist Punkt 3 (deterministische Slot-Ids) plus die Promotion-Regel aus Punkt 7 — daran hängt, ob Overrides stabil binden und ob Undo/Redo und „Basis gelöscht" sauber durchlaufen. Der Rest ist vorhandene Merge-/Layout-Maschinerie.


bau das mal rein


Bevor ich schneide, muss ich die tatsächlichen Bausteine im `GroupProcessor` genau kennen — `instantiateScene` (Id-Vergabe für deterministische Ids), die `GroupEvent`-Typen, `apply` und `materialize`.


30	const roleTag = (role: NodeRole) => `role-${role}`;
31
32	// The single re-identify primitive: deep-clone a scene (a product template OR a live subtree being
33	// copied) and give every handle id and transform key a fresh UUID, remapping parentKey. Source ids
34	// repeat across instances and configured copies must not share identity, so this ALWAYS clones. Returns
35	// the prepared handles plus the element root (the handle whose transform has no parentKey). This is the
36	// one place identity is minted — buildCreateEvent (which both product-add and copy funnel through)
37	// calls it; nothing else re-ids.
38	export function instantiateScene(scene: Scene): { handles: Handle[]; rootHandleId: string } {
39	    const src = structuredClone(scene);
40	    const keyMap = new Map<string, string>();
41
42	    for (const handle of src.handles ?? []) {
43	        handle.id = `h-${crypto.randomUUID()}`;
44	        for (const comp of handle.components ?? []) {
45	            if (comp._resourceClass === "component_transform" && comp.key) {
46	                const newKey = `k-${crypto.randomUUID()}`;
47	                keyMap.set(comp.key, newKey);
48	                comp.key = newKey;
49	            }
50	        }
51	    }
52
53	    let rootHandleId = "";
54	    for (const handle of src.handles ?? []) {
55	        for (const comp of handle.components ?? []) {
56	            if (comp._resourceClass !== "component_transform") continue;
57	            if (comp.parentKey) {
58	                const mapped = keyMap.get(comp.parentKey);
59	                if (mapped) comp.parentKey = mapped;
60	            } else {
61	                // The root transform carries no parentKey → its handle is the element root.
62	                rootHandleId = handle.id;
63	            }
64	        }
65	    }
66	    return { handles: src.handles, rootHandleId };
67	}
68
69	// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.
70	export interface Edge {
71	    from: string; // element root handle id, positioned first
72	    to: string; // element root handle id, positioned relative to `from`
73	    fromPmi: string; // docking point id on `from`
74	    toPmi: string; // docking point id on `to`
75	    // Rotation of `to` relative to `from` across this dock (in `from`'s frame). Absent/identity means
76	    // the two parts share orientation — the plain left/right case (CS_LINKS/CS_RECHTS carry no rotation).
77	    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.
78	    rot?: Quat;
79	}
80
81	interface Group {
82	    id: string;
83	    key: string; // transform key of the group node; members' root transforms parent to it
84	    members: string[]; // element root handle ids, no implied order — order lives in `edges`
85	    edges: Edge[];
86	    transform: Transform; // group node world transform
87	}
88
89	export interface GroupCreateEvent {
90	    type: "group-create";
91	    timestamp: number;
92	    groupId: string;
93	    groupKey: string;
94	    rootHandleId: string; // the element root among `handles`
95	    handles: Handle[]; // the element's full subtree, already opaquely re-identified
96	    position: Vec3;
97	    rotation: Quat;
98	}
99
100	export interface GroupMergeEvent {
101	    type: "group-merge";
102	    timestamp: number;
103	    targetGroupId: string;
104	    movingGroupId: string;
105	    anchorRootId: string; // target member the moving part docks onto (positioned first)
106	    joinRootId: string; // moving member that docks (positioned relative to the anchor)
107	    anchorPmiId: string; // docking point id on the anchor
108	    joinPmiId: string; // docking point id on the join, placed to coincide with the anchor's
109	    rot?: Quat; // rotation of the join part relative to the anchor (absent/identity = shared orientation)
110	}
111
112	export interface GroupTransformEvent {
113	    type: "group-move" | "group-rotate";
114	    timestamp: number;
115	    groupId: string;
116	    position: Vec3;
117	    rotation: Quat;
118	}
119
120	export interface GroupDetachEvent {
121	    type: "group-detach";
122	    timestamp: number;
123	    sourceGroupId: string; // the group being dissolved
124	    elementId: string; // the part pulled out (its own one-member group)
125	    // The pulled element becomes its own group; the rest stays one group, re-linked across the gap
126	    // (the element's two neighbours dock directly), so the reading-order chain closes up. Each new
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 }>;
129	}
130
131	export interface GroupRegroupEvent {
132	    type: "group-regroup";
133	    timestamp: number;
134	    // Source groups that get dissolved, and the new groups they become: one per connected component
135	    // (selected components are the movable ones, the rest stays). World-preserving, ids baked in.
136	    sourceGroupIds: string[];
137	    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
138	}
139
140	export type GroupEvent =
141	    | GroupCreateEvent
142	    | GroupMergeEvent
143	    | GroupTransformEvent
144	    | GroupDetachEvent
145	    | GroupRegroupEvent;
146
147	export class GroupProcessor {
148
149	    // Last replayed state — record-time queries (which group owns a part, where its open ends are)
150	    // read this; replay() rebuilds it from the event log.
151	    private groups = new Map<string, Group>();
152	    private handles = new Map<string, Handle>(); // id → element subtree handle (cloned)
153	    private elementHandles = new Map<string, Handle[]>(); // element root id → its full subtree
154	    private elementToGroup = new Map<string, string>(); // element root id → group id
155	    // Built each materialise: handle id → {parent handle id, role}. The role resolver reads this.
156	    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();
157
158	    // Record-time id allocation: a fresh UUID per graph id (handle, transform key, group id/key).
159	    // Unique across instances and replays (baked into the create event). The prefix keeps the id a
160	    // valid CSS identifier — a bare UUID can start with a digit, which breaks the "#id" selector.
161	    private nextId(prefix: string): string {
162	        return `${prefix}-${crypto.randomUUID()}`;
163	    }
164
165	    // ---- record-time -------------------------------------------------------
166
167	    // Build a create event from a product scene (or a copied subtree): re-identify it via
168	    // instantiateScene (source ids repeat across instances), find the element root, and bake the
169	    // prepared subtree into the event so replay is deterministic.
170	    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
171	        const { handles, rootHandleId } = instantiateScene(productScene);
172	        return {
173	            type: "group-create",
174	            timestamp: Date.now(),
175	            groupId: this.nextId("g"),
176	            groupKey: this.nextId("gk"),
177	            rootHandleId,
178	            handles,
179	            position,
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	    }

const roleTag = (role: NodeRole) => `role-${role}`;

// The single re-identify primitive: deep-clone a scene (a product template OR a live subtree being
// copied) and give every handle id and transform key a fresh UUID, remapping parentKey. Source ids
// repeat across instances and configured copies must not share identity, so this ALWAYS clones. Returns
// the prepared handles plus the element root (the handle whose transform has no parentKey). This is the
// one place identity is minted — buildCreateEvent (which both product-add and copy funnel through)
// calls it; nothing else re-ids.
export function instantiateScene(scene: Scene): { handles: Handle[]; rootHandleId: string } {
    const src = structuredClone(scene);
    const keyMap = new Map<string, string>();

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

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

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.
export interface Edge {
    from: string; // element root handle id, positioned first
    to: string; // element root handle id, positioned relative to `from`
    fromPmi: string; // docking point id on `from`
    toPmi: string; // docking point id on `to`
    // Rotation of `to` relative to `from` across this dock (in `from`'s frame). Absent/identity means
    // the two parts share orientation — the plain left/right case (CS_LINKS/CS_RECHTS carry no rotation).
    // A non-identity rotation is how a 90° branch (e.g. onto a post) is expressed.
    rot?: Quat;
}

interface Group {
    id: string;
    key: string; // transform key of the group node; members' root transforms parent to it
    members: string[]; // element root handle ids, no implied order — order lives in `edges`
    edges: Edge[];
    transform: Transform; // group node world transform
}

export interface GroupCreateEvent {
    type: "group-create";
    timestamp: number;
    groupId: string;
    groupKey: string;
    rootHandleId: string; // the element root among `handles`
    handles: Handle[]; // the element's full subtree, already opaquely re-identified
    position: Vec3;
    rotation: Quat;
}

export interface GroupMergeEvent {
    type: "group-merge";
    timestamp: number;
    targetGroupId: string;
    movingGroupId: string;
    anchorRootId: string; // target member the moving part docks onto (positioned first)
    joinRootId: string; // moving member that docks (positioned relative to the anchor)
    anchorPmiId: string; // docking point id on the anchor
    joinPmiId: string; // docking point id on the join, placed to coincide with the anchor's
    rot?: Quat; // rotation of the join part relative to the anchor (absent/identity = shared orientation)
}

export interface GroupTransformEvent {
    type: "group-move" | "group-rotate";
    timestamp: number;
    groupId: string;
    position: Vec3;
    rotation: Quat;
}

export interface GroupDetachEvent {
    type: "group-detach";
    timestamp: number;
    sourceGroupId: string; // the group being dissolved
    elementId: string; // the part pulled out (its own one-member group)
    // The pulled element becomes its own group; the rest stays one group, re-linked across the gap
    // (the element's two neighbours dock directly), so the reading-order chain closes up. Each new
    // group carries its edges and a world-preserving transform for its origin so nothing jumps.
    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
}

export interface GroupRegroupEvent {
    type: "group-regroup";
    timestamp: number;
    // Source groups that get dissolved, and the new groups they become: one per connected component
    // (selected components are the movable ones, the rest stays). World-preserving, ids baked in.
    sourceGroupIds: string[];
    newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;
}

export type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent;

export class GroupProcessor {

    // Last replayed state — record-time queries (which group owns a part, where its open ends are)
    // read this; replay() rebuilds it from the event log.
    private groups = new Map<string, Group>();
    private handles = new Map<string, Handle>(); // id → element subtree handle (cloned)
    private elementHandles = new Map<string, Handle[]>(); // element root id → its full subtree
    private elementToGroup = new Map<string, string>(); // element root id → group id
    // Built each materialise: handle id → {parent handle id, role}. The role resolver reads this.
    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();

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

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

    // Build a create event from a product scene (or a copied subtree): re-identify it via
    // instantiateScene (source ids repeat across instances), find the element root, and bake the
    // prepared subtree into the event so replay is deterministic.
    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = instantiateScene(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            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;
    }


Wichtig: `instantiateScene` mintet Handle-Ids per `crypto.randomUUID()` — nicht deterministisch. Für die Ableitung brauche ich eine deterministische Variante (Ids aus der Slot-Id), sonst churnt jeder Replay. Ich lese den Rest von `apply`/`materialize`, um sauber zu integrieren.


520	            case "group-merge": {
521	                const target = this.groups.get(event.targetGroupId);
522	                const moving = this.groups.get(event.movingGroupId);
523	                if (!target || !moving) break;
524	                // A docking point must never be booked twice. openEndExclusions already keeps the
525	                // placement UI off occupied points, but guard here too: if the anchor or join point is
526	                // already in use, drop the merge rather than let a double-booked edge into the graph.
527	                const occupied = this.occupiedPmis();
528	                const busy = (element: string, pmi: string) =>
529	                    occupied.some((o) => o.element === element && o.pmi === pmi);
530	                if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;
531	                target.members.push(...moving.members);
532	                target.edges.push(...moving.edges);
533	                target.edges.push({
534	                    from: event.anchorRootId,
535	                    to: event.joinRootId,
536	                    fromPmi: event.anchorPmiId,
537	                    toPmi: event.joinPmiId,
538	                    rot: event.rot,
539	                });
540	                for (const member of moving.members) this.elementToGroup.set(member, target.id);
541	                this.groups.delete(event.movingGroupId);
542	                break;
543	            }
544	            case "group-move":
545	            case "group-rotate": {
546	                const g = this.groups.get(event.groupId);
547	                if (!g) break;
548	                g.transform = { pos: event.position, rot: event.rotation };
549	                break;
550	            }
551	            case "group-detach": {
552	                if (!this.groups.has(event.sourceGroupId)) break;
553	                for (const ng of event.newGroups) {
554	                    this.groups.set(ng.groupId, {
555	                        id: ng.groupId,
556	                        key: ng.groupKey,
557	                        members: [...ng.members],
558	                        edges: ng.edges.map((e) => ({ ...e })),
559	                        transform: ng.transform,
560	                    });
561	                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
562	                }
563	                this.groups.delete(event.sourceGroupId);
564	                break;
565	            }
566	            case "group-regroup": {
567	                for (const sid of event.sourceGroupIds) this.groups.delete(sid);
568	                for (const ng of event.newGroups) {
569	                    this.groups.set(ng.groupId, {
570	                        id: ng.groupId,
571	                        key: ng.groupKey,
572	                        members: [...ng.members],
573	                        edges: ng.edges.map((e) => ({ ...e })),
574	                        transform: ng.transform,
575	                    });
576	                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
577	                }
578	                break;
579	            }
580	        }
581	    }
582
583	    private materialize(baseScene: Scene): Scene {
584	        const handles: Handle[] = [...(baseScene?.handles ?? [])];

            case "group-merge": {
                const target = this.groups.get(event.targetGroupId);
                const moving = this.groups.get(event.movingGroupId);
                if (!target || !moving) break;
                // A docking point must never be booked twice. openEndExclusions already keeps the
                // placement UI off occupied points, but guard here too: if the anchor or join point is
                // already in use, drop the merge rather than let a double-booked edge into the graph.
                const occupied = this.occupiedPmis();
                const busy = (element: string, pmi: string) =>
                    occupied.some((o) => o.element === element && o.pmi === pmi);
                if (busy(event.anchorRootId, event.anchorPmiId) || busy(event.joinRootId, event.joinPmiId)) break;
                target.members.push(...moving.members);
                target.edges.push(...moving.edges);
                target.edges.push({
                    from: event.anchorRootId,
                    to: event.joinRootId,
                    fromPmi: event.anchorPmiId,
                    toPmi: event.joinPmiId,
                    rot: event.rot,
                });
                for (const member of moving.members) this.elementToGroup.set(member, target.id);
                this.groups.delete(event.movingGroupId);
                break;
            }
            case "group-move":
            case "group-rotate": {
                const g = this.groups.get(event.groupId);
                if (!g) break;
                g.transform = { pos: event.position, rot: event.rotation };
                break;
            }
            case "group-detach": {
                if (!this.groups.has(event.sourceGroupId)) break;
                for (const ng of event.newGroups) {
                    this.groups.set(ng.groupId, {
                        id: ng.groupId,
                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                this.groups.delete(event.sourceGroupId);
                break;
            }
            case "group-regroup": {
                for (const sid of event.sourceGroupIds) this.groups.delete(sid);
                for (const ng of event.newGroups) {
                    this.groups.set(ng.groupId, {
                        id: ng.groupId,
                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                break;
            }
        }
    }

    private materialize(baseScene: Scene): Scene {
        const handles: Handle[] = [...(baseScene?.handles ?? [])];


Genug Kontext. Ich baue die Infrastruktur (domänen-agnostisch) plus eine config-getriebene GEKO-Ableitung, die per Default inert ist — bestehende Tests/Stories ändern sich nicht, bis geko Steher-Regeln liefert. Ich fange im `GroupProcessor` an.

**G1 — deterministische Instanziierung**


Edit

    return { handles: src.handles, rootHandleId };
}

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.

    return { handles: src.handles, rootHandleId };
}

// Like instantiateScene, but ids are derived deterministically from a seed instead of random UUIDs.
// A derivation (e.g. auto-inserted Stehers) recomputes the same element on every replay; random ids
// would churn selection/docking and break override binding, so the same seed must always yield the
// same ids. The seed is sanitized to a valid CSS identifier tail (handle ids are used as `#id`).
export function instantiateSceneDeterministic(scene: Scene, seed: string): { handles: Handle[]; rootHandleId: string } {
    const src = structuredClone(scene);
    const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");
    const keyMap = new Map<string, string>();

    let i = 0;
    for (const handle of src.handles ?? []) {
        handle.id = `h-${s}-${i}`;
        for (const comp of handle.components ?? []) {
            if (comp._resourceClass === "component_transform" && comp.key) {
                const newKey = `k-${s}-${i}`;
                keyMap.set(comp.key, newKey);
                comp.key = newKey;
            }
        }
        i++;
    }

    let rootHandleId = "";
    for (const handle of src.handles ?? []) {
        for (const comp of handle.components ?? []) {
            if (comp._resourceClass !== "component_transform") continue;
            if (comp.parentKey) {
                const mapped = keyMap.get(comp.parentKey);
                if (mapped) comp.parentKey = mapped;
            } else {
                rootHandleId = handle.id;
            }
        }
    }
    return { handles: src.handles, rootHandleId };
}

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.


    return { handles: src.handles, rootHandleId };
}

// Like instantiateScene, but ids are derived deterministically from a seed instead of random UUIDs.
// A derivation (e.g. auto-inserted Stehers) recomputes the same element on every replay; random ids
// would churn selection/docking and break override binding, so the same seed must always yield the
// same ids. The seed is sanitized to a valid CSS identifier tail (handle ids are used as `#id`).
export function instantiateSceneDeterministic(scene: Scene, seed: string): { handles: Handle[]; rootHandleId: string } {
    const src = structuredClone(scene);
    const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");
    const keyMap = new Map<string, string>();

    let i = 0;
    for (const handle of src.handles ?? []) {
        handle.id = `h-${s}-${i}`;
        for (const comp of handle.components ?? []) {
            if (comp._resourceClass === "component_transform" && comp.key) {
                const newKey = `k-${s}-${i}`;
                keyMap.set(comp.key, newKey);
                comp.key = newKey;
            }
        }
        i++;
    }

    let rootHandleId = "";
    for (const handle of src.handles ?? []) {
        for (const comp of handle.components ?? []) {
            if (comp._resourceClass !== "component_transform") continue;
            if (comp.parentKey) {
                const mapped = keyMap.get(comp.parentKey);
                if (mapped) comp.parentKey = mapped;
            } else {
                rootHandleId = handle.id;
            }
        }
    }
    return { handles: src.handles, rootHandleId };
}

// `to` is positioned so its `toPmi` coincides with `from`'s `fromPmi`.

+// Like instantiateScene, but ids are derived deterministically from a seed instead of random UUIDs.

+// A derivation (e.g. auto-inserted Stehers) recomputes the same element on every replay; random ids

+// would churn selection/docking and break override binding, so the same seed must always yield the

+// same ids. The seed is sanitized to a valid CSS identifier tail (handle ids are used as `#id`).

+export function instantiateSceneDeterministic(scene: Scene, seed: string): { handles: Handle[]; rootHandleId: string } {

+    const src = structuredClone(scene);

+    const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");

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

+    for (const handle of src.handles ?? []) {

+        for (const comp of handle.components ?? []) {

+            if (comp._resourceClass === "component_transform" && comp.key) {

+                const newKey = `k-${s}-${i}`;

+                keyMap.set(comp.key, newKey);

+            if (comp._resourceClass !== "component_transform") continue;

+                const mapped = keyMap.get(comp.parentKey);

+                if (mapped) comp.parentKey = mapped;

+                rootHandleId = handle.id;

+    return { handles: src.handles, rootHandleId };

from: string; // element root handle id, positioned first


**G2 — `module-event`-Typ (undo-bar, Core interpretiert ihn nur als State-Ablage)**


export type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent;

// An opaque, undo-able state entry owned by a module (e.g. a manual Steher override). The core stores
// and undoes it like any event but never interprets the payload — it only files it under
// moduleState[moduleId][key]. The owning module reads its slice back during derive(). `payload: null`
// clears the key. This is the "override" half of derive: geometry is derived (never stored), overrides
// on that geometry are stored here so they survive undo/redo.
export interface GroupModuleEvent {
    type: "module-event";
    timestamp: number;
    moduleId: string;
    key: string;
    payload: unknown | null;
}

export type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent
    | GroupModuleEvent;


export type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent;

// An opaque, undo-able state entry owned by a module (e.g. a manual Steher override). The core stores
// and undoes it like any event but never interprets the payload — it only files it under
// moduleState[moduleId][key]. The owning module reads its slice back during derive(). `payload: null`
// clears the key. This is the "override" half of derive: geometry is derived (never stored), overrides
// on that geometry are stored here so they survive undo/redo.
export interface GroupModuleEvent {
    type: "module-event";
    timestamp: number;
    moduleId: string;
    key: string;
    payload: unknown | null;
}

export type GroupEvent =
    | GroupCreateEvent
    | GroupMergeEvent
    | GroupTransformEvent
    | GroupDetachEvent
    | GroupRegroupEvent
    | GroupModuleEvent;

newGroups: Array<{ members: string[]; edges: Edge[]; groupId: string; groupKey: string; transform: Transform }>;

+// An opaque, undo-able state entry owned by a module (e.g. a manual Steher override). The core stores

+// and undoes it like any event but never interprets the payload — it only files it under

+// moduleState[moduleId][key]. The owning module reads its slice back during derive(). `payload: null`

+// clears the key. This is the "override" half of derive: geometry is derived (never stored), overrides

+// on that geometry are stored here so they survive undo/redo.


**G3 — moduleState-Feld, deterministischer Create, Replay-Split**


    // Built each materialise: handle id → {parent handle id, role}. The role resolver reads this.
    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();

    // Built each materialise: handle id → {parent handle id, role}. The role resolver reads this.
    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();
    // Module-owned override state, filed by module-event: moduleId → (key → payload). Read back by a
    // module's derive() to override the geometry it derives. Rebuilt each replay from the event log.
    private moduleState = new Map<string, Map<string, unknown>>();


    // Built each materialise: handle id → {parent handle id, role}. The role resolver reads this.
    private nodeIndex = new Map<string, { parentId: string | null; role: NodeRole }>();
    // Module-owned override state, filed by module-event: moduleId → (key → payload). Read back by a
    // module's derive() to override the geometry it derives. Rebuilt each replay from the event log.
    private moduleState = new Map<string, Map<string, unknown>>();

private elementToGroup = new Map<string, string>(); // element root id → group id

+    // Module-owned override state, filed by module-event: moduleId → (key → payload). Read back by a

+    // module's derive() to override the geometry it derives. Rebuilt each replay from the event log.

+    private moduleState = new Map<string, Map<string, unknown>>();

// Record-time id allocation: a fresh UUID per graph id (handle, transform key, group id/key).

// Unique across instances and replays (baked into the create event). The prefix keeps the id a


    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = instantiateScene(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = instantiateScene(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

    // Deterministic counterpart of buildCreateEvent for derived (never-stored) elements: same ids on
    // every replay, keyed off `seed` (a slot id), so selection/docking/override binding stay stable.
    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string): GroupCreateEvent {
        const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");
        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

    // The override slice a module filed via module-event; empty map if none. Read in derive().
    moduleStateOf(moduleId: string): Map<string, unknown> {
        return this.moduleState.get(moduleId) ?? new Map();
    }


    buildCreateEvent(productScene: Scene, position: Vec3): GroupCreateEvent {
        const { handles, rootHandleId } = instantiateScene(productScene);
        return {
            type: "group-create",
            timestamp: Date.now(),
            groupId: this.nextId("g"),
            groupKey: this.nextId("gk"),
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

    // Deterministic counterpart of buildCreateEvent for derived (never-stored) elements: same ids on
    // every replay, keyed off `seed` (a slot id), so selection/docking/override binding stay stable.
    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string): GroupCreateEvent {
        const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");
        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);
        return {
            type: "group-create",
            timestamp: 0,
            groupId: `g-${s}`,
            groupKey: `gk-${s}`,
            rootHandleId,
            handles,
            position,
            rotation: IDENTITY_QUAT,
        };
    }

    // The override slice a module filed via module-event; empty map if none. Read in derive().
    moduleStateOf(moduleId: string): Map<string, unknown> {
        return this.moduleState.get(moduleId) ?? new Map();
    }

+    // Deterministic counterpart of buildCreateEvent for derived (never-stored) elements: same ids on

+    // every replay, keyed off `seed` (a slot id), so selection/docking/override binding stay stable.

+    buildCreateEventDeterministic(productScene: Scene, position: Vec3, seed: string): GroupCreateEvent {

+        const s = seed.replace(/[^a-zA-Z0-9_-]/g, "-");

+        const { handles, rootHandleId } = instantiateSceneDeterministic(productScene, s);

+    // The override slice a module filed via module-event; empty map if none. Read in derive().

+    moduleStateOf(moduleId: string): Map<string, unknown> {

+        return this.moduleState.get(moduleId) ?? new Map();

groupIdOfElement(rootHandleId: string): string | null {

return this.elementToGroup.get(rootHandleId) ?? null;


Jetzt der Replay-Split und der `module-event`-Handler in `apply`.


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

    replay(baseScene: Scene, events: GroupEvent[]): Scene {
        this.reset();
        this.applyEvents(events);
        return this.materialize(baseScene);
    }

    // The seam for replay-time derivation: a caller applies the stored events, lets modules derive
    // ephemeral geometry on top of the assembled model, then materialises. reset → applyEvents(stored)
    // → module.derive(this) → materializeScene.
    reset(): void {
        this.groups = new Map();
        this.handles = new Map();
        this.elementHandles = new Map();
        this.elementToGroup = new Map();
        this.moduleState = new Map();
    }

    applyEvents(events: GroupEvent[]): void {
        for (const event of events) this.apply(event);
    }

    materializeScene(baseScene: Scene): Scene {
        return this.materialize(baseScene);
    }


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

    replay(baseScene: Scene, events: GroupEvent[]): Scene {
        this.reset();
        this.applyEvents(events);
        return this.materialize(baseScene);
    }

    // The seam for replay-time derivation: a caller applies the stored events, lets modules derive
    // ephemeral geometry on top of the assembled model, then materialises. reset → applyEvents(stored)
    // → module.derive(this) → materializeScene.
    reset(): void {
        this.groups = new Map();
        this.handles = new Map();
        this.elementHandles = new Map();
        this.elementToGroup = new Map();
        this.moduleState = new Map();
    }

    applyEvents(events: GroupEvent[]): void {
        for (const event of events) this.apply(event);
    }

    materializeScene(baseScene: Scene): Scene {
        return this.materialize(baseScene);
    }

+        return this.materialize(baseScene);

+    // The seam for replay-time derivation: a caller applies the stored events, lets modules derive

+    // ephemeral geometry on top of the assembled model, then materialises. reset → applyEvents(stored)

+    // → module.derive(this) → materializeScene.

+    applyEvents(events: GroupEvent[]): void {

+    materializeScene(baseScene: Scene): Scene {


            case "group-regroup": {
                for (const sid of event.sourceGroupIds) this.groups.delete(sid);
                for (const ng of event.newGroups) {
                    this.groups.set(ng.groupId, {
                        id: ng.groupId,
                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                break;
            }
        }
    }

            case "group-regroup": {
                for (const sid of event.sourceGroupIds) this.groups.delete(sid);
                for (const ng of event.newGroups) {
                    this.groups.set(ng.groupId, {
                        id: ng.groupId,
                        key: ng.groupKey,
                        members: [...ng.members],
                        edges: ng.edges.map((e) => ({ ...e })),
                        transform: ng.transform,
                    });
                    for (const m of ng.members) this.elementToGroup.set(m, ng.groupId);
                }
                break;
            }
            case "module-event": {
                let slice = this.moduleState.get(event.moduleId);
                if (!slice) {
                    slice = new Map();
                    this.moduleState.set(event.moduleId, slice);
                }
                if (event.payload === null) slice.delete(event.key);
                else slice.set(event.key, event.payload);
                break;
            }
        }
    }