visual-3d.ts:167 [Fire3D Asset Loader] Attempting to load asset: {uri: 'https://rob-geko.hifassets.com/fire/assets/images/empty_warehouse_2K.jpg', originalPath: 'empty_warehouse_2K.jpg', typeKey: unde…


Rollenförderer Visualisierung und Fehler beheben


Conversation compacted


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

Summary:
1. Primary Request and Intent:
   The conversation covered many tasks in the adesso-cpq/geko project. The MOST RECENT and current intent: **add green spheres at the PMIs of a fence element (Zaunelement) when it's selected, make them clickable, and pop an alert on click**. The user explicitly wanted: (a) green spheres at PMIs on select ("kannst Du Kugeln onSelect eines Zaunelements einbauen?"), (b) a round/sphere collider ("Geht das auch in ECHT Rund mit RUNDEM Colider?"), (c) intercept a click on the spheres with an alert ("tu das mal und gib einfach einen Alert, wenn ich klicke"), (d) after browser crashes, "geh mal auf ein glb" (switch to a glb-based marker). Earlier completed intents: fix group-dissolution rotation bug, rebuild tests onto new PMI naming, move docking rules to JSON, flip placement to ID-based whitelist, consolidate instantiate/reidentify, fix GraphExport null bug.

2. Key Technical Concepts:
   - GroupProcessor event-sourced model (group-create/merge/detach/regroup/move/rotate), replay() → materialize() scene graph
   - PMI docking: CS_EIN_L/CS_EIN_R (entry/join points), CS_OUT_R_0/CS_OUT_L_0/_90/_270 (open ends), CS_SELECT_*, class-<gruppe>-<klasse> tags on root handles
   - DockingRules: Record<movingClass, Record<targetClass, Record<dragPoint, dropPoint[]>>>, hyphen notation (schutzzaun_basic-tuer)
   - fire-visual/@ad/fire: reverse-engineered third-party engine (NOT our code to modify); @ad/fire is the built package (geko-fire, git.heron.at/pzm/geko)
   - Scene graph JSON: handles with component_transform, component_pmi, component_collider, sceneGraph_reference (glb), overrideMaterials, material_reference
   - fire runtime limitations (discovered via browser console): component_renderer/component_lineRenderer are unknown/skipped; only sceneGraph_reference (glb) renders; geometry_parametric works only for the config grid (component_lineRenderer in initialScene) but NOT via setScene
   - Collider ShapeType enum: sphere=2, box=4, mesh=8, capsule=16; triggersSelection defaults true
   - Picking: cursorSelector.ts raycasts colliders → getSelectable → collider.handle fallback; SelectionClickEvent target
   - Asset loading: glbs from https://rob-geko.hifassets.com/fire/assets/katalog/..., materials from .../fire/assets/materials/
   - UIReaction union: contextMenu, sendCommand, notification, dialog, dispatchEvent (in configurator/types.ts)

3. Files and Code Sections:
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts` (MOST ACTIVE)
     - `withSelectionMarkers(scene)` — adds green sphere markers at PMIs of selected element roots. CURRENT state (just edited to glb):
       ```js
       private withSelectionMarkers(scene: any): any {
           const roots = new Set(this.selectionElementRoots());
           if (!roots.size) return scene;
           const markers: any[] = [];
           for (const h of scene.handles ?? []) {
               if (!roots.has(h.id)) continue;
               const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
               if (!tr?.key) continue;
               for (const c of h.components ?? []) {
                   if (c._resourceClass !== "component_pmi") continue;
                   const pos = c.properties?.position?.value ?? [0, 0, 0];
                   markers.push({
                       id: `sel-marker-${h.id}-${c.id}`,
                       tags: ["name-select-marker"],
                       sceneGraph: { _resourceClass: "sceneGraph_reference", path: "katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb" },
                       overrideMaterials: [{ _resourceClass: "material_standard", diffuse: [0, 1, 0] }],
                       components: [
                           { _resourceClass: "component_transform", parentKey: tr.key, translation: pos, rotation: [0, 0, 0], scale: [2, 2, 2] },
                           { _resourceClass: "component_collider", type: 2, shapeType: 2, center: [0, 0, 0], radius: 60 },
                       ],
                   });
               }
           }
           return { ...scene, handles: [...(scene.handles ?? []), ...markers] };
       }
       ```
     - `selectionReactions(stop)` — now emits setScene with markers:
       ```js
       private selectionReactions(stop: UIReaction): UIReaction[] {
           const scene = this.withSelectionMarkers(this.replay());
           const selectors = this.selectedNodeIds().map((id) => "#" + id);
           return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }, { type: "sendCommand", cmd: "set_selection", args: [selectors] }];
       }
       ```
     - `handleSelectionClickEvent` — marker-click intercept at top:
       ```js
       const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
       const targetId: unknown = event.target?.id;
       if (typeof targetId === "string" && targetId.startsWith("sel-marker-")) {
           const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
           const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
           return [{ type: "dialog", title: "PMI-Marker", message: `Klick auf PMI ${pmi}\nElement ${element}` }];
       }
       const additive = event.selectionAction === "add";
       ```
     - `placementDragPoints(subject)` — ID-based whitelist (earlier work, still present): builds `#${el} .name-${dp}` for open points via `this.groups.elementsOfClass(targetClass)`, excludes occupied and own members
     - Contains handleAddProduct, handleDetachElement (start_placement_mode), getContextForSelection (context menu "Platzieren")
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.ts`
     - `makeComponentGroup` rotation fix (COMMITTED 9fbadce): `rot: quatMul(g.transform.rot, origin.rot)` (was `g.transform.rot`); computePositions removed, uses computeLayout
     - `instantiateScene(scene)` free exported function (clone + UUIDs, replaces reidentify)
     - `elementsOfClass(klass)` method (finds element roots by class-<klass> tag)
     - `buildCreateEvent` calls instantiateScene (reidentify deleted)
     - classOfGroup reads `class-` tag
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/services/product-service.ts` — ProductService.instantiate (i<n>- counter) removed; getProduct returns raw template
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/Fire3D.vue` — GraphExport fix: processReactions setScene branch now sets `currentSceneGraph.value = reaction.args[0]` (line ~136); removed TEMP fire-event console.log
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/.storybook/products/docking-rules.json` — created, hyphen-notation DockingRules (COMMITTED d448cdb)
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/components/visual/ProductConfigurator.stories.ts` — loads docking-rules.json via fetch, `v-if="dockingRules"` (COMMITTED d448cdb)
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.test.ts` — chain() uses DOCKING_RULES + CS_EIN_L/CS_OUT_R_0; added whitelist test
   - `/Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/model/GroupProcessor.test.ts` — rebuilt onto CS_EIN_L/CS_OUT_R_0 (STEP=1134), added "detach keeps orientation" test

4. Errors and fixes:
   - Marker doesn't render + crashes on select ("unknown Component 'wi' was skipped" + "Cannot read properties of undefined (reading 'getBounds')"): First attempt was inline mesh `component_renderer` + `geometry_parametric` sphere-3d + `material_basic`. Fix attempt 1: switched to `component_lineRenderer` + `sphere-line` (mirroring config grid). STILL CRASHED — browser log proved BOTH renderer component types are unknown/skipped in @ad/fire. Final fix: switched to glb via `sceneGraph_reference` (CAP4040.glb, proven to load) + overrideMaterials green + sphere collider. **User feedback: "geh mal auf ein glb"** — user was correct that glb is the only working path; I initially resisted (arguing no sphere glb exists) which was wrong given renderer components don't work.
   - "Keine Kugeln" misinterpretation: I read it as "drop the spheres" and reverted the marker code. **User feedback: "Hähhhh!!! Ich will den KugelCode mit collider und alert drin haben"** — I had to restore everything.
   - Pre-existing red tests (GroupProcessor.test.ts): fixture SBS1044PRA.json never had CS_LINKS/CS_RECHTS. Fixed by rebuilding tests onto real PMI names.
   - DefaultCommandProcessor detach test was pre-existing red (processor built without docking rules + obsolete CS_LINKS/CS_RECHTS). Fixed by passing DOCKING_RULES and real PMI names to chain().
   - Directional docking analysis: I proposed "mirror frames" and "per-end occupancy" theories, both rejected by user. User clarified: "3 CS_EIN_L on OUT_R_0/90/270 is fine, but NO CS_EIN_R on OUT_R (0 -> GAR KEINER)" and later "EIN EIN_R DARF NICHT AUF EIN OUT_L". Diagnostic dump proved our payload IS directional (EIN_L→OUT_R only). This analysis was inconclusive/pending.
   - console.error vs console.log: I removed console.error from eventData.ts; user said "ICH HATTE GESAGT logs NICHT errors" — restored the console.error calls.

5. Problem Solving:
   Solved: group-dissolution rotation preservation; test fixture drift; docking rules externalization; ID-whitelist placement; instantiate/reidentify consolidation; GraphExport null bug; connection verification (4 of 5 elements connected via group node gk-70af55c6). Ongoing/current: green sphere markers rendering — established via browser console that only glb (sceneGraph_reference) renders in this @ad/fire build; just switched to CAP4040.glb marker. NOT yet verified in browser whether the glb marker now renders and clicks work.

6. All user messages:
   - "Kannst due dir mal den Produktkatalog anschauen? derr Rolleförderer wird nicht korrekt visualisiert und wirft einen Fehler"
   - "Mein also müssen wir das erst mal durchstrukturieren"
   - "Schau mal in die Rolleförderer Assets / Gintv es Kandidaten für die Einträge im Katalog?"
   - "lönntest du mal die 6RST Teile verfügbar machen?"
   - "Mach mal alles rückgängig, das müssen wir prüfen da geht gerade zu viel kaputt"
   - "hast du Dinge gelöscht?"
   - "gut -- dann lass, wie es ist / zwei andere Dinge / Wenn ich platzieren wähle, wird das Element via ausgewähltem PMI an der Maus positioniert / Woher kommt die Positionierung?"
   - "ok -- das ist allse fire Visual <-> NICHT UNSER CODE von uns nicht zu ändern?"
   - "das ist eine Kopie von deren Code, die wir zum reverse engineering nutzen"
   - "ok -- dann etwas anderes / Check mal den Code, wenn eine Gruppe aufgelöst wird --> da wird manchmal wird / werden die Elemente dann gedreht / wann passiert das genau?"
   - "ok -- können wir es so machen, dass die drehung beibehalten wird?"
   - "kannst du dann die Tests fixen?"
   - "NEIN / es gibt eine neue namens logik, die haben wir gestern implementiert / schau dir die Platzierungslogik an --> Test auf diese Platzierungslogik umbauen ggf. ergänzen"
   - "commit und push"
   - "der Kipp- Fix iost drinn?"
   - "stand git?"
   - "Wo werden die JSONS für Drag / drop verarbeitet?"
   - "HALLLOOOOOOOO / ICH REDE VON DEN PMIs AUS UNSEREM PROJEKT"
   - "Wie sind denn die Drag Points implementiert?" (later, re-asked after context)
   - "Wer list wann die JSONS und wo liegen die"
   - "Ich sehe die JSONS in Products nicht"
   - "KANNST DU DEUTSCH REDEN UND DIE FRAGE BEANTWORTEN / WO ISAT / SIND DIE JASON- DATEIEN, IN DENNEN DIE DRAG AN DROP EIGENSCHAFTEN BESCHRIUEBEN SIND?="
   - "VÖLLIHER UNSINN / WENN DAS SO IST, HAST DU RIESEN SCHEISS GEBAUT"
   - "// CLASS: Allround Safety Fence Posts... [pasted docking rules with colon notation] ... ich hatte die gesgern DIESE datei gegenben / wo liegen diese Informatiuonen???????????"
   - "Das stimm doch gar niocht [pasted product JSON] ... wo sind hier die Klassen zum ein und ausbau gestzt?"
   - "das heißt man setzt den Klassenamen über tags?"
   - "ok -- die bauteile (PMIs= sind gerichtet :: ein ...EIN_L PMI kann nur auf einen OUT_R-... eingebaut werden / Im Szenegrph funktioniert das nicht / die docking rules sehen richtig aus / z.B. [pasted rules] / Kannst Du analysieren?"
   - "NEIN / es ist völlig in Ordnung wenn 3 C_EIN_L auf die UOT_R_0 / 90 / 270 snappen -> an das rechte Ende passen 3 Element (gerade / vor / hinten) aber eben 3 C_EIN_L Es darf KEIN C_EIN_R darauf kommen (0 -> GAR KEINER)"
   - "NEIN / DAS IOST NICHT DAS PORBLEM UND FALSCH DAZU / EIN EIN_R DARF NICHT AUF EIN OUT_L / kannst du mal ausgeben, was wir an den Grapen übergfeben, wenn wir den PlazierungsMode starten?"
   - "NEIN / einfach loggen ich schau im Broweser / Diagnode ggf. später / und logge bitte auch die Root-Id der geplacten Elemente / UND Nimm bitte alles andedren console logs von uns raus"
   - "ICH HATTE GESAGT logs NICHT edrrors#"
   - "??? der Placement mode sollte doch nur gestartet werden, wenn rechte Maus -> Platzieren gewählt ist / derr kommt viel häufiger"
   - "l,ogs wieder raus, das hat alles gepasst"
   - "Der GraphExport war nicht sehrt erfolgreich, da steht nur nul drinn" (context: docking-rules.json was modified externally, keep it)
   - "[pasted large exported scene graph JSON] ... kannst du sehen, ob die vier Elemente tatsächlkich VERBUNDEN sind"
   - "ok - / Können wir Virtuelle Obkekte (z.B. kleine kreise) in den Graph einbauen?"
   - "Wie sind denn die Drag Points implementiert?"
   - "können wir das nutzen? / können wir auf click auf den SelectionPMIS rgrüne Kugeln / Ovale einbauen?"
   - "Ich brauche kein DragPoints ich brauche grüne Kugeln an den PMIs --> kannst Du kugeln onSelect eines Zaunelemenzts einbauen?"
   - "können wir ein click auf eine unserer kugeln abfangen?"
   - "tu das aml und gib einfach einen Alert, wenn icj klicke"
   - "Geht das auch in ECHT Rund mit RUNDEM Colider?"
   - "[pasted browser console with FeatureComponent deregister + unknown Component 'wi' + getBounds crash]"
   - "geh mal auf ein glb"
   - "[pasted browser console showing glbs load from rob-geko.hifassets.com but 'unknown Component wi' + getBounds crash still]"

7. Pending Tasks:
   - Verify (in browser, user checks) that the glb-based green marker now renders and clicking pops the dialog/alert.
   - Possibly still-open: directional docking "EIN_R must not go on OUT_R/OUT_L" analysis (inconclusive; our payload proven directional; likely a scene-graph data/geometry issue). User has not asked to resolve this further recently.
   - Nothing committed for the marker work (whitelist, instantiate refactor, GraphExport fix, and marker code all sit uncommitted in the working tree).

8. Current Work:
   Immediately before this summary, I switched the green sphere marker from a crashing renderer-component approach to a **glb-based marker** in `withSelectionMarkers` (DefaultCommandProcessor.ts), because the browser console proved that in this @ad/fire build only `sceneGraph_reference` (glb) renders — `component_renderer` and `component_lineRenderer` are both "unknown Component 'wi' was skipped", leaving geometry undefined and crashing collectBounds/getBounds on select. The new marker references `katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb` (confirmed loading in the log from `https://rob-geko.hifassets.com/fire/assets/...`), tinted green via `overrideMaterials: [{ _resourceClass: "material_standard", diffuse: [0, 1, 0] }]`, scaled [2,2,2], with a sphere collider (shapeType 2, radius 60) for clicking, plus the handleSelectionClickEvent alert intercept (dialog reaction). I just ran typecheck (clean, only pre-existing ProductBrowser.vue errors) and tests (`vitest run DefaultCommandProcessor` → 6 passed). Not committed.

9. Optional Next Step:
   Report to the user that the marker is now glb-based (CAP4040.glb, green, sphere collider, alert on click), typecheck clean and tests green, and ask them to check in the browser whether the green marker now renders and the click-dialog appears — since the last user instruction was "geh mal auf ein glb" and the browser is where rendering is verified. Do NOT proceed to further changes (e.g., a real sphere glb, or the directional analysis) without the user confirming the glb marker renders. Relevant recent quote establishing the task: user said "geh mal auf ein glb" and the browser console confirmed "the glbs load ... but every renderer-component is unknown 'wi'" — so the glb path is the only viable render approach, which I have now implemented and need the user to verify visually.

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


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


<script setup lang="ts">
import { ref, onMounted } from "vue";
import Fire3D from "./Fire3D.vue";
import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
import ProductEditorPanel from "../building-blocks/ProductEditorPanel.vue";

// Load config
const config = ref<Record<string, unknown>>({});
const fire3d = ref<InstanceType<typeof Fire3D> | null>(null);
const processor = new DefaultCommandProcessor();

// Sample product data
const productData = ref<Record<string, any>>({
    id: "12345",
    name: "Conveyor Roller",
    diameter: 50,
    length: 200,
    speed: 100,
    material: "stainless steel",
});

onMounted(async () => {
    // Load configuration
    const configResponse = await fetch("/assets/environment.config.json");
    config.value = await configResponse.json();

    // Load initial scene
    try {
        const sceneResponse = await fetch("/assets/scenes/rolle.json");
        const scene = await sceneResponse.json();
        await fire3d.value?.setScene(scene);
    } catch (error) {
        console.error("Failed to load scene:", error);
    }
});

// Handle save action
const handleSave = async (data: Record<string, any>) => {
    try {
        // Prepare the command payload with the format specified
        const commandPayload = {
            command: "updateProductConfiguration",
            productCategory: "conveyor_roller", // This could be dynamic based on the product
            variables: { ...data },
        };

        // In a real implementation, this would call your backend API
        // const response = await fetch('/api/execute-command', {
        //   method: 'POST',
        //   headers: { 'Content-Type': 'application/json' },
        //   body: JSON.stringify(commandPayload)
        // })
        //
        // const result = await response.json()

        // For demo purposes, we'll simulate a successful response
        const result = {
            success: true,
            productId: data.id,
            scene: null, // In a real app, this would contain the new scene data
        };

        if (result.success) {
            // If we got a new scene, update the visualization
            if (result.scene) {
                await fire3d.value?.setScene(result.scene);
            }

            alert("Product configuration saved!");
        } else {
            console.error("Failed to save product:", result);
            alert("Failed to save product configuration");
        }
    } catch (error) {
        console.error("Error saving product:", error);
        alert("Error saving product configuration");
    }
};
</script>

<template>
    <div class="demo-container">
        <Fire3D ref="fire3d" :config="config" :processor="processor">
            <template #editor>
                <ProductEditorPanel title="Roller Configuration" :product-data="productData" @save="handleSave" />
            </template>
        </Fire3D>
    </div>
</template>

<style scoped>
.demo-container {
    height: 100vh;
    width: 100vw;
}
</style>


// The placement event's selectors are absolute paths down to the PMI; we only need the
// globally unique part-instance handle id (the last `handle#<id>` segment) to bind the relation
// between the two parts (not the PMIs — moving a PMI doesn't move the part).
function lastHandleId(fullSelector: string | null | undefined): string | null {
    if (!fullSelector) return null;
    const handles = [...fullSelector.matchAll(/handle#([^.:\s>]+)/g)];
    return handles.length ? handles[handles.length - 1][1] : null;
}

function lastPmiId(fullSelector: string | null | undefined): string | null {
    if (!fullSelector) return null;
    const pmis = [...fullSelector.matchAll(/pmi#([^.:\s>]+)/g)];
    return pmis.length ? pmis[pmis.length - 1][1] : null;
}

export function extractPlacementData(event: any) {
    const entity = event.subject?.entity;
    return {
        // The subject is the moving group node; `position`/`rotation` are the snap target.
        handleId: entity?.id,
        position: event.position?.values,
        rotation: event.rotation?.values,
        // Which element/CS docked: the drag side belongs to the moving group, the drop side to the
        // target group. The drag CS (CS_LINKS/CS_RECHTS) fixes the side; the handle ids fix the
        // anchor (target end) and join (moving end) elements of the merge.
        dragHandleId: lastHandleId(event.dragPointSelector),
        dragPmiId: lastPmiId(event.dragPointSelector),
        dropHandleId: lastHandleId(event.dropPointSelector),
        dropPmiId: lastPmiId(event.dropPointSelector),
    };
}
export function extractMoveData(event: any) {
    const targets = event.subjects ?? [];
    const positions = event.positions ?? [];
    const delta = event.delta?.values;

    if (targets.length !== positions.length) {
        console.error("[extractMoveData] targets/positions length mismatch:", targets.length, positions.length);
        return [];
    }

    const result = [];
    for (let i = 0; i < targets.length; i++) {
        const entity = targets[i]?.entity;
        result.push({
            // The selected subject is the group node itself.
            handleId: entity?.id ?? entity?.parent?.id,
            delta,
            position: positions[i]?.values,
        });
    }
    return result;
}
export function extractRotateData(event: any) {
    const targets = event.subjects ?? [];
    // Rotation runs around the pivot, so it changes the part's position too — the mode provides
    // localRotations/localPositions; we record both (relative to the part's parent).
    const rotations = event.localRotations ?? [];
    const positions = event.localPositions ?? [];
    const axis = event.axis?.values;
    const deltaAngle = event.deltaAngle;

    if (targets.length !== rotations.length) {
        console.error("[extractRotateData] targets/rotations length mismatch:", targets.length, rotations.length);
        return [];
    }

    const result = [];
    for (let i = 0; i < targets.length; i++) {
        const entity = targets[i]?.entity;
        result.push({
            // The selected subject is the group node itself.
            handleId: entity?.id ?? entity?.parent?.id,
            axis,
            deltaAngle,
            rotation: rotations[i]?.values,
            position: positions[i]?.values,
        });
    }
    return result;
}


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


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

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

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

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

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

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

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


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

Keine offenen Punkte.

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


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

Keine offenen Punkte.


Ich hole mir den aktuellen Marker-Code und die Geometry-Variante aus der History.


Bash

Find marker-related code in DefaultCommandProcessor


(Bash completed with no output)


Find marker code with absolute path


grep -rn "withSelectionMarkers\|sel-marker" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/ ; echo "---files---"; ls /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/

Search events dir and list files


---files---
CommandProcessor.ts
DefaultCommandProcessor.test.ts
DefaultCommandProcessor.ts
EventStore.ts
eventData.ts


Find selection handling in processor


wc -l /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts; echo "---"; grep -cn "Selection\|selection" /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts

Line count and selection matches


     607 /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/events/DefaultCommandProcessor.ts
---


1	import type { UIReaction, MenuEntry } from "../types";
2	import { CommandProcessor } from "./CommandProcessor";
3	import { EventStore } from "./EventStore";
4	import { GroupProcessor, ROLE, type GroupEvent } from "../model/GroupProcessor";
5	import { extractMoveData, extractRotateData, extractPlacementData } from "./eventData";
6	import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";
7
8	// Client-supplied placement rules, keyed by the moving part's docking class:
9	//   { movingClass: { targetClass: { dragPointName: dropPointName[] } } }
10	// Classes match the `class-…` tags on part root handles (e.g. "schutzzaun_allround-steher").
11	export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;
12
13	export class DefaultCommandProcessor extends CommandProcessor {
14	    private eventStore = new EventStore();
15	    private groups = new GroupProcessor();
16	    private freeCursorX = 0;
17	    // Current selection as a set of picks. Each pick is a picked geometry plus its escalation level
18	    // (part → assembly → …). A plain click sets a single pick (re-clicking the same one escalates);
19	    // an additive click (multi-select) adds another pick, or toggles it off if already present.
20	    private selection: { pickedId: string; level: number }[] = [];
21	    // A regroup recorded when a move/rotate starts but not yet committed: it is applied during the
22	    // interaction (so the engine sees the movable groups) and pushed together with the move/rotate as
23	    // one undo step on completion — or discarded on cancel. Keeps "break group to move" out of undo.
24	    private pendingRegroup: GroupEvent | null = null;
25
26	    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
27	    constructor(private readonly dockingRules?: DockingRules) {
28	        super();
29	    }
30
31	    setInitialScene(scene: any): void {
32	        this.eventStore.setInitialState(scene);
33	    }
34
35	    private replay(): any {
36	        const events = this.eventStore.getEventsToReplay();
37	        return this.groups.replay(
38	            this.eventStore.getInitialState(),
39	            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
40	        );
41	    }
42
43	    handleEvent(event: any): UIReaction[] {
44	        switch (event?.eventTypeName) {
45	            case "interaction-mode":
46	                if (event.lifecycleState !== "update") return [];
47	                return this.handleInteractionMode(event);
48	            case "add-product":
49	                return this.handleAddProduct(event);
50	            case "keyboard-build":
51	                return this.handleKeyboardBuild(event);
52	            case "detach-element":
53	                return this.handleDetachElement(event);
54	            case "escalate-selection":
55	                return this.handleEscalateSelection(event);
56	            case "prepare-action":
57	                return this.handlePrepareAction(event);
58	            case "undo":
59	                return this.handleUndo();
60	            case "redo":
61	                return this.handleRedo();
62	            case "esc":
63	                return this.handleEsc();
64	            case "context-click":
65	                return this.getContextForSelection(event);
66	            case "selection-click":
67	                return this.handleSelectionClickEvent(event);
68	            case "selection":
69	                return [];
70	            default:
71	                console.error("unknown event type ", event?.eventTypeName);
72	                return [];
73	        }
74	    }
75
76	    private handleEsc(): UIReaction[] {
77	        // Cancel a prepared-but-uncommitted regroup and resync the engine to the real model.
78	        if (this.pendingRegroup) {
79	            this.pendingRegroup = null;
80	            const scene = this.replay();
81	            return [
82	                { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
83	                { type: "sendCommand", cmd: "setScene", args: [scene] },
84	            ];
85	        }
86	        return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
87	    }
88
89	    private handleAddProduct(event: any): UIReaction[] {
90	        if (!event.handles?.length) return [];
91	        // A new product enters as a free group of one, offset along X by the running cursor so
92	        // products land adjacent and in view instead of overlapping.
93	        const position: Vec3 = [this.freeCursorX, 0, 0];
94	        this.freeCursorX += this.productExtent(event.handles);
95	        const createEvent = this.groups.buildCreateEvent({ handles: event.handles }, position);
96	        this.eventStore.push(createEvent);
97	        const scene = this.replay();
98	        return [
99	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
100	            { type: "sendCommand", cmd: "setScene", args: [scene] },
101	        ];
102	    }
103
104	    // PROTOTYPE — keyboard build shortcut ("+" held + arrow), fed from Fire3D. Works off the last
105	    // active selection taken from the store. right/left dock a COPY of the active part onto its open
106	    // CS_OUT_R_0 / CS_OUT_L_0 end (only if that end is still free); up/down nudge the active group
107	    // along Y. Docking PMIs are hardcoded for now — this is a thinking prop, not the final wiring.
108	    private handleKeyboardBuild(event: any): UIReaction[] {
109	        this.replay();
110	        const active = this.selection[this.selection.length - 1]?.pickedId;
111	        const activeRoot = active ? this.groups.elementRootOf(active) : null;
112	        const groupId = activeRoot ? this.groups.groupIdOfElement(activeRoot) : null;
113	        if (!active || !activeRoot || !groupId) return [];
114
115	        if (event.direction === "up" || event.direction === "down") {
116	            const t = this.groups.transformOf(groupId);
117	            if (!t) return [];
118	            const step = event.direction === "up" ? 100 : -100;
119	            this.eventStore.push({
120	                type: "group-move",
121	                timestamp: Date.now(),
122	                groupId,
123	                position: [t.pos[0], t.pos[1] + step, t.pos[2]],
124	                rotation: t.rot,
125	            } as GroupEvent);
126	            return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
127	        }
128
129	        // right / left: dock a copy of the active part onto its open end
130	        const anchorPmi = event.direction === "right" ? "CS_OUT_R_0" : "CS_OUT_L_0";
131	        const joinPmi = event.direction === "right" ? "CS_EIN_L" : "CS_EIN_R";
132	        if (this.groups.occupiedPmis().some((o) => o.element === activeRoot && o.pmi === anchorPmi)) {
133	            return []; // no room at the active part's chosen end
134	        }
135	        const scene = this.groups.sceneOfElement(activeRoot);
136	        if (!scene) return [];
137	        const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);
138	        this.eventStore.push(createEvent);
139	        this.eventStore.push({
140	            type: "group-merge",
141	            timestamp: Date.now(),
142	            targetGroupId: groupId,
143	            movingGroupId: createEvent.groupId,
144	            anchorRootId: activeRoot,
145	            joinRootId: createEvent.rootHandleId,
146	            anchorPmiId: anchorPmi,
147	            joinPmiId: joinPmi,
148	            rot: IDENTITY_QUAT,
149	        } as GroupEvent);
150	        // Chain: move the active selection onto the fresh copy so the next Shift+arrow docks onto it.
151	        this.selection = [{ pickedId: createEvent.rootHandleId, level: 0 }];
152	        return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
153	    }
154	    // Default click selects the single element; re-clicking the same spot escalates up the role
155	    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
156	    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
157	    private handleSelectionClickEvent(event: any): UIReaction[] {
158	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
159	        // Intercept a click on one of our PMI marker spheres — pop an alert with the hit PMI.
160	        const targetId: unknown = event.target?.id;
161	        if (typeof targetId === "string" && targetId.startsWith("sel-marker-")) {
162	            const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
163	            const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
164	            return [{ type: "dialog", title: "PMI-Marker", message: `Klick auf PMI ${pmi}\nElement ${element}` }];
165	        }
166	        const additive = event.selectionAction === "add";
167	        if (!event.target) {
168	            if (!additive) this.selection = [];
169	            return this.selectionReactions(stop);
170	        }
171	        this.replay();
172	        const pickedId = event.target.id;
173	        const chain = this.groups.selectableAncestors(pickedId);
174	        if (!chain.length) return [stop];
175
176	        if (additive) {
177	            const present = this.selection.some((p) => p.pickedId === pickedId);
178	            this.selection = present
179	                ? this.selection.filter((p) => p.pickedId !== pickedId)
180	                : [...this.selection, { pickedId, level: 0 }];
181	        } else {
182	            const sole = this.selection.length === 1 ? this.selection[0] : null;
183	            const level = sole && sole.pickedId === pickedId ? Math.min(sole.level + 1, chain.length - 1) : 0;
184	            this.selection = [{ pickedId, level }];
185	        }
186	        return this.selectionReactions(stop);
187	    }
188
189	    // Right-click escalation: only meaningful for a single selected pick; bump it one level.
190	    private handleEscalateSelection(event: any): UIReaction[] {
191	        this.replay();
192	        if (this.selection.length === 1 && this.selection[0].pickedId === event.pickedId) {
193	            const chain = this.groups.selectableAncestors(event.pickedId);
194	            this.selection[0].level = Math.min(this.selection[0].level + 1, chain.length - 1);
195	        }
196	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
197	        return this.selectionReactions(stop).slice(1); // no stop needed here, just the selection
198	    }
199
200	    // The node ids currently selected (each pick resolved to its escalated node).
201	    private selectedNodeIds(): string[] {
202	        return this.selection
203	            .map((p) => {
204	                const chain = this.groups.selectableAncestors(p.pickedId);
205	                return chain[Math.min(p.level, chain.length - 1)];
206	            })
207	            .filter((id): id is string => !!id);
208	    }
209
210	    private selectionReactions(stop: UIReaction): UIReaction[] {
211	        const scene = this.withSelectionMarkers(this.replay());
212	        const selectors = this.selectedNodeIds().map((id) => "#" + id);
213	        return [
214	            stop,
215	            { type: "sendCommand", cmd: "setScene", args: [scene] },
216	            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
217	        ];
218	    }
219
220	    // On selection, drop a small green sphere at every PMI of the selected element(s). The marker hangs
221	    // under the element root's transform (PMI positions are element-local), rendered via an inline
222	    // parametric sphere + basic material, with a round collider so it's clickable. Tagged
223	    // `name-select-marker`. No selection → scene unchanged.
224	    private withSelectionMarkers(scene: any): any {
225	        const roots = new Set(this.selectionElementRoots());
226	        if (!roots.size) return scene;
227	        const markers: any[] = [];
228	        for (const h of scene.handles ?? []) {
229	            if (!roots.has(h.id)) continue;
230	            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
231	            if (!tr?.key) continue;
232	            for (const c of h.components ?? []) {
233	                if (c._resourceClass !== "component_pmi") continue;
234	                const pos = c.properties?.position?.value ?? [0, 0, 0];
235	                markers.push({
236	                    id: `sel-marker-${h.id}-${c.id}`,
237	                    tags: ["name-select-marker"],
238	                    // The only render path this @ad/fire build supports for scene-graph handles: a glb via
239	                    // sceneGraph_reference (renderer/lineRenderer components are skipped as "unknown"). We
240	                    // reuse an existing small part glb as the marker blob, tinted green via overrideMaterials.
241	                    sceneGraph: { _resourceClass: "sceneGraph_reference", path: "katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb" },
242	                    overrideMaterials: [{ _resourceClass: "material_standard", diffuse: [0, 1, 0] }],
243	                    components: [
244	                        { _resourceClass: "component_transform", parentKey: tr.key, translation: pos, rotation: [0, 0, 0], scale: [2, 2, 2] },
245	                        // Round collider (shapeType 2 = sphere), triggersSelection defaults true → the raycast
246	                        // hits it and reports the marker as the click target.
247	                        { _resourceClass: "component_collider", type: 2, shapeType: 2, center: [0, 0, 0], radius: 60 },
248	                    ],
249	                });
250	            }
251	        }
252	        return { ...scene, handles: [...(scene.handles ?? []), ...markers] };
253	    }
254
255	    private handleInteractionMode(event: any): UIReaction[] {
256	        switch (event.modeName) {
257	            case "rotation":
258	                return this.handleRotateEnd(event);
259	            case "movement":
260	                return this.handleMoveEnd(event);
261	            case "placement":
262	                return this.handlePlacementEnd(event);
263	            default:
264	                console.error(`Unknown interaction mode: ${event.modeName}`);
265	                return [];
266	        }
267	    }
268
269	    // Move/rotate act on group nodes — the engine moves every member natively. All moved subjects are
270	    // recorded, together with the pending regroup (if any), as one undo step.
271	    private handleMoveEnd(event: any): UIReaction[] {
272	        const moves = extractMoveData(event);
273	        if (!moves.length) return [];
274	        this.replay();
275	        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
276	        for (const m of moves) {
277	            const groupId = this.resolveGroupId(m.handleId);
278	            if (!groupId) continue;
279	            group.push({
280	                type: "group-move",
281	                timestamp: Date.now(),
282	                groupId,
283	                position: m.position,
284	                rotation: this.groups.transformOf(groupId)?.rot ?? IDENTITY_QUAT,
285	            });
286	        }
287	        return this.commitInteraction(group);
288	    }
289
290	    private handleRotateEnd(event: any): UIReaction[] {
291	        const rotations = extractRotateData(event);
292	        if (!rotations.length) return [];
293	        this.replay();
294	        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
295	        for (const r of rotations) {
296	            const groupId = this.resolveGroupId(r.handleId);
297	            if (!groupId) continue;
298	            group.push({
299	                type: "group-rotate",
300	                timestamp: Date.now(),
301	                groupId,
302	                position: r.position,
303	                rotation: r.rotation,
304	            });
305	        }
306	        return this.commitInteraction(group);
307	    }
308
309	    // Push the interaction (regroup + per-subject transforms) as one undo step and clear the pending.
310	    private commitInteraction(group: GroupEvent[]): UIReaction[] {
311	        this.pendingRegroup = null;
312	        if (group.length) this.eventStore.pushGroup(group);
313	        const scene = this.replay();
314	        return [{ type: "sendCommand", cmd: "setScene", args: [scene] }];
315	    }
316
317	    // Start move/rotate strictly on the current selection: consolidate it into movable groups (break a
318	    // group only if a partial selection requires it), keep that regroup pending, select the resulting
319	    // group nodes and start the engine mode. Already-whole-group selections move without regrouping.
320	    private handlePrepareAction(event: any): UIReaction[] {
321	        this.pendingRegroup = null;
322	        this.replay();
323	        const elementRoots = this.selectionElementRoots();
324	        if (!elementRoots.length) return [];
325
326	        const { event: regroup, moveGroupIds } = this.groups.buildRegroupEvent(elementRoots);
327	        this.pendingRegroup = regroup;
328	        const scene = this.replay();
329
330	        const selectors = moveGroupIds
331	            .map((id) => this.groups.groupKeyOf(id))
332	            .filter((k): k is string => !!k)
333	            .map((k) => "#" + k);
334	        const startCmd = event.mode === "rotate" ? "start_rotate_mode" : "start_move_mode";
335	        return [
336	            { type: "sendCommand", cmd: "setScene", args: [scene] },
337	            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
338	            { type: "sendCommand", cmd: startCmd, args: [] },
339	        ];
340	    }
341
342	    // The element roots covered by the current selection: a selected part is itself, a selected group
343	    // node expands to all its members.
344	    private selectionElementRoots(): string[] {
345	        const roots = new Set<string>();
346	        for (const node of this.selectedNodeIds()) {
347	            if (this.groups.roleOfId(node) === ROLE.assembly) {
348	                for (const m of this.groups.membersOfKey(node)) roots.add(m);
349	            } else {
350	                roots.add(node);
351	            }
352	        }
353	        return [...roots];
354	    }
355
356	    private handlePlacementEnd(event: any): UIReaction[] {
357	        const data = extractPlacementData(event);
358	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
359	        this.replay();
360
361	        // The subject is always a top-level group node (an element to re-place is detached into its
362	        // own group first — see handleDetachElement — so the engine never gets a nested subject).
363	        const movingGroupId = this.resolveGroupId(data.handleId);
364	        if (!movingGroupId) return [stop];
365	        const placement = this.placementEvent(data, movingGroupId);
366	        if (placement) this.eventStore.push(placement);
367
368	        const scene = this.replay();
369	        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
370	    }
371
372	    // Pull a part out of its group into its own top-level group, then start placement on that group's
373	    // node (a valid top-level subject). The freed neighbour points are open again post-detach.
374	    private handleDetachElement(event: any): UIReaction[] {
375	        this.replay();
376	        const detach = this.groups.buildDetachEvent(event.elementId);
377	        if (!detach) return [];
378	        this.eventStore.push(detach);
379	        const scene = this.replay();
380
381	        const groupId = detach.newGroups.find((ng) => ng.members.includes(event.elementId))?.groupId;
382	        const groupKey = groupId ? this.groups.groupKeyOf(groupId) : null;
383	        const setScene: UIReaction = { type: "sendCommand", cmd: "setScene", args: [scene] };
384	        if (!groupKey) return [setScene];
385
386	        const subject = "#" + groupKey;
387	        const dragPoints = this.placementDragPoints(subject);
388	        const reactions: UIReaction[] = [
389	            setScene,
390	            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
391	        ];
392	        // No docking rule for this class → no drag points. fire's placement mode dereferences
393	        // dragPoints[0], so skip start_placement_mode entirely instead of handing it an empty array.
394	        if (dragPoints.length > 0) {
395	            reactions.push({
396	                type: "sendCommand",
397	                cmd: "start_placement_mode",
398	                args: [{ subject, allowAllDragPoints: true, dragPoints }],
399	            });
400	        }
401	        return reactions;
402	    }
403
404	    // The merge (on snap) or move (free) event for a moving group.
405	    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {
406	        if (data.dropHandleId) {
407	            const anchorRoot = this.groups.elementRootOf(data.dropHandleId);
408	            const joinRoot = data.dragHandleId ? this.groups.elementRootOf(data.dragHandleId) : null;
409	            const targetGroupId = anchorRoot ? this.groups.groupIdOfElement(anchorRoot) : null;
410	            if (anchorRoot && joinRoot && targetGroupId && targetGroupId !== movingGroupId && data.dropPmiId && data.dragPmiId) {
411	                // The snapped points fix the join: the dragged point is placed onto the drop point.
412	                // Direction (any axis) follows from the point positions; the relative rotation (e.g. a
413	                // 90° branch onto a post) follows from the points' frame orientations.
414	                return {
415	                    type: "group-merge",
416	                    timestamp: Date.now(),
417	                    targetGroupId,
418	                    movingGroupId,
419	                    anchorRootId: anchorRoot,
420	                    joinRootId: joinRoot,
421	                    anchorPmiId: data.dropPmiId,
422	                    joinPmiId: data.dragPmiId,
423	                    rot: this.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),
424	                };
425	            }
426	            return null;
427	        }
428	        // No snap: free reposition of the group to the drop position.
429	        return {
430	            type: "group-move",
431	            timestamp: Date.now(),
432	            groupId: movingGroupId,
433	            position: data.position,
434	            rotation: this.groups.transformOf(movingGroupId)?.rot ?? IDENTITY_QUAT,
435	        };
436	    }
437
438	    private handleUndo(): UIReaction[] {
439	        if (!this.eventStore.canUndo) return [];
440	        this.eventStore.undo();
441	        const scene = this.replay();
442	        return [
443	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
444	            { type: "sendCommand", cmd: "setScene", args: [scene] },
445	            { type: "notification", message: "Action reverted" },
446	        ];
447	    }
448
449	    private handleRedo(): UIReaction[] {
450	        if (!this.eventStore.canRedo) return [];
451	        this.eventStore.redo();
452	        const scene = this.replay();
453	        return [
454	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
455	            { type: "sendCommand", cmd: "setScene", args: [scene] },
456	            { type: "notification", message: "Action reapplied" },
457	        ];
458	    }
459
460	    private getContextForSelection(event: any): UIReaction[] {
461	        if (!event.pointed?.length) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
462	        this.replay();
463	        const pickedId = event.pointed[0].entity.id;
464	        const groupSelector = this.groupSelectorOf(pickedId);
465	        if (!groupSelector) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
466
467	        // The single picked element (for "re-place this element", as opposed to the whole group).
468	        const elementRoot = this.groups.elementRootOf(pickedId);
469
470	        // Right-click on something already in the selection keeps the whole set (so it doesn't drop a
471	        // multi-select or an escalation); a fresh target selects just that element. The escalate entry
472	        // only shows for a single-pick selection that can still go higher.
473	        const chain = this.groups.selectableAncestors(pickedId);
474	        if (!this.selection.some((p) => p.pickedId === pickedId)) this.selection = [{ pickedId, level: 0 }];
475	        const level = this.selection.find((p) => p.pickedId === pickedId)?.level ?? 0;
476	        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
477	        const canEscalate = this.selection.length === 1 && level < chain.length - 1;
478	        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode
479	        // dereferences dragPoints[0] and would crash on an empty array).
480	        const dragPoints = this.placementDragPoints(groupSelector);
481
482	        return [
483	            { type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },
484	            {
485	                type: "contextMenu",
486	                entries: [
487	                    ...(canEscalate
488	                        ? ([
489	                              {
490	                                  label: "Übergeordnete Gruppe wählen",
491	                                  reactions: [{ type: "dispatchEvent", event: { eventTypeName: "escalate-selection", pickedId } }],
492	                              },
493	                          ] as MenuEntry[])
494	                        : []),
495	                    {
496	                        label: "Move",
497	                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "move" } }],
498	                    },
499	                    {
500	                        label: "Rotate",
501	                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "rotate" } }],
502	                    },
503	                    ...(dragPoints.length > 0
504	                        ? ([
505	                              {
506	                                  label: "Platzieren",
507	                                  reactions: [
508	                                      {
509	                                          type: "sendCommand",
510	                                          cmd: "start_placement_mode",
511	                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],
512	                                      },
513	                                  ],
514	                              },
515	                          ] as MenuEntry[])
516	                        : []),
517	                    ...(elementRoot
518	                        ? ([
519	                              {
520	                                  label: "Element neu platzieren",
521	                                  // Detach runs in the processor (records the split), then it starts
522	                                  // placement on the freed element's own top-level group node.
523	                                  reactions: [
524	                                      { type: "dispatchEvent", event: { eventTypeName: "detach-element", elementId: elementRoot } },
525	                                  ],
526	                              },
527	                          ] as MenuEntry[])
528	                        : []),
529	                ],
530	            },
531	        ];
532	    }
533
534	    // A picked handle (part geometry or group node) → "#<groupKey>" of the owning group node.
535	    private groupSelectorOf(handleId: string): string | null {
536	        const groupId = this.resolveGroupId(handleId);
537	        if (!groupId) return null;
538	        const key = this.groups.groupKeyOf(groupId);
539	        return key ? "#" + key : null;
540	    }
541
542	    // Docking class of a "#<groupKey>" subject, from the element root's `class-…` tag. Null when the
543	    // group carries no class — then there are no rules and the part places freely without snapping.
544	    private classOf(subject: string): string | null {
545	        const key = subject.startsWith("#") ? subject.slice(1) : subject;
546	        const groupId = this.groups.groupByKey(key);
547	        return groupId ? this.groups.classOfGroup(groupId) : null;
548	    }
549
550	    // Placement drag/drop wiring from the client-supplied docking rules (root-level points only). The
551	    // moving part's class selects its rule block; per target class, each drag point (CS_EIN_*) maps to
552	    // the drop points (CS_OUT_*) it may snap onto. The class only *finds* the candidate elements in the
553	    // model — the emitted drop selectors then key on the concrete element id (`#<id> .name-<dp>`), a
554	    // whitelist of the currently OPEN points. This keys on the (universal) id instead of excluding
555	    // occupied points by id, so a shared/edge case can only under-offer, never wrongly block a sibling.
556	    // The subject's own members and every occupied point are left out. No rule → no drag points → free.
557	    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
558	        const klass = this.classOf(subject);
559	        const rules = klass ? this.dockingRules?.[klass] : undefined;
560	        if (!rules) return [];
561
562	        const occupied = new Set(this.groups.occupiedPmis().map((o) => `${o.element}${o.pmi}`));
563	        const ownKey = subject.startsWith("#") ? subject.slice(1) : subject;
564	        const ownMembers = new Set(this.groups.membersOfKey(ownKey));
565
566	        // Per drag point on the subject, collect the concrete open drop points across every target class.
567	        const byDrag = new Map<string, string[]>();
568	        for (const [targetClass, dragMap] of Object.entries(rules)) {
569	            const targets = this.groups.elementsOfClass(targetClass).filter((el) => !ownMembers.has(el));
570	            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
571	                const open = byDrag.get(dragPoint) ?? [];
572	                for (const el of targets) {
573	                    for (const dp of dropPoints) {
574	                        if (!occupied.has(`${el}${dp}`)) open.push(`#${el} .name-${dp}`);
575	                    }
576	                }
577	                byDrag.set(dragPoint, open);
578	            }
579	        }
580	        return [...byDrag].map(([dragPoint, dropPoints]) => ({
581	            selector: `.name-${dragPoint}`,
582	            dropPoints,
583	        }));
584	    }
585
586	    private resolveGroupId(handleId: string): string | null {
587	        const byKey = this.groups.groupByKey(handleId);
588	        if (byKey) return byKey;
589	        const root = this.groups.elementRootOf(handleId);
590	        return root ? this.groups.groupIdOfElement(root) : null;
591	    }
592
593	    // Extent of a product from its docking points (domain-agnostic): the largest span across the
594	    // axes, used as the free-placement step so products don't pile up on each other.
595	    private productExtent(handles: any[]): number {
596	        const root = handles.find((h) =>
597	            h.components?.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),
598	        );
599	        const pts: number[][] = [];
600	        for (const c of root?.components ?? []) {
601	            if (c._resourceClass === "component_pmi") pts.push(c.properties.position.value);
602	        }
603	        if (!pts.length) return 0;
604	        const span = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
605	        return Math.max(span(0), span(1), span(2));
606	    }
607	}
608

import type { UIReaction, MenuEntry } from "../types";
import { CommandProcessor } from "./CommandProcessor";
import { EventStore } from "./EventStore";
import { GroupProcessor, ROLE, type GroupEvent } from "../model/GroupProcessor";
import { extractMoveData, extractRotateData, extractPlacementData } from "./eventData";
import { IDENTITY_QUAT, type Vec3 } from "../model/transformMath";

// Client-supplied placement rules, keyed by the moving part's docking class:
//   { movingClass: { targetClass: { dragPointName: dropPointName[] } } }
// Classes match the `class-…` tags on part root handles (e.g. "schutzzaun_allround-steher").
export type DockingRules = Record<string, Record<string, Record<string, string[]>>>;

export class DefaultCommandProcessor extends CommandProcessor {
    private eventStore = new EventStore();
    private groups = new GroupProcessor();
    private freeCursorX = 0;
    // Current selection as a set of picks. Each pick is a picked geometry plus its escalation level
    // (part → assembly → …). A plain click sets a single pick (re-clicking the same one escalates);
    // an additive click (multi-select) adds another pick, or toggles it off if already present.
    private selection: { pickedId: string; level: number }[] = [];
    // A regroup recorded when a move/rotate starts but not yet committed: it is applied during the
    // interaction (so the engine sees the movable groups) and pushed together with the move/rotate as
    // one undo step on completion — or discarded on cancel. Keeps "break group to move" out of undo.
    private pendingRegroup: GroupEvent | null = null;

    // dockingRules come from the cpq client (e.g. geko) — cpq itself is domain-agnostic.
    constructor(private readonly dockingRules?: DockingRules) {
        super();
    }

    setInitialScene(scene: any): void {
        this.eventStore.setInitialState(scene);
    }

    private replay(): any {
        const events = this.eventStore.getEventsToReplay();
        return this.groups.replay(
            this.eventStore.getInitialState(),
            this.pendingRegroup ? [...events, this.pendingRegroup] : events,
        );
    }

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

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

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

    // PROTOTYPE — keyboard build shortcut ("+" held + arrow), fed from Fire3D. Works off the last
    // active selection taken from the store. right/left dock a COPY of the active part onto its open
    // CS_OUT_R_0 / CS_OUT_L_0 end (only if that end is still free); up/down nudge the active group
    // along Y. Docking PMIs are hardcoded for now — this is a thinking prop, not the final wiring.
    private handleKeyboardBuild(event: any): UIReaction[] {
        this.replay();
        const active = this.selection[this.selection.length - 1]?.pickedId;
        const activeRoot = active ? this.groups.elementRootOf(active) : null;
        const groupId = activeRoot ? this.groups.groupIdOfElement(activeRoot) : null;
        if (!active || !activeRoot || !groupId) return [];

        if (event.direction === "up" || event.direction === "down") {
            const t = this.groups.transformOf(groupId);
            if (!t) return [];
            const step = event.direction === "up" ? 100 : -100;
            this.eventStore.push({
                type: "group-move",
                timestamp: Date.now(),
                groupId,
                position: [t.pos[0], t.pos[1] + step, t.pos[2]],
                rotation: t.rot,
            } as GroupEvent);
            return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
        }

        // right / left: dock a copy of the active part onto its open end
        const anchorPmi = event.direction === "right" ? "CS_OUT_R_0" : "CS_OUT_L_0";
        const joinPmi = event.direction === "right" ? "CS_EIN_L" : "CS_EIN_R";
        if (this.groups.occupiedPmis().some((o) => o.element === activeRoot && o.pmi === anchorPmi)) {
            return []; // no room at the active part's chosen end
        }
        const scene = this.groups.sceneOfElement(activeRoot);
        if (!scene) return [];
        const createEvent = this.groups.buildCreateEvent(scene as never, [0, 0, 0]);
        this.eventStore.push(createEvent);
        this.eventStore.push({
            type: "group-merge",
            timestamp: Date.now(),
            targetGroupId: groupId,
            movingGroupId: createEvent.groupId,
            anchorRootId: activeRoot,
            joinRootId: createEvent.rootHandleId,
            anchorPmiId: anchorPmi,
            joinPmiId: joinPmi,
            rot: IDENTITY_QUAT,
        } as GroupEvent);
        // Chain: move the active selection onto the fresh copy so the next Shift+arrow docks onto it.
        this.selection = [{ pickedId: createEvent.rootHandleId, level: 0 }];
        return [{ type: "sendCommand", cmd: "setScene", args: [this.replay()] }];
    }
    // Default click selects the single element; re-clicking the same spot escalates up the role
    // hierarchy (element → group → …). An additive click (selectionAction "add") adds another pick to
    // the set, or toggles it off if already in. fire is driven with the full set via set_selection.
    private handleSelectionClickEvent(event: any): UIReaction[] {
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        // Intercept a click on one of our PMI marker spheres — pop an alert with the hit PMI.
        const targetId: unknown = event.target?.id;
        if (typeof targetId === "string" && targetId.startsWith("sel-marker-")) {
            const pmi = targetId.slice(targetId.lastIndexOf("-") + 1);
            const element = targetId.slice("sel-marker-".length, targetId.lastIndexOf("-"));
            return [{ type: "dialog", title: "PMI-Marker", message: `Klick auf PMI ${pmi}\nElement ${element}` }];
        }
        const additive = event.selectionAction === "add";
        if (!event.target) {
            if (!additive) this.selection = [];
            return this.selectionReactions(stop);
        }
        this.replay();
        const pickedId = event.target.id;
        const chain = this.groups.selectableAncestors(pickedId);
        if (!chain.length) return [stop];

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

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

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

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

    // On selection, drop a small green sphere at every PMI of the selected element(s). The marker hangs
    // under the element root's transform (PMI positions are element-local), rendered via an inline
    // parametric sphere + basic material, with a round collider so it's clickable. Tagged
    // `name-select-marker`. No selection → scene unchanged.
    private withSelectionMarkers(scene: any): any {
        const roots = new Set(this.selectionElementRoots());
        if (!roots.size) return scene;
        const markers: any[] = [];
        for (const h of scene.handles ?? []) {
            if (!roots.has(h.id)) continue;
            const tr = h.components?.find((c: any) => c._resourceClass === "component_transform");
            if (!tr?.key) continue;
            for (const c of h.components ?? []) {
                if (c._resourceClass !== "component_pmi") continue;
                const pos = c.properties?.position?.value ?? [0, 0, 0];
                markers.push({
                    id: `sel-marker-${h.id}-${c.id}`,
                    tags: ["name-select-marker"],
                    // The only render path this @ad/fire build supports for scene-graph handles: a glb via
                    // sceneGraph_reference (renderer/lineRenderer components are skipped as "unknown"). We
                    // reuse an existing small part glb as the marker blob, tinted green via overrideMaterials.
                    sceneGraph: { _resourceClass: "sceneGraph_reference", path: "katalog/schutzzaun_basic/SBS1044SRA/CAP4040.glb" },
                    overrideMaterials: [{ _resourceClass: "material_standard", diffuse: [0, 1, 0] }],
                    components: [
                        { _resourceClass: "component_transform", parentKey: tr.key, translation: pos, rotation: [0, 0, 0], scale: [2, 2, 2] },
                        // Round collider (shapeType 2 = sphere), triggersSelection defaults true → the raycast
                        // hits it and reports the marker as the click target.
                        { _resourceClass: "component_collider", type: 2, shapeType: 2, center: [0, 0, 0], radius: 60 },
                    ],
                });
            }
        }
        return { ...scene, handles: [...(scene.handles ?? []), ...markers] };
    }

    private handleInteractionMode(event: any): UIReaction[] {
        switch (event.modeName) {
            case "rotation":
                return this.handleRotateEnd(event);
            case "movement":
                return this.handleMoveEnd(event);
            case "placement":
                return this.handlePlacementEnd(event);
            default:
                console.error(`Unknown interaction mode: ${event.modeName}`);
                return [];
        }
    }

    // Move/rotate act on group nodes — the engine moves every member natively. All moved subjects are
    // recorded, together with the pending regroup (if any), as one undo step.
    private handleMoveEnd(event: any): UIReaction[] {
        const moves = extractMoveData(event);
        if (!moves.length) return [];
        this.replay();
        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
        for (const m of moves) {
            const groupId = this.resolveGroupId(m.handleId);
            if (!groupId) continue;
            group.push({
                type: "group-move",
                timestamp: Date.now(),
                groupId,
                position: m.position,
                rotation: this.groups.transformOf(groupId)?.rot ?? IDENTITY_QUAT,
            });
        }
        return this.commitInteraction(group);
    }

    private handleRotateEnd(event: any): UIReaction[] {
        const rotations = extractRotateData(event);
        if (!rotations.length) return [];
        this.replay();
        const group: GroupEvent[] = this.pendingRegroup ? [this.pendingRegroup] : [];
        for (const r of rotations) {
            const groupId = this.resolveGroupId(r.handleId);
            if (!groupId) continue;
            group.push({
                type: "group-rotate",
                timestamp: Date.now(),
                groupId,
                position: r.position,
                rotation: r.rotation,
            });
        }
        return this.commitInteraction(group);
    }

    // Push the interaction (regroup + per-subject transforms) as one undo step and clear the pending.
    private commitInteraction(group: GroupEvent[]): UIReaction[] {
        this.pendingRegroup = null;
        if (group.length) this.eventStore.pushGroup(group);
        const scene = this.replay();
        return [{ type: "sendCommand", cmd: "setScene", args: [scene] }];
    }

    // Start move/rotate strictly on the current selection: consolidate it into movable groups (break a
    // group only if a partial selection requires it), keep that regroup pending, select the resulting
    // group nodes and start the engine mode. Already-whole-group selections move without regrouping.
    private handlePrepareAction(event: any): UIReaction[] {
        this.pendingRegroup = null;
        this.replay();
        const elementRoots = this.selectionElementRoots();
        if (!elementRoots.length) return [];

        const { event: regroup, moveGroupIds } = this.groups.buildRegroupEvent(elementRoots);
        this.pendingRegroup = regroup;
        const scene = this.replay();

        const selectors = moveGroupIds
            .map((id) => this.groups.groupKeyOf(id))
            .filter((k): k is string => !!k)
            .map((k) => "#" + k);
        const startCmd = event.mode === "rotate" ? "start_rotate_mode" : "start_move_mode";
        return [
            { type: "sendCommand", cmd: "setScene", args: [scene] },
            { type: "sendCommand", cmd: "set_selection", args: [selectors] },
            { type: "sendCommand", cmd: startCmd, args: [] },
        ];
    }

    // The element roots covered by the current selection: a selected part is itself, a selected group
    // node expands to all its members.
    private selectionElementRoots(): string[] {
        const roots = new Set<string>();
        for (const node of this.selectedNodeIds()) {
            if (this.groups.roleOfId(node) === ROLE.assembly) {
                for (const m of this.groups.membersOfKey(node)) roots.add(m);
            } else {
                roots.add(node);
            }
        }
        return [...roots];
    }

    private handlePlacementEnd(event: any): UIReaction[] {
        const data = extractPlacementData(event);
        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
        this.replay();

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

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

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

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

        const subject = "#" + groupKey;
        const dragPoints = this.placementDragPoints(subject);
        const reactions: UIReaction[] = [
            setScene,
            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
        ];
        // No docking rule for this class → no drag points. fire's placement mode dereferences
        // dragPoints[0], so skip start_placement_mode entirely instead of handing it an empty array.
        if (dragPoints.length > 0) {
            reactions.push({
                type: "sendCommand",
                cmd: "start_placement_mode",
                args: [{ subject, allowAllDragPoints: true, dragPoints }],
            });
        }
        return reactions;
    }

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

    private handleUndo(): UIReaction[] {
        if (!this.eventStore.canUndo) return [];
        this.eventStore.undo();
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
            { type: "notification", message: "Action reverted" },
        ];
    }

    private handleRedo(): UIReaction[] {
        if (!this.eventStore.canRedo) return [];
        this.eventStore.redo();
        const scene = this.replay();
        return [
            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
            { type: "sendCommand", cmd: "setScene", args: [scene] },
            { type: "notification", message: "Action reapplied" },
        ];
    }

    private getContextForSelection(event: any): UIReaction[] {
        if (!event.pointed?.length) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];
        this.replay();
        const pickedId = event.pointed[0].entity.id;
        const groupSelector = this.groupSelectorOf(pickedId);
        if (!groupSelector) return [{ type: "sendCommand", cmd: "stop_interaction_mode", args: [] }];

        // The single picked element (for "re-place this element", as opposed to the whole group).
        const elementRoot = this.groups.elementRootOf(pickedId);

        // Right-click on something already in the selection keeps the whole set (so it doesn't drop a
        // multi-select or an escalation); a fresh target selects just that element. The escalate entry
        // only shows for a single-pick selection that can still go higher.
        const chain = this.groups.selectableAncestors(pickedId);
        if (!this.selection.some((p) => p.pickedId === pickedId)) this.selection = [{ pickedId, level: 0 }];
        const level = this.selection.find((p) => p.pickedId === pickedId)?.level ?? 0;
        const selectedSelectors = this.selectedNodeIds().map((id) => "#" + id);
        const canEscalate = this.selection.length === 1 && level < chain.length - 1;
        // No docking rule → no drag points → omit the "Platzieren" entry (fire's placement mode
        // dereferences dragPoints[0] and would crash on an empty array).
        const dragPoints = this.placementDragPoints(groupSelector);

        return [
            { type: "sendCommand", cmd: "set_selection", args: [selectedSelectors] },
            {
                type: "contextMenu",
                entries: [
                    ...(canEscalate
                        ? ([
                              {
                                  label: "Übergeordnete Gruppe wählen",
                                  reactions: [{ type: "dispatchEvent", event: { eventTypeName: "escalate-selection", pickedId } }],
                              },
                          ] as MenuEntry[])
                        : []),
                    {
                        label: "Move",
                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "move" } }],
                    },
                    {
                        label: "Rotate",
                        reactions: [{ type: "dispatchEvent", event: { eventTypeName: "prepare-action", mode: "rotate" } }],
                    },
                    ...(dragPoints.length > 0
                        ? ([
                              {
                                  label: "Platzieren",
                                  reactions: [
                                      {
                                          type: "sendCommand",
                                          cmd: "start_placement_mode",
                                          args: [{ subject: groupSelector, allowAllDragPoints: true, dragPoints }],
                                      },
                                  ],
                              },
                          ] as MenuEntry[])
                        : []),
                    ...(elementRoot
                        ? ([
                              {
                                  label: "Element neu platzieren",
                                  // Detach runs in the processor (records the split), then it starts
                                  // placement on the freed element's own top-level group node.
                                  reactions: [
                                      { type: "dispatchEvent", event: { eventTypeName: "detach-element", elementId: elementRoot } },
                                  ],
                              },
                          ] as MenuEntry[])
                        : []),
                ],
            },
        ];
    }

    // A picked handle (part geometry or group node) → "#<groupKey>" of the owning group node.
    private groupSelectorOf(handleId: string): string | null {
        const groupId = this.resolveGroupId(handleId);
        if (!groupId) return null;
        const key = this.groups.groupKeyOf(groupId);
        return key ? "#" + key : null;
    }

    // Docking class of a "#<groupKey>" subject, from the element root's `class-…` tag. Null when the
    // group carries no class — then there are no rules and the part places freely without snapping.
    private classOf(subject: string): string | null {
        const key = subject.startsWith("#") ? subject.slice(1) : subject;
        const groupId = this.groups.groupByKey(key);
        return groupId ? this.groups.classOfGroup(groupId) : null;
    }

    // Placement drag/drop wiring from the client-supplied docking rules (root-level points only). The
    // moving part's class selects its rule block; per target class, each drag point (CS_EIN_*) maps to
    // the drop points (CS_OUT_*) it may snap onto. The class only *finds* the candidate elements in the
    // model — the emitted drop selectors then key on the concrete element id (`#<id> .name-<dp>`), a
    // whitelist of the currently OPEN points. This keys on the (universal) id instead of excluding
    // occupied points by id, so a shared/edge case can only under-offer, never wrongly block a sibling.
    // The subject's own members and every occupied point are left out. No rule → no drag points → free.
    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
        const klass = this.classOf(subject);
        const rules = klass ? this.dockingRules?.[klass] : undefined;
        if (!rules) return [];

        const occupied = new Set(this.groups.occupiedPmis().map((o) => `${o.element}${o.pmi}`));
        const ownKey = subject.startsWith("#") ? subject.slice(1) : subject;
        const ownMembers = new Set(this.groups.membersOfKey(ownKey));

        // Per drag point on the subject, collect the concrete open drop points across every target class.
        const byDrag = new Map<string, string[]>();
        for (const [targetClass, dragMap] of Object.entries(rules)) {
            const targets = this.groups.elementsOfClass(targetClass).filter((el) => !ownMembers.has(el));
            for (const [dragPoint, dropPoints] of Object.entries(dragMap)) {
                const open = byDrag.get(dragPoint) ?? [];
                for (const el of targets) {
                    for (const dp of dropPoints) {
                        if (!occupied.has(`${el}${dp}`)) open.push(`#${el} .name-${dp}`);
                    }
                }
                byDrag.set(dragPoint, open);
            }
        }
        return [...byDrag].map(([dragPoint, dropPoints]) => ({
            selector: `.name-${dragPoint}`,
            dropPoints,
        }));
    }

    private resolveGroupId(handleId: string): string | null {
        const byKey = this.groups.groupByKey(handleId);
        if (byKey) return byKey;
        const root = this.groups.elementRootOf(handleId);
        return root ? this.groups.groupIdOfElement(root) : null;
    }

    // Extent of a product from its docking points (domain-agnostic): the largest span across the
    // axes, used as the free-placement step so products don't pile up on each other.
    private productExtent(handles: any[]): number {
        const root = handles.find((h) =>
            h.components?.some((c: any) => c._resourceClass === "component_transform" && !c.parentKey),
        );
        const pts: number[][] = [];
        for (const c of root?.components ?? []) {
            if (c._resourceClass === "component_pmi") pts.push(c.properties.position.value);
        }
        if (!pts.length) return 0;
        const span = (i: number) => Math.max(...pts.map((p) => p[i])) - Math.min(...pts.map((p) => p[i]));
        return Math.max(span(0), span(1), span(2));
    }
}


Der glb-Marker crasht weiter an `getBounds`. Du willst die Geometry-Variante — ich hole mir das genaue Schema aus einer vorhandenen Szene, damit die Felder exakt stimmen.


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
grep -rln "geometry_parametric\|component_lineRenderer\|component_renderer\|sphere-line\|sphere-3d\|material_basic\|geometry_line" .storybook src public 2>/dev/null | head -30

Find existing geometry/renderer schema usage


.storybook/server-root/fbxProcessor.worker.js
.storybook/server-root/glbProcessor.worker.js
.storybook/server-root/fbxProcessor.worker.js.map
.storybook/server-root/glbProcessor.worker.js.map
.storybook/fire/environment.config.json


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
python3 -c "
import json,re
f='.storybook/fire/environment.config.json'
s=open(f).read()
for m in ['geometry_parametric','component_lineRenderer','component_renderer','sphere','material_basic','geometry_line','component_geometry']:
    i=s.find(m)
    print(m, i)
"

Locate geometry keys in environment config


geometry_parametric 1841
component_lineRenderer 1638
component_renderer 6929
sphere 27214
material_basic 7305
geometry_line -1
component_geometry -1


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
python3 -c "
s=open('.storybook/fire/environment.config.json').read()
print(s[1400:2400])
print('=====SPHERE=====')
print(s[26900:27700])
print('=====RENDERER=====')
print(s[6800:7600])
"

Dump geometry, sphere and renderer schema regions


                  }
                ]
            },
            {
                "active": true,
                "tags": ["grid", "test"],
                "components": [
                    {
                        "_resourceClass": "component_lineRenderer",
                        "castShadow": false,
                        "receiveShadow": false,
                        "geometry": {
                            "_resourceClass": "geometry_parametric",
                            "parameters": {
                                "factoryName": "grid",
                                "unit": 100,
                                "size": 10000,
                                "normal": [0, 1, 0]
                            }
                        },
                        "material": {
                            "_resourceClass": "material_line",
                            "tags": ["test"],
                            "transparent": true,
                            "opacity": 0.
=====SPHERE=====
LineSegmentGeometryFactory"
                    },
                    {
                        "typeKey": "coneGeometryFactory"
                    },
                    {
                        "typeKey": "torusGeometryFactory"
                    },
                    {
                        "typeKey": "sphereGeometryFactory"
                    },
                    {
                        "typeKey": "prismGeometryFactory"
                    },
                    {
                        "typeKey": "prism2GeometryFactory"
                    },
                    {
                        "typeKey": "radialGeometryFactory"
                    },
                    {
                        "typeKey": "2DLineGeometryFactory"
                    },
                    {

=====RENDERER=====
otation": [0, 0.70710678, 0, 0.70710678]
                    },
                    {
                        "_resourceClass": "component_renderer",
                        "castShadow": false,
                        "receiveShadow": false,
                        "geometry": {
                            "_resourceClass": "geometry_reference",
                            "path": "arrow"
                        },
                        "material": {
                            "_resourceClass": "material_basic",
                            "diffuse": [1, 0, 0],
                            "transparent": true,
                            "alphaTest": 0,
                            "depthTest": false,
                            "opacity": 0.45
                        }


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
grep -o "sphereGeometryFactory[^}]*radius[^}]*" .storybook/server-root/glbProcessor.worker.js | head -3
echo "---params search---"
grep -oE "factoryName['\"]?\s*[:=]\s*['\"]sphere['\"][^;]{0,200}" .storybook/server-root/*.worker.js | head
echo "---sphere factory def---"
grep -oE ".{80}sphereGeometryFactory.{160}" .storybook/server-root/glbProcessor.worker.js | head -2

Find sphere factory parameter names


---params search---
---sphere factory def---


cd /Users/marcus.hinz/projects/adesso-cpq/packages/frontend
node -e '
const fs=require("fs");
for(const f of ["glbProcessor.worker.js","fbxProcessor.worker.js"]){
  const s=fs.readFileSync(".storybook/server-root/"+f,"utf8");
  const i=s.indexOf("sphereGeometry");
  if(i>=0){console.log("==="+f+" @"+i);console.log(s.slice(i-120,i+260));}
}
'

Inspect sphere geometry factory usage in worker


cd /Users/marcus.hinz/projects/adesso-cpq
FIRE=$(find . -path ./node_modules/.cache -prune -o -name "@ad_fire.js" -print 2>/dev/null | head -1)
echo "fire: $FIRE"
FIRE2=$(find packages -path "*@ad/fire*" -name "*.js" 2>/dev/null | head -5)
echo "$FIRE2"
ls -d node_modules/@ad/fire 2>/dev/null; find . -name "*.js" -path "*@ad*fire*" 2>/dev/null | head

Locate built @ad/fire engine file


cd /Users/marcus.hinz/projects/adesso-cpq
F=node_modules/.pnpm/@ad+fire@1.0.6/node_modules/@ad/fire/browser/index.js
node -e '
const fs=require("fs");
const s=fs.readFileSync(process.argv[1],"utf8");
const i=s.indexOf("sphereGeometry");
console.log("idx",i);
if(i>=0) console.log(s.slice(i-200,i+400));
' "$F"

Find sphere factory in fire browser build


cd /Users/marcus.hinz/projects/adesso-cpq
F=node_modules/.pnpm/@ad+fire@1.0.6/node_modules/@ad/fire/browser/index.js
node -e '
const fs=require("fs");const s=fs.readFileSync(process.argv[1],"utf8");
for(const term of ["ParametricGeometry","parametric","factoryName","MeshFactory.sphere","getTransformedSphere"]){
  const i=s.indexOf(term);
  console.log("=== "+term+" @"+i);
  if(i>=0) console.log(s.slice(i-80,i+220).replace(/\n/g," "));
}
' "$F"

Inspect parametric geometry factory internals


=== ParametricGeometry @506821
ute(e){return q.isType(e)}async deserializeSkeleton({geometry:e},t){if(!o.Model.ParametricGeometryModel.isType(e))return null;const n=this.getFactory(e.parameters);if(n){e={...n.defaultModel,...e};const r=await n.execute(e.parameters,t);return e.pivot&&this.transferPivot(r,o.Vector3Type.deserialize(
=== parametric @1041091
oid 0);class Ts extends xs{static isType(e){return"object"==typeof e&&"geometry_parametric"==e._resourceClass&&!!e.parameters?.factoryName}}class Es extends _s{}class Cs extends Es{static isType(e){return"object"==typeof e&&"material_line"==e._resourceClass}}class Rs extends Es{static isType(e){retu
=== factoryName @507426
ale(t)).negate())}getFactory(e){try{return this.entityFactoryManager.getAll(Y,e.factoryName).find(t=>t.canExecute(e))}catch(e){this.logger.debug((0,c.logMessage)(this,"getFactory",e.message))}}async onExecute(e,t){const n=await this.applyMappings(this.mappings,{input:e,execution:t},{});return await
=== MeshFactory.sphere @534507
is.elementManager.add(e,l.EditorLifetimeType.none);return this.sphereGeometry=c.MeshFactory.sphere(c.Vector3.create([.05,.05,.05])),t.addComponent(this.componentManager.create(l.LineRendererComponent,{geometry:this.geometry,material:this.elementManager.lineMaterial(this.materialColor,!0)})),t.addCom
=== getTransformedSphere @534798
,t.addComponent(this.componentManager.create(l.RendererComponent,{geometry:this.getTransformedSphere(e),materials:[this.elementManager.meshMaterial(c.Color.green)]})),t.components.forEach(e=>e.component.syncTarget.matrixAutoUpdate=!1),this.elements[e.index]=t,t.addCallbacks(e.registerSyncCallback(s.


cd /Users/marcus.hinz/projects/adesso-cpq
F=node_modules/.pnpm/@ad+fire@1.0.6/node_modules/@ad/fire/browser/index.js
node -e '
const fs=require("fs");const s=fs.readFileSync(process.argv[1],"utf8");
let idx=0,c=0;
while((idx=s.indexOf("radius",idx+1))>=0 && c<8){console.log("@"+idx+": "+s.slice(idx-90,idx+70).replace(/\n/g," "));c++;}
' "$F"

Find radius-based geometry params