1	# @fire-visual/relations
2
3	## Overview
4
5	The relations module provides a feature library to define constraints and relations between objects within a scene. It tracks transformation relationships between component pairs and emits events on changes, enabling dynamic object dependencies and constraints.
6
7	## Package Information
8
9	| Property | Value |
10	|----------|-------|
11	| **Name** | `@fire-visual/relations` |
12	| **Version** | `0.60.1-SNAPSHOT` |
13	| **Keywords** | 3d, 3d-engine, relations, constrains |
14	| **Package Manager** | yarn@4.10.2 |
15
16	### Dependencies
17
18	| Dependency | Version | Purpose |
19	|------------|---------|---------|
20	| @fire-visual/engine | workspace | Core engine |
21	| @fire-visual/di | workspace | Dependency injection |
22	| @fire-visual/math | workspace | Math utilities |
23	| @fire-visual/utils | workspace | Utility functions |
24
25	## Architecture
26
27	### Relations System
28
29	```
30	RelationsFeature
31	    |
32	    +-- RelationManager
33	    |       +-- Relation[]
34	    |               +-- Source component
35	    |               +-- Target component
36	    |               +-- Transformation tracking
37	    |
38	    +-- Events
39	    |       +-- CreateRelationEvent
40	    |       +-- DestroyRelationEvent
41	    |
42	    +-- Editor
43	            +-- RelationRenderer
44	```
45
46	### Relation Flow
47
48	1. Relations created between component pairs
49	2. RelationManager tracks all relations
50	3. Transformation changes detected per frame
51	4. Events fired on relation changes
52	5. Dependent objects updated automatically
53
54	## Key Classes & Interfaces
55
56	### RelationsFeature
57
58	Feature for creating and managing relations between entities.
59
60	| Property/Method | Type | Description |
61	|-----------------|------|-------------|
62	| `manager` | `RelationManager` | Relation registry |
63	| `createRelation()` | Method | Create new relation |
64	| `destroyRelation()` | Method | Remove relation |
65	| `getRelations()` | Method | Get relations for component |
66	| `getCommands()` | Method | Environment commands |
67
68	### Relation
69
70	Core relation class linking two components.
71
72	| Property/Method | Type | Description |
73	|-----------------|------|-------------|
74	| `source` | `Component` | Source component |
75	| `target` | `Component` | Target component |
76	| `type` | `RelationType` | Relation type |
77	| `transform` | `Matrix4_4` | Relative transformation |
78	| `active` | `boolean` | Relation active state |
79	| `update()` | Method | Update transformation |
80	| `destroy()` | Method | Destroy relation |
81
82	### RelationManager
83
84	Registry managing all relations in the scene.
85
86	| Property/Method | Type | Description |
87	|-----------------|------|-------------|
88	| `relations` | `Relation[]` | All relations |
89	| `register()` | Method | Register relation |
90	| `unregister()` | Method | Unregister relation |
91	| `getBySource()` | Method | Get relations by source |
92	| `getByTarget()` | Method | Get relations by target |
93	| `clear()` | Method | Clear all relations |
94
95	### RelationType
96
97	Relation type enumeration.
98
99	| Value | Description |
100	|-------|-------------|
101	| `parent` | Parent-child relationship |
102	| `sibling` | Sibling relationship |
103	| `reference` | Reference relationship |
104	| `constraint` | Constraint relationship |
105	| `custom` | Custom relationship |
106
107	## Events
108
109	### CreateRelationEvent
110
111	Event fired when relation is created.
112
113	| Property | Type | Description |
114	|----------|------|-------------|
115	| `relation` | `Relation` | Created relation |
116	| `source` | `Component` | Source component |
117	| `target` | `Component` | Target component |
118
119	### DestroyRelationEvent
120
121	Event fired when relation is destroyed.
122
123	| Property | Type | Description |
124	|----------|------|-------------|
125	| `relation` | `Relation` | Destroyed relation |
126	| `source` | `Component` | Source component |
127	| `target` | `Component` | Target component |
128
129	## Data Management
130
131	### RelationModel
132
133	Data model for relation serialization.
134
135	| Property | Type | Description |
136	|----------|------|-------------|
137	| `sourceSelector` | `string` | Source component selector |
138	| `targetSelector` | `string` | Target component selector |
139	| `type` | `string` | Relation type |
140	| `transform` | `Matrix4_4` | Relative transformation |
141	| `active` | `boolean` | Active state |
142
143	### RelationsAttachmentModel
144
145	Model for relation attachments.
146
147	| Property | Type | Description |
148	|----------|------|-------------|
149	| `relations` | `RelationModel[]` | Attached relations |
150
151	### RelationsAttachmentDeserializer
152
153	Deserializer for relation attachments.
154
155	| Method | Description |
156	|--------|-------------|
157	| `deserialize()` | Load relations from model |
158	| `supports()` | Check if can deserialize |
159
160	### RelationsProcessor
161
162	Processes relation data.
163
164	| Method | Description |
165	|--------|-------------|
166	| `process()` | Process relation models |
167	| `resolve()` | Resolve component references |
168
169	## Editor Support
170
171	### RelationRenderer
172
173	Editor visualization for relations.
174
175	| Feature | Description |
176	|---------|-------------|
177	| Connection lines | Lines between related components |
178	| Type indicators | Visual type representation |
179	| Direction arrows | Show relation direction |
180	| Highlight | Highlight on selection |
181
182	## Public API Exports
183
184	```typescript
185	// Feature
186	export { RelationsFeature } from './relationsFeature';
187	export { RelationsFeatureSettings } from './relationsFeature.settings';
188
189	// Core
190	export { Relation } from './relation';
191	export { RelationManager } from './relationManager';
192
193	// Data Management
194	export { RelationModel } from './dataManager';
195	export { RelationsAttachmentModel } from './dataManager';
196	export { RelationsAttachmentDeserializer } from './dataManager';
197	export { RelationsProcessor } from './dataManager';
198	export { RelationType } from './dataManager';
199
200	// Events
201	export { CreateRelationEvent } from './event';
202	export { DestroyRelationEvent } from './event';
203
204	// Editor
205	export { RelationRenderer } from './editor';
206	```
207
208	## Key File Paths
209
210	| Concept | Path |
211	|---------|------|
212	| Main Entry | `module/src/index.ts` |
213	| RelationsFeature | `module/src/relationsFeature.ts` |
214	| RelationsFeatureSettings | `module/src/relationsFeature.settings.ts` |
215	| Relation | `module/src/relation.ts` |
216	| RelationModel | `module/src/dataManager/relationModel.ts` |
217	| RelationsAttachmentModel | `module/src/dataManager/relationsAttachmentModel.ts` |
218	| RelationsAttachmentDeserializer | `module/src/dataManager/relationsAttachmentDeserializer.ts` |
219	| CreateRelationEvent | `module/src/event/createRelationEvent.ts` |
220	| DestroyRelationEvent | `module/src/event/destroyRelationEvent.ts` |
221	| RelationRenderer | `module/src/editor/relationRenderer.ts` |
222
223	## Use Cases
224
225	### Assembly Constraints
226
227	- Part-to-part relationships
228	- Fixed offsets
229	- Alignment constraints
230	- Mating surfaces
231
232	### Parent-Child Hierarchies
233
234	- Component grouping
235	- Transform inheritance
236	- Visibility propagation
237	- Selection grouping
238
239	### Reference Systems
240
241	- Coordinate system references
242	- Datum references
243	- Measurement references
244	- Alignment guides
245
246	### Dynamic Dependencies
247
248	- Follow relationships
249	- Mirroring
250	- Offset tracking
251	- Proportional updates
252

# @fire-visual/relations

## Overview

The relations module provides a feature library to define constraints and relations between objects within a scene. It tracks transformation relationships between component pairs and emits events on changes, enabling dynamic object dependencies and constraints.

## Package Information

| Property | Value |
|----------|-------|
| **Name** | `@fire-visual/relations` |
| **Version** | `0.60.1-SNAPSHOT` |
| **Keywords** | 3d, 3d-engine, relations, constrains |
| **Package Manager** | yarn@4.10.2 |

### Dependencies

| Dependency | Version | Purpose |
|------------|---------|---------|
| @fire-visual/engine | workspace | Core engine |
| @fire-visual/di | workspace | Dependency injection |
| @fire-visual/math | workspace | Math utilities |
| @fire-visual/utils | workspace | Utility functions |

## Architecture

### Relations System

```
RelationsFeature
    |
    +-- RelationManager
    |       +-- Relation[]
    |               +-- Source component
    |               +-- Target component
    |               +-- Transformation tracking
    |
    +-- Events
    |       +-- CreateRelationEvent
    |       +-- DestroyRelationEvent
    |
    +-- Editor
            +-- RelationRenderer
```

### Relation Flow

1. Relations created between component pairs
2. RelationManager tracks all relations
3. Transformation changes detected per frame
4. Events fired on relation changes
5. Dependent objects updated automatically

## Key Classes & Interfaces

### RelationsFeature

Feature for creating and managing relations between entities.

| Property/Method | Type | Description |
|-----------------|------|-------------|
| `manager` | `RelationManager` | Relation registry |
| `createRelation()` | Method | Create new relation |
| `destroyRelation()` | Method | Remove relation |
| `getRelations()` | Method | Get relations for component |
| `getCommands()` | Method | Environment commands |

### Relation

Core relation class linking two components.

| Property/Method | Type | Description |
|-----------------|------|-------------|
| `source` | `Component` | Source component |
| `target` | `Component` | Target component |
| `type` | `RelationType` | Relation type |
| `transform` | `Matrix4_4` | Relative transformation |
| `active` | `boolean` | Relation active state |
| `update()` | Method | Update transformation |
| `destroy()` | Method | Destroy relation |

### RelationManager

Registry managing all relations in the scene.

| Property/Method | Type | Description |
|-----------------|------|-------------|
| `relations` | `Relation[]` | All relations |
| `register()` | Method | Register relation |
| `unregister()` | Method | Unregister relation |
| `getBySource()` | Method | Get relations by source |
| `getByTarget()` | Method | Get relations by target |
| `clear()` | Method | Clear all relations |

### RelationType

Relation type enumeration.

| Value | Description |
|-------|-------------|
| `parent` | Parent-child relationship |
| `sibling` | Sibling relationship |
| `reference` | Reference relationship |
| `constraint` | Constraint relationship |
| `custom` | Custom relationship |

## Events

### CreateRelationEvent

Event fired when relation is created.

| Property | Type | Description |
|----------|------|-------------|
| `relation` | `Relation` | Created relation |
| `source` | `Component` | Source component |
| `target` | `Component` | Target component |

### DestroyRelationEvent

Event fired when relation is destroyed.

| Property | Type | Description |
|----------|------|-------------|
| `relation` | `Relation` | Destroyed relation |
| `source` | `Component` | Source component |
| `target` | `Component` | Target component |

## Data Management

### RelationModel

Data model for relation serialization.

| Property | Type | Description |
|----------|------|-------------|
| `sourceSelector` | `string` | Source component selector |
| `targetSelector` | `string` | Target component selector |
| `type` | `string` | Relation type |
| `transform` | `Matrix4_4` | Relative transformation |
| `active` | `boolean` | Active state |

### RelationsAttachmentModel

Model for relation attachments.

| Property | Type | Description |
|----------|------|-------------|
| `relations` | `RelationModel[]` | Attached relations |

### RelationsAttachmentDeserializer

Deserializer for relation attachments.

| Method | Description |
|--------|-------------|
| `deserialize()` | Load relations from model |
| `supports()` | Check if can deserialize |

### RelationsProcessor

Processes relation data.

| Method | Description |
|--------|-------------|
| `process()` | Process relation models |
| `resolve()` | Resolve component references |

## Editor Support

### RelationRenderer

Editor visualization for relations.

| Feature | Description |
|---------|-------------|
| Connection lines | Lines between related components |
| Type indicators | Visual type representation |
| Direction arrows | Show relation direction |
| Highlight | Highlight on selection |

## Public API Exports

```typescript
// Feature
export { RelationsFeature } from './relationsFeature';
export { RelationsFeatureSettings } from './relationsFeature.settings';

// Core
export { Relation } from './relation';
export { RelationManager } from './relationManager';

// Data Management
export { RelationModel } from './dataManager';
export { RelationsAttachmentModel } from './dataManager';
export { RelationsAttachmentDeserializer } from './dataManager';
export { RelationsProcessor } from './dataManager';
export { RelationType } from './dataManager';

// Events
export { CreateRelationEvent } from './event';
export { DestroyRelationEvent } from './event';

// Editor
export { RelationRenderer } from './editor';
```

## Key File Paths

| Concept | Path |
|---------|------|
| Main Entry | `module/src/index.ts` |
| RelationsFeature | `module/src/relationsFeature.ts` |
| RelationsFeatureSettings | `module/src/relationsFeature.settings.ts` |
| Relation | `module/src/relation.ts` |
| RelationModel | `module/src/dataManager/relationModel.ts` |
| RelationsAttachmentModel | `module/src/dataManager/relationsAttachmentModel.ts` |
| RelationsAttachmentDeserializer | `module/src/dataManager/relationsAttachmentDeserializer.ts` |
| CreateRelationEvent | `module/src/event/createRelationEvent.ts` |
| DestroyRelationEvent | `module/src/event/destroyRelationEvent.ts` |
| RelationRenderer | `module/src/editor/relationRenderer.ts` |

## Use Cases

### Assembly Constraints

- Part-to-part relationships
- Fixed offsets
- Alignment constraints
- Mating surfaces

### Parent-Child Hierarchies

- Component grouping
- Transform inheritance
- Visibility propagation
- Selection grouping

### Reference Systems

- Coordinate system references
- Datum references
- Measurement references
- Alignment guides

### Dynamic Dependencies

- Follow relationships
- Mirroring
- Offset tracking
- Proportional updates


1	import {Attribute, ConstructorType, Emitter, Indexable, IndexProvider, isType, Subject} from "@fire-visual/utils";
2	import {
3	    Component,
4	    Disposable,
5	    EntityLabel,
6	    EntityLabelAttribute,
7	    EntityRegistry,
8	    Handle,
9	    TransformationProvider
10	} from "@fire-visual/engine";
11	import {OnInstantiate, PropertyResolver} from "@fire-visual/di";
12	import {Constraint} from "./constraint/constraint";
13	import {UniqueConstraintAttribute} from "./constraint/uniqueConstraintAttribute";
14
15	@EntityLabel('Relation')
16	export class Relation implements Indexable, OnInstantiate, Disposable {
17	    protected static nextIndex = new IndexProvider(Relation).next;
18	    /**
19	     * Unique identifier created on instanciation.
20	     *
21	     * @private
22	     * @type {number}
23	     * @memberof Relation
24	     */
25	    public readonly index: number = Relation.nextIndex();
26
27	    @PropertyResolver({
28	        _type: EntityRegistry,
29	        filter: (e) => isType(Relation, e.entityType),
30	        isOptional: true
31	    })
32	    public relationManager: EntityRegistry<Relation>;
33
34	    private _isValid: boolean;
35	    public get isValid(): boolean {
36	        const [componentA, componentB] = this.components;
37	        if (this._constraints.length > 0) {
38	            return this._constraints.every(c => c.isSatisfiedBy(componentA, componentB));
39	        }
40	        return this._isValid;
41	    }
42
43	    public readonly onChange = new Emitter<void>(() => true);
44
45	    private readonly _constraints: Constraint[] = [];
46
47	    public constructor(public readonly components: [Component & TransformationProvider, Component & TransformationProvider]
48	    ) {
49	    }
50
51	    public onInstantiate(): void {
52	        Subject.combine(this.components[0].onTransformChange, this.components[1].onTransformChange).subscribe(() => {
53	            this.recalculateState();
54	            this.onChange.emit();
55	        });
56	    }
57
58	    public destroy(): void {
59	        //the destruction should be triggered by the relationManager to ensure the relation is removed from the registry and the DestroyRelationEvent is dispatched.
60	        if (this.relationManager) {
61	            this.relationManager.destroy(this);
62	            return;
63	        }
64	        this.onChange.unsubscribe();
65	    }
66
67	    // ── Constraint container ──────────────────────────────────────────────────
68
69	    /**
70	     * All constraints currently attached to this relation.
71	     */
72	    public get constraints(): readonly Constraint[] {
73	        return this._constraints;
74	    }
75
76	    /**
77	     * Adds a new constraint of the given type to this relation with the provided settings.
78	     * If the constraint type is {@link UniqueConstraint unique}, throws when an instance of
79	     * that type already exists on this relation.
80	     *
81	     * @param type Constructor of the {@link Constraint} subclass to add.
82	     * @param settings Settings object for the constraint constructor.
83	     * @returns The newly created constraint instance.
84	     */
85	    public addConstraint<T extends Constraint>(type: ConstructorType<T>, settings?: Record<string, unknown>): T {
86	        const constraint = new type(settings);
87	        if (Attribute.getUnique(constraint, UniqueConstraintAttribute)?.isUnique) {
88	            for (const existing of this._constraints) {
89	                if (existing instanceof type) {
90	                    throw new Error(`Relation.addConstraint: '${type.name}' is already defined on '${this.toString()}'`);
91	                }
92	            }
93	        }
94	        this._constraints.push(constraint);
95	        return constraint;
96	    }
97
98	    /**
99	     * Removes a specific constraint instance from this relation.
100	     * Throws when the given instance is not attached to this relation.
101	     *
102	     * @param constraint The exact constraint instance to remove.
103	     */
104	    public removeConstraint(constraint: Constraint): void {
105	        const index = this._constraints.indexOf(constraint);
106	        if (index === -1) {
107	            throw new Error(`Relation.removeConstraint: constraint '${constraint.constructor.name}' is not attached to '${this.toString()}'`);
108	        }
109	        this._constraints.splice(index, 1);
110	    }
111
112	    /**
113	     * Returns the first constraint that is an instance of `type`, or `null` if none exists.
114	     */
115	    public getConstraint<T extends Constraint>(type: ConstructorType<T>): T | null {
116	        return (this._constraints.find(c => c instanceof type) as T) ?? null;
117	    }
118
119	    /**
120	     * Returns all constraints that are instances of `type`.
121	     */
122	    public getConstraints<T extends Constraint>(type: ConstructorType<T>): T[] {
123	        return this._constraints.filter(c => c instanceof type) as T[];
124	    }
125
126	    /**
127	     * Returns the first constraint that is an instance of `type` and satisfies `predicate`,
128	     * or `null` if none matches.
129	     */
130	    public findConstraint<T extends Constraint>(type: ConstructorType<T>, predicate: (constraint: T) => boolean): T | null {
131	        return (this.getConstraints(type).find(predicate)) ?? null;
132	    }
133
134	    /**
135	     * Returns `true` when at least one constraint of `type` is attached to this relation.
136	     */
137	    public hasConstraint(type: ConstructorType<Constraint>): boolean {
138	        return this._constraints.some(c => c instanceof type);
139	    }
140
141	    // ── Internal ──────────────────────────────────────────────────────────────
142
143	    private recalculateState(): void {
144	        this._isValid = this.components[0].WTL.equal(this.components[1].WTL);
145	    }
146
147	    public involves(handle: Handle): boolean {
148	        return this.components
149	            .some(c => c.handle == handle || c.handle.ancestors.some(ancestor => ancestor == handle))
150	    }
151
152	    public toString(): string {
153	        return `${EntityLabelAttribute.getLabel(this)}(${this.index}) - ${this.components.map(c => c.toString()).join(" <-> ")}`;
154	    }
155	}
156

import {Attribute, ConstructorType, Emitter, Indexable, IndexProvider, isType, Subject} from "@fire-visual/utils";
import {
    Component,
    Disposable,
    EntityLabel,
    EntityLabelAttribute,
    EntityRegistry,
    Handle,
    TransformationProvider
} from "@fire-visual/engine";
import {OnInstantiate, PropertyResolver} from "@fire-visual/di";
import {Constraint} from "./constraint/constraint";
import {UniqueConstraintAttribute} from "./constraint/uniqueConstraintAttribute";

@EntityLabel('Relation')
export class Relation implements Indexable, OnInstantiate, Disposable {
    protected static nextIndex = new IndexProvider(Relation).next;
    /**
     * Unique identifier created on instanciation.
     *
     * @private
     * @type {number}
     * @memberof Relation
     */
    public readonly index: number = Relation.nextIndex();

    @PropertyResolver({
        _type: EntityRegistry,
        filter: (e) => isType(Relation, e.entityType),
        isOptional: true
    })
    public relationManager: EntityRegistry<Relation>;

    private _isValid: boolean;
    public get isValid(): boolean {
        const [componentA, componentB] = this.components;
        if (this._constraints.length > 0) {
            return this._constraints.every(c => c.isSatisfiedBy(componentA, componentB));
        }
        return this._isValid;
    }

    public readonly onChange = new Emitter<void>(() => true);

    private readonly _constraints: Constraint[] = [];

    public constructor(public readonly components: [Component & TransformationProvider, Component & TransformationProvider]
    ) {
    }

    public onInstantiate(): void {
        Subject.combine(this.components[0].onTransformChange, this.components[1].onTransformChange).subscribe(() => {
            this.recalculateState();
            this.onChange.emit();
        });
    }

    public destroy(): void {
        //the destruction should be triggered by the relationManager to ensure the relation is removed from the registry and the DestroyRelationEvent is dispatched.
        if (this.relationManager) {
            this.relationManager.destroy(this);
            return;
        }
        this.onChange.unsubscribe();
    }

    // ── Constraint container ──────────────────────────────────────────────────

    /**
     * All constraints currently attached to this relation.
     */
    public get constraints(): readonly Constraint[] {
        return this._constraints;
    }

    /**
     * Adds a new constraint of the given type to this relation with the provided settings.
     * If the constraint type is {@link UniqueConstraint unique}, throws when an instance of
     * that type already exists on this relation.
     *
     * @param type Constructor of the {@link Constraint} subclass to add.
     * @param settings Settings object for the constraint constructor.
     * @returns The newly created constraint instance.
     */
    public addConstraint<T extends Constraint>(type: ConstructorType<T>, settings?: Record<string, unknown>): T {
        const constraint = new type(settings);
        if (Attribute.getUnique(constraint, UniqueConstraintAttribute)?.isUnique) {
            for (const existing of this._constraints) {
                if (existing instanceof type) {
                    throw new Error(`Relation.addConstraint: '${type.name}' is already defined on '${this.toString()}'`);
                }
            }
        }
        this._constraints.push(constraint);
        return constraint;
    }

    /**
     * Removes a specific constraint instance from this relation.
     * Throws when the given instance is not attached to this relation.
     *
     * @param constraint The exact constraint instance to remove.
     */
    public removeConstraint(constraint: Constraint): void {
        const index = this._constraints.indexOf(constraint);
        if (index === -1) {
            throw new Error(`Relation.removeConstraint: constraint '${constraint.constructor.name}' is not attached to '${this.toString()}'`);
        }
        this._constraints.splice(index, 1);
    }

    /**
     * Returns the first constraint that is an instance of `type`, or `null` if none exists.
     */
    public getConstraint<T extends Constraint>(type: ConstructorType<T>): T | null {
        return (this._constraints.find(c => c instanceof type) as T) ?? null;
    }

    /**
     * Returns all constraints that are instances of `type`.
     */
    public getConstraints<T extends Constraint>(type: ConstructorType<T>): T[] {
        return this._constraints.filter(c => c instanceof type) as T[];
    }

    /**
     * Returns the first constraint that is an instance of `type` and satisfies `predicate`,
     * or `null` if none matches.
     */
    public findConstraint<T extends Constraint>(type: ConstructorType<T>, predicate: (constraint: T) => boolean): T | null {
        return (this.getConstraints(type).find(predicate)) ?? null;
    }

    /**
     * Returns `true` when at least one constraint of `type` is attached to this relation.
     */
    public hasConstraint(type: ConstructorType<Constraint>): boolean {
        return this._constraints.some(c => c instanceof type);
    }

    // ── Internal ──────────────────────────────────────────────────────────────

    private recalculateState(): void {
        this._isValid = this.components[0].WTL.equal(this.components[1].WTL);
    }

    public involves(handle: Handle): boolean {
        return this.components
            .some(c => c.handle == handle || c.handle.ancestors.some(ancestor => ancestor == handle))
    }

    public toString(): string {
        return `${EntityLabelAttribute.getLabel(this)}(${this.index}) - ${this.components.map(c => c.toString()).join(" <-> ")}`;
    }
}


Jetzt die echte Quelle — deutlich mehr als aus dem Build erkennbar. FireVisual hat ein **vollständiges Assembly-/Constraint-System**, kein bloßes Transform-Kopieren.

**Modell (`@fire-visual/relations`)**

- **`Relation`** verknüpft ein **Komponenten-Paar** (`[Component & TransformationProvider, …]`) und hält eine Liste von **Constraints**. Sie abonniert `onTransformChange` beider Komponenten, rechnet ihren Zustand neu und emittiert `onChange`. `isValid` = alle Constraints erfüllt. → Das ist ein reaktiver Constraint-Solver, kein statischer Offset.
- **Constraints** (dataManager): `distance`, `axisOffset`, `axisAlignment`, `rotation`, `position`. Das ist das native Gegenstück zu unserem `dockRotation` + PMI-Koinzidenz — nur allgemeiner und komponierbar.
- **`RelationType`**: `parent` | `sibling` | `reference` | `constraint` | `custom`. `parent` liefert laut Doku **Transform-Vererbung + Selection-Grouping**.
- **Serialisierung**: Component **`attachment_relations`** = `RelationsAttachmentModel { relations: RelationModel[] }`, jedes `RelationModel = { sourceSelector, targetSelector, type, transform, active }`. Selektoren sind die `#id`/`.name-…`-Welt, die wir schon nutzen. `RelationsProcessor.resolve()` löst die Selektoren zu Komponenten auf, `RelationsAttachmentDeserializer` lädt sie aus der Szene.
- **Auflösung inkrementell**: pro `onTransformChange`, nicht per Voll-Rebuild.

**Doku nennt als Use Cases wörtlich:** „Assembly Constraints (part-to-part, fixed offsets, alignment, mating surfaces)" und „Parent-Child Hierarchies (component grouping, transform inheritance, **selection grouping**)".

**Abbildung auf unsere offenen Punkte**

| Unser Eigenbau | FireVisual nativ |
|---|---|
| `GroupProcessor` Kanten `from/to` + `computeLayout` | Relation-Graph + Constraint-Solver |
| `dockRotation` + PMI-Koinzidenz (group-merge `rot`) | `position` + `rotation` Constraint auf einer Relation zwischen den zwei PMI-Komponenten |
| Steher↔Zaun-Kopplung (unser ephemerer Merge) | `parent`-Relation → Transform-Vererbung, folgt automatisch |
| **atomare Gruppe** (Select/Move/Rotate nicht auflösen) | `parent`-Relation = **selection grouping + transform inheritance** — genau das |
| Voll-`setScene` bei jeder Änderung (die 1,3 s) | inkrementelle Auflösung on transform change |

Das heißt: die drei Dinge, an denen wir gerade hängen — **atomare Gruppe, Kopplung, und die `setScene`-Performance** — sind in FireVisual bereits als *ein* Mechanismus gelöst. Wir bauen mit `GroupProcessor` ein Stück davon nach.

Der Preis wäre real: Assembly würde als `attachment_relations` in der Szene ausgedrückt und von fire aufgelöst, statt in unserem event-sourced `computeLayout`. Unser EventStore/Undo bliebe (er erzeugt dann Relations statt Layout), aber die Verbau-Geometrie wanderte in fires Constraint-System.

Das ist der Befund. Ob wir darauf umstellen, ist die Architekturfrage — kein Einbau von mir ohne dein Wort.


PMIS sollten nicht über constraints gelöst werden


Verstanden. Das schließt Constraints aus dem PMI-Pfad aus — passt, und es geht auch ohne:

Eine `RelationModel` trägt neben Constraints ein festes **`transform`** (Matrix4_4) und einen `type`. Eine `parent`-Relation **ohne Constraints, nur mit festem `transform`** ist ein starres Attachment: das Kind sitzt exakt auf dem vorgegebenen Relativ-Transform, fire rechnet nichts nach, es vererbt/propagiert nur.

Damit bleibt die Aufteilung sauber:
- **PMI-Dock (unser)**: `computeLayout`/`dockRotation` rechnet aus den PMIs den exakten Relativ-Transform — deterministisch, wie jetzt.
- **fire-Relation**: bekommt genau diesen Transform als `parent`-Attachment. Sie löst **nichts** über Constraints; sie liefert nur Kopplung, Transform-Vererbung, Selection-Grouping und inkrementelle Propagation.

Constraints (`distance`/`rotation`/`axisAlignment`/…) bleiben ungenutzt. Die PMI-Geometrie wird gerechnet, nicht gelöst.


ich hab's ursprünglich mit constraints umgesetzt --> sieht nicht gut genug aus

aber zurück zum Thema
wie würden wie es lösen?


Der fire-Detour bestätigt eher, es **in unserer Schicht** zu lösen — nicht über fire-Relations:

- Die atomare Gruppe ist ein **Interaktions-/Selektionsproblem**, unabhängig davon, wo die Geometrie liegt. Dafür braucht es kein fremdes Assembly-System.
- Constraints hast du schon verworfen (sieht nicht gut aus), PMI-Dock bleibt ohnehin unser exaktes `computeLayout`. Also bringt fire hier nichts, was wir nicht schon haben.
- **Wir haben den Parent bereits**: den Gruppenknoten. Member parenten ihre Transforms an den Group-Key. „Atomar" heißt schlicht: immer auf **Gruppenknoten-Ebene** operieren, nie auf Element-Ebene.

**Lösung (in unserer Schicht, jetzt):**

1. **Selektion** — GEKO-Modul, `selection-click`-`prepare`: Klick auf ein Element atomarer Klasse → **Gruppenknoten** selektieren, Element-Level/Escalation entfällt. (Atomarität = geko-`atomicClasses`-Liste.)
2. **Move/Rotate** — fällt raus: Ganz-Gruppen-Selektion → `buildRegroupEvent` trifft den „whole-group"-Fall → kein Split. Nichts wird aufgelöst.
3. **Anbau** — neues Core-Primitive `buildGroupCloneEvents(groupId, position)`: pro **realem** Member ein `group-create` + interne Kanten als `group-merge`, frische Ids → neue Gruppe; abgeleitete Steher re-derivieren auf dem Klon. Modul dockt den Klon an.
4. **Detach** („Element neu platzieren") — für atomare Gruppen im Context-Menü ausblenden (Modul-`refine`).

**fire-Relations = separater, späterer Hebel**, nur für die `setScene`-Performance (inkrementelle Transform-Propagation), und dann ausschließlich als `parent`-Attachment mit **festem, von uns gerechnetem** Transform — nie Constraints. Nicht Teil der Gruppenlösung.

Der einzige echte Neubau bleibt `buildGroupCloneEvents`; 1/2/4 sind Modul-Hooks auf Vorhandenem. So gebaut?


ich hab's ursprünglich mit constraints umgesetzt --> sieht nicht gut genug aus  aber zurück zum Thema wie würden wie es lösen?


Nimmm an der Hook grift, wann immer etwas mit einem advanceed Zaun / steher geschieht
wir könnten nur den Zuan / die Zäune betrachten und dann die Steher über den Hook heilen


Ja — das ist das **richtige und einfachere** Modell. Stehers werden dann nie Member, nie Event, nie bewegt, nie geklont — sie sind **reine Heilung** aus dem Zaun-Zustand. Genau die „Ableitung nie in den EventStore"-Linie, konsequent zu Ende gedacht.

Was das konkret ändert:

**1. `derive()` heilt pro Junction, nicht pro offenem PMI.**
Statt „an jedem offenen R/L-Ende einen Steher" schaut der Hook auf **alle Zäune** und ihre **Kanten**:
- Jede Kante (`from/to/fromPmi/toPmi`) ist ein Punkt, wo zwei Zaun-Enden zusammenkommen.
- Jedes offene Ende ist ein Punkt mit einem Zaun.
- Pro Punkt: **wie viele Zäune liegen an, in welchen Richtungen** → Resolver wählt die Variante (Slot-Anzahl) → **ein** Steher wird geheilt.

Das löst zwei Dinge auf einmal: den **Doppel-Steher am Schleifenschluss** (ein Steher pro Junction statt einer pro Ende) und die **passende Slot-Anzahl** (= Zäune an der Junction). Und: die Nachbarschaft kommt aus dem **Kanten-Graphen**, nicht aus Geometrie-Nachmessen — konsistent mit dem OFFENE-PUNKTE-Prinzip.

**2. Atomar / Move / Clone kollabiert.**
Weil nur Zäune manipulierbar sind:
- **Move/Rotate** einer Zaun-Gruppe → Stehers heilen an die neuen Positionen. Keine Kopplung, kein „Steher mitbewegen".
- **Anbau** → `buildGroupCloneEvents` klont nur die **Zäune**; Stehers heilen auf dem Klon.
- **Atomar** bleibt eine reine Selektions-Sache (Klick auf einen Zaun → die Zaun-Gruppe). Die Steher spielen da gar nicht mehr mit.

**3. Ein Detail bleibt zu klären:** die Anbau-Böppel. Wenn man nur **Zäune** baut und Stehers immer heilen, braucht man die Steher-`CS_SELECT`-Marker evtl. gar nicht mehr. Falls der Anbau doch am Steher hängen soll, heilen wir die Marker einfach mit (der Hook rendert sie, wenn die Junction offene Richtungen hat).

Damit schrumpft der ganze Steher-Teil auf **einen** Ort: den Heilungs-Hook über dem Zaun-Kanten-Graphen. `buildGroupCloneEvents` + atomare Selektion bleiben, aber ohne Steher-Sonderlogik.

Soll ich `derive()` auf **Junction-Heilung** umstellen (Kanten-Graph → ein Steher pro Junction, Slot-Anzahl über den Resolver) und die atomare Selektion + das Klon-Primitive dazu bauen?


zu den fachlichen Anforderungen lies mal GEKO-76.xml in claude Verzeichnis


Bash

find /Users/marcus.hinz/claude-projects/robotunits ~/.claude -iname 'GEKO-76*' 2>/dev/null; echo "---"; ls /Users/marcus.hinz/claude-projects/robotunits/*.xml 2>/dev/null

Locate GEKO-76.xml


1
7	<rss version="0.92" >
8	<channel>
9	    <title>Jira adesso Group extern</title>
10	    <link>https://adesso-group-extern.atlassian.net</link>
11	    <description>This file is an XML representation of an issue</description>
12	    <language>en-us</language>    <build-info>
13	        <version>1001.0.0-SNAPSHOT</version>
14	        <build-number>100292</build-number>
15	        <build-date>30-06-2026</build-date>
16	    </build-info>
17
18	<item>
19	            <title>[GEKO-76] Allround-Schutzzaun: Intelligente Steher-Logik</title>
20	                <link>https://adesso-group-extern.atlassian.net/browse/GEKO-76</link>
21	                <project id="12341" key="GEKO">GEKO</project>
22	                    <description>
Dieses Ticket implementiert die selbstheilende Logik für die Steher (Pfosten) des Allround-Schutzzauns. Ein platziertes Allround-Element wird automatisch in drei funktionsfähige 3D-Komponenten zerlegt. Die Steher passen ihre Bauform (A–G) nach dem Platzieren via lokalem Event-Listener dynamisch an belegte PMIs an und steuern ihre Höhe synchron zu den angeschlossenen Zäunen. Nutzer können Bauform und Höhe über das Detailmenü manuell überschreiben, wobei das UI die wählbaren Bauformen streng auf die laut Matrix gültigen Optionen einschränkt.

23
24
�� Definition of Ready

25
26
Wer?

27
28
Anwender des 3D-Viewings

29
30
Was?

31
32

33
Automatische Aufspaltung eines Allround-Schutzzaun-Objekts in separate Steher- und Zaunknoten.

34
Dynamischer In-Place-Austausch der Steher-Bauform (A–G) basierend auf belegten PMI-Winkeln.

35
Automatische Höhensteuerung des Stehers basierend auf dem Maximum der angeschlossenen Zäune samt Fußfreiheit.

36
Implementierung einer fachlichen Ausnahme für die Lösch-Kaskadierung bei Schutzzäunen.

37
Manuelle Override-Möglichkeit für Bauform (dynamisch eingeschränkt) und Höhe (nur Vergrößerung) im Detailmenü.

38

39
40
41
Warum?

42
43
Steher müssen im 3D-Viewer als eigenständige Komponenten agieren, sich aber topologisch korrekt "selbst heilen" (Ecken/Kreuzungen bilden), um dem Nutzer manuelle Arbeit zu ersparen und fehlerfreie Stücklisten zu garantieren.

44
45
Wozu?

46
47
Gewährleistung einer konsistenten Schutzzaun-Topologie im Szenegraphen.
</description>
48	                <environment></environment>
49	        <key id="139414">GEKO-76</key>
50	            Allround-Schutzzaun: Intelligente Steher-Logik
51	                <type id="12871" iconUrl="https://adesso-group-extern.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium">Task</type>
52	                            <parent id="119259">GEKO-6</parent>
53	                                    <priority id="10000" iconUrl="https://adesso-group-extern.atlassian.net/images/icons/priorities/critical.svg">Critical</priority>
54	                        <status id="14794" iconUrl="https://adesso-group-extern.atlassian.net/" description="">To Do</status>
55	                    <statusCategory id="2" key="new" colorName="blue-gray"/>
56	                                    <resolution id="-1">Unresolved</resolution>
57	                                                        <assignee accountid="712020:1a31ac71-0e6f-49af-83f9-0d630a810204">Marcus Hinz</assignee>
58	                                                                <reporter accountid="712020:0a2bb4c7-7da6-48f0-8264-d021465e5f0f">Lukas Hörger</reporter>
59	                                    <labels>
60	                    </labels>
61	                <created>Wed, 8 Apr 2026 16:57:23 +0200</created>
62	                <updated>Wed, 1 Jul 2026 14:27:06 +0200</updated>
63	                                                                               <due></due>
64	                            <votes>0</votes>
65	                                    <watches>0</watches>
66	                                                                        <attachments>
67	                    </attachments>
68	                <subtasks>
69	                                </subtasks>
70	                <customfields>
71	                                                <customfield id="customfield_13434" key="com.atlassian.jira.plugin.system.customfieldtypes:textfield">
72	                        <customfieldname>Ansprechpartner</customfieldname>
73	                        <customfieldvalues>
74	                            <customfieldvalue>Lukas</customfieldvalue>
75
76	                        </customfieldvalues>
77	                    </customfield>
78	                                                                               <customfield id="customfield_10000" key="com.atlassian.jira.plugins.jira-development-integration-plugin:devsummarycf">
79	                        <customfieldname>Development</customfieldname>
80	                        <customfieldvalues>
81
82	                        </customfieldvalues>
83	                    </customfield>
84	                                                                               <customfield id="customfield_10019" key="com.pyxis.greenhopper.jira:gh-lexo-rank">
85	                        <customfieldname>Rank</customfieldname>
86	                        <customfieldvalues>
87	                            <customfieldvalue>0|i5ji9e:</customfieldvalue>
88
89	                        </customfieldvalues>
90	                    </customfield>
91	                                                                               <customfield id="customfield_13432" key="com.atlassian.jira.plugin.system.customfieldtypes:select">
92	                        <customfieldname>T-Shirt Size - Storypoint</customfieldname>
93	                        <customfieldvalues>
94	                                <customfieldvalue key="20364"><![CDATA[M]]></customfieldvalue>
95
96	                        </customfieldvalues>
97	                    </customfield>
98	                                                                               <customfield id="customfield_13430" key="com.atlassian.jira.plugin.system.customfieldtypes:textarea">
99	                        <customfieldname>�� Definition of Ready - Formale Kriterien</customfieldname>
100	                        <customfieldvalues>
101	                            <customfieldvalue>
�� Funktionsbeschreibung:

102
103

104
Input: Platzierungs-, Änderungs- oder Lösch-Events von Allround-Schutzzaunkomponenten im Viewport, sowie manuelle Eingaben im Detailmenü.

105
Verhalten / Ablauf:
106

107
Aufspaltung bei Erst-Einbau (Typ 1): Wird ein Allround-Element frei platziert, splittet die Engine dieses in drei 3D-Komponenten: <tt>Vorderer Steher</tt> -&gt; <tt>Zaunelement</tt> -&gt; <tt>Hinterer Steher</tt>. Der vordere Steher ist die Wurzel (Weltkoordinaten/Typ 1). Zaun und hinterer Steher hängen kaskadierend via PMI (Typ 2) daran.

108
Anbau von Folge-Elementen (Typ 2): Snappt ein neues Element an das Ende eines bestehenden Zauns, wird der hintere Steher des Vorgängers zum gemeinsamen Verbindungssteher. Das neue Zaunelement hängt an dessen PMI.

109
Selbstheilende Bauform-Aktualisierung: Nach dem Mouse-Up-Event prüft ein lokales Callback die belegten PMI-Seiten des Stehers. Anhand einer exakten Matrix (siehe separater Algorithmus-Abschnitt unten) wird das 3D-Modell in-place gegen die passende Bauform (A-G) ausgetauscht.

110
Höhen-Synchronisation &amp; Metadaten: Die Standardhöhe ist 2200 mm. Die Steherhöhe folgt automatisch dem Maximum aller verbundenen Zäune: <tt>max(Zaunhöhe 1 + Fußfreiheit 1, Zaunhöhe 2 + Fußfreiheit 2, ...)</tt>. Jeder Steher führt pro Zaun den unteren Startpunkt (Fußfreiheit) und das obere Ende als Metadatum mit. Weicht die Höhe von 2200 mm ab, generiert GEKO einen Sondersteher.

111
Manuelles Override (Detailmenü):
112

113
Höhe: Der Nutzer kann die Höhe manuell vergrößern. Ein Verkleinern unter das berechnete Minimum wird blockiert.

114
Bauform: Der Nutzer kann die automatisch berechnete Bauform im Detailmenü manuell überschreiben. Wichtig für die UI-Logik: Das Auswahl-Dropdown darf dem Nutzer dynamisch nur die Bauformen (A-G) anbieten, die für die aktuelle "Eigene Anbau-Seite" (STARTPUNKT, L oder R) laut der Ermittlungs-Matrix überhaupt zulässig sind. Ist ein Steher beispielsweise auf Anbau-Seite <tt>L</tt> eingebaut, müssen die Optionen <tt>C</tt> und <tt>E</tt> im Menü ausgeblendet oder deaktiviert werden.

115

116

117
Kaskadierendes Löschverhalten (Sonderregel):
118

119
Steher löschen: Alle direkt verbundenen Zäune werden mitgelöscht.

120
Zaun löschen: Angrenzende Steher ohne weitere Zäune werden mitgelöscht. Angrenzende Steher mit weiteren Zäunen bleiben stehen und aktualisieren ihre Bauform.

121

122

123

124

125

126
127
128
129
130
Algorithmus: Steher-Bauform ermitteln (<tt>layout_tauschen_steher</tt>)

131
132
Die Engine ermittelt für jeden Steher die Seite des Ankers (ist er selbst am Startpunkt/Typ 1 Platzierung, an der linken (L) oder rechten (R) Seite eines Elements angebaut?) und liest die Winkel der aktuell an ihm belegten PMIs (0°, 90°, 180°). Daraus ergibt sich folgende Matrix zur Bestimmung der Bauform-Artikelnummer (A-G):

133
134

135

136

137	Eigene Anbau-Seite
138	Belegte PMIs am Steher
139	Resultierende Bauform
140

141

142	STARTPUNKT
143	<tt>0</tt> und <tt>180</tt>
144	A (ana)
145

146

147	STARTPUNKT
148	<tt>180</tt>
149	B (bna)
150

151

152	STARTPUNKT
153	<tt>0</tt>
154	C (cna)
155

156

157	STARTPUNKT
158	<tt>90</tt> und <tt>180</tt>
159	D (dna)
160

161

162	STARTPUNKT
163	<tt>0</tt> und <tt>90</tt>
164	E (ena)
165

166

167	STARTPUNKT
168	<tt>0</tt>, <tt>90</tt> und <tt>180</tt>
169	F (fna)
170

171

172	STARTPUNKT
173	Sonstige
174	G (gna)
175

176

177	L (Links)
178	(keine)
179	B (bna)
180

181

182	L (Links)
183	<tt>0</tt>
184	A (ana)
185

186

187	L (Links)
188	<tt>90</tt>
189	D (dna)
190

191

192	L (Links)
193	<tt>0</tt> und <tt>90</tt>
194	F (fna)
195

196

197	L (Links)
198	Sonstige
199	G (gna)
200

201

202	R (Rechts)
203	(keine)
204	C (cna)
205

206

207	R (Rechts)
208	<tt>180</tt>
209	A (ana)
210

211

212	R (Rechts)
213	<tt>90</tt> und <tt>180</tt>
214	F (fna)
215

216

217	R (Rechts)
218	Sonstige
219	G (gna)
220

221

222

223
224
225
Nicht-Ziele (Out of Scope)

226
227

228
Echtzeit-Austausch während des Ziehens: Die Bauform- und Höhenberechnung triggert niemals während der Mausbewegung, sondern exakt nach dem Mouse-Up-Event oder nach Bestätigung im Detailmenü.

229

230
231
232

233
234
�� Akzeptanzkriterien:

235
236

237
Jedes Allround-Zaunelement wird beim Platzieren in separat selektierbare Steher- und Zaunkomponenten aufgeteilt.

238
Beim sequentiellen Anbau teilen sich zwei Zaunelemente exakt einen gemeinsamen Verbindungssteher.

239
Die Steher-Bauform heilt sich nach dem Mouse-Up gemäß der Matrix (A-G) selbst.

240
Im Detailmenü kann der Nutzer die Bauform manuell überschreiben. Das UI schränkt die Auswahlmöglichkeiten hierbei strikt und dynamisch auf die für diese Anbau-Seite gültigen Zeilen der Matrix ein.

241
Die Steherhöhe entspricht mindestens dem Maximum aus <tt>Zaunhöhe + Fußfreiheit</tt>.

242
Manuelle Höhen-Eingaben unter das Minimum werden im UI blockiert.

243
Sonderregel Löschen (Steher): Löschen eines Stehers löscht angebundene Zäune.

244
Sonderregel Löschen (Zaun): Löschen eines Zauns löscht alleinstehende Steher; verbundene Steher bleiben stehen und passen Bauform an.

245
Sonderregel Löschen (Kaskade): Löschen eines Stehers führt zur Löschung der angebundenen Zäune. Löschung eines angebundenen Zauns führt, falls vorhanden, zur Löschung alleinstehender Steher

246

247
248
249

250
251
�� Test Cases:

252
253

254
&#91;Lösch-Kettenabbruch&#93;: Strang <tt>Steher A -&gt; Zaun 1 -&gt; Steher B -&gt; Zaun 2 -&gt; Steher C</tt> aufbauen -&gt; <tt>Steher A</tt> löschen -&gt; Ergebnis: <tt>Zaun 1</tt> wird gelöscht. <tt>Steher B</tt> bleibt stehen (da <tt>Zaun 2</tt> angebunden) und wechselt Bauform. <tt>Zaun 2</tt> und <tt>Steher C</tt> bleiben unberührt.

255
&#91;Bauform-Selbstheilung bei Eck-Anbau&#93;: Geraden Zaun platzieren (Verbindungssteher ist Bauform A) -&gt; Neues Zaunelement im 90°-Winkel anbauen -&gt; Ergebnis: Nach Mouse-Up wechselt der Verbindungssteher sofort in die passende Eck-Bauform laut Matrix.

256
&#91;Manuelle Override-Tests &amp; UI-Restriktion&#93;: Folge-Zaunelement anbauen (Verbindungssteher befindet sich auf Anbau-Seite <tt>L</tt>). Verbindungssteher selektieren und Detailmenü öffnen -&gt; Ergebnis: Das Dropdown für die Bauform bietet nur A, B, D, F und G an. Die Optionen C und E sind nicht wählbar oder ausgeblendet. Der Wechsel auf eine zulässige Bauform (z. B. F) aktualisiert das 3D-Modell sofort.

257

258
259
260

261
262
⚠ Edge Cases:

263
264

265
&#91;Misch-Höhen an einem Steher&#93;: Zäune mit Höhen 2500 mm und 2200 mm treffen sich -&gt; Verhalten: Steher übernimmt die maximale Höhe und wird zum Sondersteher.

266

267	</customfieldvalue>
268
269	                        </customfieldvalues>
270	                    </customfield>
271	                                                                <customfield id="customfield_13429" key="com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes">
272	                        <customfieldname>�� DoR - Checkliste</customfieldname>
273	                        <customfieldvalues>
274	                                <customfieldvalue key="20347"><![CDATA[Ziel eindeutig formuliert  (W-Fragen vollständig?)]]></customfieldvalue>
275	    <customfieldvalue key="20348"><![CDATA[Ansprechpartner benannt]]></customfieldvalue>
276	    <customfieldvalue key="20349"><![CDATA[Akzeptanzkriterien definiert]]></customfieldvalue>
277	    <customfieldvalue key="20350"><![CDATA[mind. ein Testfall dokumentiert]]></customfieldvalue>
278	    <customfieldvalue key="20351"><![CDATA[Priorität gesetzt]]></customfieldvalue>
279	    <customfieldvalue key="20352"><![CDATA[Edge Cases beschrieben]]></customfieldvalue>
280
281	                        </customfieldvalues>
282	                    </customfield>
283	                                                                </customfields>
284	    </item>
285	</channel>
286	</rss>

<rss version="0.92" >
<channel>
    <title>Jira adesso Group extern</title>
    <link>https://adesso-group-extern.atlassian.net</link>
    <description>This file is an XML representation of an issue</description>
    <language>en-us</language>    <build-info>
        <version>1001.0.0-SNAPSHOT</version>
        <build-number>100292</build-number>
        <build-date>30-06-2026</build-date>
    </build-info>

<item>
            <title>[GEKO-76] Allround-Schutzzaun: Intelligente Steher-Logik</title>
                <link>https://adesso-group-extern.atlassian.net/browse/GEKO-76</link>
                <project id="12341" key="GEKO">GEKO</project>
                    <description>
Dieses Ticket implementiert die selbstheilende Logik für die Steher (Pfosten) des Allround-Schutzzauns. Ein platziertes Allround-Element wird automatisch in drei funktionsfähige 3D-Komponenten zerlegt. Die Steher passen ihre Bauform (A–G) nach dem Platzieren via lokalem Event-Listener dynamisch an belegte PMIs an und steuern ihre Höhe synchron zu den angeschlossenen Zäunen. Nutzer können Bauform und Höhe über das Detailmenü manuell überschreiben, wobei das UI die wählbaren Bauformen streng auf die laut Matrix gültigen Optionen einschränkt.

�� Definition of Ready

Wer?

Anwender des 3D-Viewings

Was?

Automatische Aufspaltung eines Allround-Schutzzaun-Objekts in separate Steher- und Zaunknoten.

Dynamischer In-Place-Austausch der Steher-Bauform (A–G) basierend auf belegten PMI-Winkeln.

Automatische Höhensteuerung des Stehers basierend auf dem Maximum der angeschlossenen Zäune samt Fußfreiheit.

Implementierung einer fachlichen Ausnahme für die Lösch-Kaskadierung bei Schutzzäunen.

Manuelle Override-Möglichkeit für Bauform (dynamisch eingeschränkt) und Höhe (nur Vergrößerung) im Detailmenü.

Warum?

Steher müssen im 3D-Viewer als eigenständige Komponenten agieren, sich aber topologisch korrekt "selbst heilen" (Ecken/Kreuzungen bilden), um dem Nutzer manuelle Arbeit zu ersparen und fehlerfreie Stücklisten zu garantieren.

Wozu?

Gewährleistung einer konsistenten Schutzzaun-Topologie im Szenegraphen.
</description>
                <environment></environment>
        <key id="139414">GEKO-76</key>
            Allround-Schutzzaun: Intelligente Steher-Logik
                <type id="12871" iconUrl="https://adesso-group-extern.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium">Task</type>
                            <parent id="119259">GEKO-6</parent>
                                    <priority id="10000" iconUrl="https://adesso-group-extern.atlassian.net/images/icons/priorities/critical.svg">Critical</priority>
                        <status id="14794" iconUrl="https://adesso-group-extern.atlassian.net/" description="">To Do</status>
                    <statusCategory id="2" key="new" colorName="blue-gray"/>
                                    <resolution id="-1">Unresolved</resolution>
                                                        <assignee accountid="712020:1a31ac71-0e6f-49af-83f9-0d630a810204">Marcus Hinz</assignee>
                                                                <reporter accountid="712020:0a2bb4c7-7da6-48f0-8264-d021465e5f0f">Lukas Hörger</reporter>
                                    <labels>
                    </labels>
                <created>Wed, 8 Apr 2026 16:57:23 +0200</created>
                <updated>Wed, 1 Jul 2026 14:27:06 +0200</updated>
                                                                                <due></due>
                            <votes>0</votes>
                                    <watches>0</watches>
                                                                        <attachments>
                    </attachments>
                <subtasks>
                                </subtasks>
                <customfields>
                                                <customfield id="customfield_13434" key="com.atlassian.jira.plugin.system.customfieldtypes:textfield">
                        <customfieldname>Ansprechpartner</customfieldname>
                        <customfieldvalues>
                            <customfieldvalue>Lukas</customfieldvalue>

                        </customfieldvalues>
                    </customfield>
                                                                                <customfield id="customfield_10000" key="com.atlassian.jira.plugins.jira-development-integration-plugin:devsummarycf">
                        <customfieldname>Development</customfieldname>
                        <customfieldvalues>

                        </customfieldvalues>
                    </customfield>
                                                                                <customfield id="customfield_10019" key="com.pyxis.greenhopper.jira:gh-lexo-rank">
                        <customfieldname>Rank</customfieldname>
                        <customfieldvalues>
                            <customfieldvalue>0|i5ji9e:</customfieldvalue>

                        </customfieldvalues>
                    </customfield>
                                                                                <customfield id="customfield_13432" key="com.atlassian.jira.plugin.system.customfieldtypes:select">
                        <customfieldname>T-Shirt Size - Storypoint</customfieldname>
                        <customfieldvalues>
                                <customfieldvalue key="20364"><![CDATA[M]]></customfieldvalue>

                        </customfieldvalues>
                    </customfield>
                                                                                <customfield id="customfield_13430" key="com.atlassian.jira.plugin.system.customfieldtypes:textarea">
                        <customfieldname>�� Definition of Ready - Formale Kriterien</customfieldname>
                        <customfieldvalues>
                            <customfieldvalue>
�� Funktionsbeschreibung:

Input: Platzierungs-, Änderungs- oder Lösch-Events von Allround-Schutzzaunkomponenten im Viewport, sowie manuelle Eingaben im Detailmenü.

Verhalten / Ablauf:

Aufspaltung bei Erst-Einbau (Typ 1): Wird ein Allround-Element frei platziert, splittet die Engine dieses in drei 3D-Komponenten: <tt>Vorderer Steher</tt> -&gt; <tt>Zaunelement</tt> -&gt; <tt>Hinterer Steher</tt>. Der vordere Steher ist die Wurzel (Weltkoordinaten/Typ 1). Zaun und hinterer Steher hängen kaskadierend via PMI (Typ 2) daran.

Anbau von Folge-Elementen (Typ 2): Snappt ein neues Element an das Ende eines bestehenden Zauns, wird der hintere Steher des Vorgängers zum gemeinsamen Verbindungssteher. Das neue Zaunelement hängt an dessen PMI.

Selbstheilende Bauform-Aktualisierung: Nach dem Mouse-Up-Event prüft ein lokales Callback die belegten PMI-Seiten des Stehers. Anhand einer exakten Matrix (siehe separater Algorithmus-Abschnitt unten) wird das 3D-Modell in-place gegen die passende Bauform (A-G) ausgetauscht.

Höhen-Synchronisation &amp; Metadaten: Die Standardhöhe ist 2200 mm. Die Steherhöhe folgt automatisch dem Maximum aller verbundenen Zäune: <tt>max(Zaunhöhe 1 + Fußfreiheit 1, Zaunhöhe 2 + Fußfreiheit 2, ...)</tt>. Jeder Steher führt pro Zaun den unteren Startpunkt (Fußfreiheit) und das obere Ende als Metadatum mit. Weicht die Höhe von 2200 mm ab, generiert GEKO einen Sondersteher.

Manuelles Override (Detailmenü):

Höhe: Der Nutzer kann die Höhe manuell vergrößern. Ein Verkleinern unter das berechnete Minimum wird blockiert.

Bauform: Der Nutzer kann die automatisch berechnete Bauform im Detailmenü manuell überschreiben. Wichtig für die UI-Logik: Das Auswahl-Dropdown darf dem Nutzer dynamisch nur die Bauformen (A-G) anbieten, die für die aktuelle "Eigene Anbau-Seite" (STARTPUNKT, L oder R) laut der Ermittlungs-Matrix überhaupt zulässig sind. Ist ein Steher beispielsweise auf Anbau-Seite <tt>L</tt> eingebaut, müssen die Optionen <tt>C</tt> und <tt>E</tt> im Menü ausgeblendet oder deaktiviert werden.

Kaskadierendes Löschverhalten (Sonderregel):

Steher löschen: Alle direkt verbundenen Zäune werden mitgelöscht.

Zaun löschen: Angrenzende Steher ohne weitere Zäune werden mitgelöscht. Angrenzende Steher mit weiteren Zäunen bleiben stehen und aktualisieren ihre Bauform.

Algorithmus: Steher-Bauform ermitteln (<tt>layout_tauschen_steher</tt>)

Die Engine ermittelt für jeden Steher die Seite des Ankers (ist er selbst am Startpunkt/Typ 1 Platzierung, an der linken (L) oder rechten (R) Seite eines Elements angebaut?) und liest die Winkel der aktuell an ihm belegten PMIs (0°, 90°, 180°). Daraus ergibt sich folgende Matrix zur Bestimmung der Bauform-Artikelnummer (A-G):

Eigene Anbau-Seite
Belegte PMIs am Steher
Resultierende Bauform

STARTPUNKT
<tt>0</tt> und <tt>180</tt>
A (ana)

STARTPUNKT
<tt>180</tt>
B (bna)

STARTPUNKT
<tt>0</tt>
C (cna)

STARTPUNKT
<tt>90</tt> und <tt>180</tt>
D (dna)

STARTPUNKT
<tt>0</tt> und <tt>90</tt>
E (ena)

STARTPUNKT
<tt>0</tt>, <tt>90</tt> und <tt>180</tt>
F (fna)

STARTPUNKT
Sonstige
G (gna)

L (Links)
(keine)
B (bna)

L (Links)
<tt>0</tt>
A (ana)

L (Links)
<tt>90</tt>
D (dna)

L (Links)
<tt>0</tt> und <tt>90</tt>
F (fna)

L (Links)
Sonstige
G (gna)

R (Rechts)
(keine)
C (cna)

R (Rechts)
<tt>180</tt>
A (ana)

R (Rechts)
<tt>90</tt> und <tt>180</tt>
F (fna)

R (Rechts)
Sonstige
G (gna)

Nicht-Ziele (Out of Scope)

Echtzeit-Austausch während des Ziehens: Die Bauform- und Höhenberechnung triggert niemals während der Mausbewegung, sondern exakt nach dem Mouse-Up-Event oder nach Bestätigung im Detailmenü.

�� Akzeptanzkriterien:

Jedes Allround-Zaunelement wird beim Platzieren in separat selektierbare Steher- und Zaunkomponenten aufgeteilt.

Beim sequentiellen Anbau teilen sich zwei Zaunelemente exakt einen gemeinsamen Verbindungssteher.

Die Steher-Bauform heilt sich nach dem Mouse-Up gemäß der Matrix (A-G) selbst.

Im Detailmenü kann der Nutzer die Bauform manuell überschreiben. Das UI schränkt die Auswahlmöglichkeiten hierbei strikt und dynamisch auf die für diese Anbau-Seite gültigen Zeilen der Matrix ein.

Die Steherhöhe entspricht mindestens dem Maximum aus <tt>Zaunhöhe + Fußfreiheit</tt>.

Manuelle Höhen-Eingaben unter das Minimum werden im UI blockiert.

Sonderregel Löschen (Steher): Löschen eines Stehers löscht angebundene Zäune.

Sonderregel Löschen (Zaun): Löschen eines Zauns löscht alleinstehende Steher; verbundene Steher bleiben stehen und passen Bauform an.

Sonderregel Löschen (Kaskade): Löschen eines Stehers führt zur Löschung der angebundenen Zäune. Löschung eines angebundenen Zauns führt, falls vorhanden, zur Löschung alleinstehender Steher

�� Test Cases:

&#91;Lösch-Kettenabbruch&#93;: Strang <tt>Steher A -&gt; Zaun 1 -&gt; Steher B -&gt; Zaun 2 -&gt; Steher C</tt> aufbauen -&gt; <tt>Steher A</tt> löschen -&gt; Ergebnis: <tt>Zaun 1</tt> wird gelöscht. <tt>Steher B</tt> bleibt stehen (da <tt>Zaun 2</tt> angebunden) und wechselt Bauform. <tt>Zaun 2</tt> und <tt>Steher C</tt> bleiben unberührt.

&#91;Bauform-Selbstheilung bei Eck-Anbau&#93;: Geraden Zaun platzieren (Verbindungssteher ist Bauform A) -&gt; Neues Zaunelement im 90°-Winkel anbauen -&gt; Ergebnis: Nach Mouse-Up wechselt der Verbindungssteher sofort in die passende Eck-Bauform laut Matrix.

&#91;Manuelle Override-Tests &amp; UI-Restriktion&#93;: Folge-Zaunelement anbauen (Verbindungssteher befindet sich auf Anbau-Seite <tt>L</tt>). Verbindungssteher selektieren und Detailmenü öffnen -&gt; Ergebnis: Das Dropdown für die Bauform bietet nur A, B, D, F und G an. Die Optionen C und E sind nicht wählbar oder ausgeblendet. Der Wechsel auf eine zulässige Bauform (z. B. F) aktualisiert das 3D-Modell sofort.

⚠ Edge Cases:

&#91;Misch-Höhen an einem Steher&#93;: Zäune mit Höhen 2500 mm und 2200 mm treffen sich -&gt; Verhalten: Steher übernimmt die maximale Höhe und wird zum Sondersteher.

</customfieldvalue>

                        </customfieldvalues>
                    </customfield>
                                                                <customfield id="customfield_13429" key="com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes">
                        <customfieldname>�� DoR - Checkliste</customfieldname>
                        <customfieldvalues>
                                <customfieldvalue key="20347"><![CDATA[Ziel eindeutig formuliert  (W-Fragen vollständig?)]]></customfieldvalue>
    <customfieldvalue key="20348"><![CDATA[Ansprechpartner benannt]]></customfieldvalue>
    <customfieldvalue key="20349"><![CDATA[Akzeptanzkriterien definiert]]></customfieldvalue>
    <customfieldvalue key="20350"><![CDATA[mind. ein Testfall dokumentiert]]></customfieldvalue>
    <customfieldvalue key="20351"><![CDATA[Priorität gesetzt]]></customfieldvalue>
    <customfieldvalue key="20352"><![CDATA[Edge Cases beschrieben]]></customfieldvalue>

                        </customfieldvalues>
                    </customfield>
                                                                </customfields>
    </item>
</channel>
</rss>


Gelesen. GEKO-76 spezifiziert genau das Steher-Verhalten — und bestätigt dein „Zäune betrachten, Steher heilen"-Modell, gibt ihm aber die konkreten Regeln.

**Was das Ticket fordert**

- **Split beim Erst-Einbau:** ein Allround-Element zerfällt in **drei** Komponenten: `vorderer Steher → Zaun → hinterer Steher`. Vorderer Steher = Wurzel (Typ 1/Welt), Zaun + hinterer Steher kaskadieren via PMI (Typ 2).
- **Anbau:** der hintere Steher des Vorgängers wird zum **gemeinsamen Verbindungssteher** — zwei Zäune teilen sich *einen* Steher (= Junction, genau dein Modell).
- **Selbstheilung der Bauform (A–G):** nach **Mouse-Up** (nie während des Ziehens) prüft der Hook die **belegten PMI-Winkel** (0/90/180) + die **eigene Anbau-Seite** (STARTPUNKT/L/R) und tauscht die Bauform in-place per Matrix.
- **Höhe:** Standard 2200; Steherhöhe = `max(Zaunhöhe + Fußfreiheit)` über alle verbundenen Zäune; ≠2200 → **Sondersteher**. Metadaten pro Zaun (Fußpunkt + oberes Ende).
- **Löschen:** Steher löschen → verbundene Zäune mit weg. Zaun löschen → allein stehende Steher weg, verbundene bleiben und heilen die Bauform neu.
- **Override:** Höhe (nur vergrößern), Bauform (Dropdown **strikt auf die Matrix-Zeilen der Anbau-Seite** beschränkt — z. B. Seite L blendet C und E aus).

**Der Resolver = diese Matrix** (das, was ich schreiben soll — jetzt konkret):

| Anbau-Seite | belegte PMIs | Bauform |
|---|---|---|
| STARTPUNKT | 0+180 / 180 / 0 / 90+180 / 0+90 / 0+90+180 / sonst | A / B / C / D / E / F / G |
| L | – / 0 / 90 / 0+90 / sonst | B / A / D / F / G |
| R | – / 180 / 90+180 / sonst | C / A / F / G |

→ `SAP4180{A..G}NA` (7 Varianten, nicht nur A–D). Input: eigene Anbau-Seite + Menge der belegten Out-Winkel — beides kommt aus dem **Kanten-Graphen**, kein Geometrie-Nachmessen.

**Wie es auf unser Modell landet**

- Dein „nur Zäune, Steher heilen" **stimmt** und ist jetzt vollständig spezifiziert. Der Heilungs-Hook läuft nach Mouse-Up (= nach dem committeten Event, unser Replay) — passt exakt.
- **Löschen fällt aus der Heilung:** Zaun-Event weg → neu heilen → allein stehende Steher verschwinden, verbundene wechseln Bauform. „Steher löschen" = seine Zäune-Events löschen.
- **Bauform/Höhe-Override** = die undo-baren `module-event`s, die ich schon gebaut habe; die UI-Beschränkung (nur gültige Matrix-Zeilen) ist geko-UI.
- **Der Steher bleibt selektierbar** (fürs Detailmenü/Löschen) — er ist geheilt, steht aber in der Szene, also anklickbar; Override/Delete zielen über seine Slot-Id.

**Zwei Punkte, die ich vor dem Bauen mit dir klären will:**

1. **Wurzel/Anker:** GEKO-76 sagt „vorderer Steher = Wurzel (Welt)". In unserem event-sourced Modell ist der **Zaun** die platzierte, welt-verankerte Entität und die Steher heilen daran. Bleibt es bei zaun-verankert (dann ist „Steher = Wurzel" nur fire-seitige Kaskaden-Topologie), oder muss der Steher wirklich der welt-verankerte Root sein?
2. **Höhe/Sondersteher + Fußfreiheit:** Woher kommen `Zaunhöhe` und `Fußfreiheit` pro Element — aus PMI-Metadaten am Zaun, aus Katalog-Stammdaten, oder liefert das der geko-Resolver? Ohne die Quelle kann die Höhen-Heilung nicht rechnen.


Die Gruppenlogik haben wir in der hand
die geht die Fachseite erst mal nix an

unsere Verankerung in der Welt ist und belibt die gruppe


Verstanden — damit ist Punkt 1 erledigt:

- **Die Gruppe ist und bleibt der Welt-Anker.** Unser `GroupProcessor`-Modell steht: der Gruppenknoten trägt den Welt-Transform, Member (Zäune) hängen dran, Steher heilen innerhalb der Gruppe.
- GEKO-76s „vorderer Steher = Wurzel" ist damit reine **fire-seitige Kaskaden-Topologie** (wie der Szenegraph emittiert wird), nicht unsere Verankerung. Für unser Event-/Grouping-Modell ignorieren wir das — die Fachseite fasst die Gruppenlogik nicht an.

Damit ist der **Bauform-Teil** vollständig baubar: Heilung nach Mouse-Up (unser Replay) über den Kanten-Graphen → belegte Out-Winkel + Anbau-Seite → Matrix → `SAP4180{A..G}NA`. Overrides als `module-event` liegen schon.

Offen bleibt nur die **Höhen-Heilung**: `max(Zaunhöhe + Fußfreiheit)` braucht die Quelle für Zaunhöhe und Fußfreiheit pro Element (PMI-Metadaten am Zaun / Katalog-Stammdaten / geko-Resolver). Bis die klar ist, baue ich den Bauform-Teil; die Höhe kommt nach, sobald die Quelle steht.


Die Gruppenlogik haben wir in der hand die geht die Fachseite erst mal nix an  unsere Verankerung in der Welt ist und belibt die gruppe


das sind konfugurationen --> dafür gibt es JSONS mit den korrekten werten


Damit ist die Spezifikation vollständig — und die Aufteilung sauber:

- **Mechanismus (unser):** Junction-Analyse aus dem Kanten-Graphen → belegte Out-Winkel + Anbau-Seite → **Bauform-Matrix** (A–G) → Höhe = `max(Zaunhöhe + Fußfreiheit)` über die verbundenen Zäune. Plus die undo-baren Overrides.
- **Daten (geko, aus JSON):** die A–G-Steher-Szenen (`SAP4180{A..G}NA`) und die Konfigurationswerte pro Element — `Zaunhöhe` und `Fußfreiheit`.

Der Heilungs-Hook liest also die Config-Werte, rechnet nichts aus der Geometrie. Der Resolver wird damit reicher: pro Steher liefert er nicht nur die Szene, sondern die Config zieht auch die Höhe.

Damit ich es gegen die echten Werte baue statt eine Struktur zu erfinden: **zeig mir eine der Config-JSONs** (Pfad, wie bei GEKO-76.xml) — dann sehe ich die Feldnamen für `Zaunhöhe`/`Fußfreiheit` (und ob sie pro Artikel, pro Klasse oder pro PMI liegen) und verdrahte den Resolver darauf.


wie machen wir das mit den selktor PMIs?


Die Selektor-PMIs bleiben am **geheilten Steher** — aber nur als **UI-Affordance**, nicht als Andockziel. Der Trick ist, dass der stored Anbau **fence-to-fence** bleibt, nie auf den ephemeren Steher zeigt.

**So läuft es:**

1. **Rendern (in der Heilung):** Der Heilungs-Hook setzt die `CS_SELECT`-Böppel am Steher — aber nur auf die **freien Out-Winkel** (die, die die aktuelle Bauform offen lässt; die belegten kennt er aus dem Kanten-Graphen). Damit passt die Marker-Menge automatisch zur Bauform A–G.

2. **Marker kodiert die Junction:** Die Marker-Id trägt zurück, aus welcher **realen Zaun-Kante** der Steher geheilt wurde plus den Winkel — z. B. `sel-marker-h-steher@<ankerZaun>:<ankerPmi>-CS_SELECT_90`. Der Steher selbst ist flüchtig, aber seine Slot-Id (Ankerzaun + PMI) ist stabil.

3. **Klick = fence-to-fence Anbau, gestored:** Der Marker-Handler parst `(Ankerzaun, Ankerpmi, Winkel)` und schreibt ein **normales Event**: neues Zaunelement (`group-create`) + `group-merge` auf den **Ankerzaun** in Richtung Winkel (der geklickte Winkel *ist* die Verzweigungsrotation). Der Steher wird **nicht** referenziert.

4. **Steher heilt neu:** Nach dem Event läuft die Heilung — die Junction hat jetzt zwei Zäune → der Steher wird zum gemeinsamen **Verbindungssteher**, Bauform wechselt per Matrix (z. B. gerade A → Ecke E/D), neue freie Winkel → neue Böppel.

**Damit bleibt die Invariante intakt:** Zäune sind gestored, Steher sind reine Heilung, und der Anbau hängt immer an einem realen Zaun. Die Steher-PMIs sind nur der Aufhänger.

**Ein konkreter Fix fällt an:** Die alte `handleMarkerBuild`-Seitenableitung (`_R_`/`_L_` → `CS_EIN_L/R`) passt nicht zur Winkel-Nomenklatur des Stehers (`CS_SELECT_0/90/180/270`). Der neue Handler nimmt den **Winkel** direkt als Richtung/Rotation, statt eine Seite zu raten.

Offen bleibt nur, an *welchen* der beiden Zäune ein Verbindungssteher-Marker anknüpft (Slot-Anker aus der Id ist der kanonische) — geometrisch identisch, nur eine Konventionsfrage.


NEIN
Böppel müssen in alle richtungen, in denen nichts belegt ist
das entscheidet NICHT die Bauform --> nach Anbau wird die richtige Bauform gewählt


Verstanden, korrigiert:

**Böppel = alle *unbelegten* Richtungen** (`CS_SELECT_0/90/180/270` minus die, an denen ein Zaun hängt). Die Belegung kommt aus dem Kanten-Graphen, **nicht** aus der Bauform.

Die Kausalität ist andersherum als ich sie hatte:
1. Böppel zeigen jede freie Richtung — egal welche Bauform der Steher gerade hat.
2. Klick → Zaun in dieser Richtung angebaut → die Richtung ist jetzt belegt.
3. **Erst danach** wählt die Heilung die passende Bauform aus den nun belegten PMIs (Matrix A–G).

Also: **Belegung steuert die Böppel, der Anbau steuert die Bauform** — die Bauform ist reine Folge, nie ein Filter für das, was man bauen kann.

Der Rest bleibt: Marker kodiert Junction + Winkel, Klick schreibt einen fence-to-fence Anbau in Richtung des Winkels, Steher heilt neu (Bauform + freie Böppel aktualisieren sich).


gut
kann man das ganze auch jetzt schon für Move bauen?


Ja — und zwar **ohne Extra-Logik**, weil die Heilung event-agnostisch ist. Der Hook läuft in jedem Replay, egal ob Anbau, Move, Rotate oder Undo. Move fällt damit von selbst raus:

- **Nach Mouse-Up** (das Move-End-Event) → Replay → Zäune liegen an der neuen Position → die Heilung dockt die Steher neu an → fertig. Das ist genau die GEKO-76-Regel „niemals während des Ziehens, exakt nach Mouse-Up".
- **Während des Ziehens** bewegt fire die Gruppe rigide — die Steher sind in der Gruppe (reingeheilt), also wandern sie mit. Kein Re-Heal bis Mouse-Up.
- Bei reiner Translation ist geheilte Position = verschobene Position → kein Sprung. Bei Rotation korrigiert der Re-Heal auf Mouse-Up (Bauform/Orientierung), was fachlich so gewollt ist.

Das ist derselbe Hook wie für den Anbau — „für Move bauen" heißt nur: den Heilungs-Hook bauen, Move ist dann automatisch abgedeckt.

**Eine Grenze:** Ein *freier* Move erzeugt **keine** neue Junction durch bloße Nähe. Zwei getrennte Stränge zusammenschieben verbindet sie nicht automatisch (wir haben bewusst keine geometrische Junction-Erkennung — Junctions kommen aus Kanten/Merges). Das Verbinden zweier Stränge bleibt der **Snap-/Merge-Pfad** (Placement), nicht der freie Move.

Kurz: Move braucht kein eigenes Feature. Sobald der Heilungs-Hook steht, gilt er für Move mit.


gut kann man das ganze auch jetzt schon für Move bauen?


dann mal los


Los. Ich baue den Kern von GEKO-76 zuerst als eigenständige, testbare Einheit: die **Bauform-Matrix** (Anbau-Seite + belegte Winkel → A–G), plus die pro-Seite gültigen Bauformen für die spätere UI-Beschränkung. Das ist vollständig spezifiziert und gegen die Ticket-Testfälle prüfbar.


Write

// GEKO-76 "layout_tauschen_steher": the Steher's Bauform (A–G) is a pure function of its own attach
// side (STARTPUNKT / L / R) and the set of occupied out-angles (0 / 90 / 180). This is the healing
// core — junction analysis feeds it (side + occupied angles), it returns the variant letter, geko maps
// the letter to a concrete part (SAP4180<letter>NA).

export type Bauform = "A" | "B" | "C" | "D" | "E" | "F" | "G";
export type AnbauSide = "START" | "L" | "R";
export type SteherAngle = 0 | 90 | 180;

// Exact match: the occupied set equals exactly the listed angles.
const eq = (set: Set<SteherAngle>, ...angles: SteherAngle[]): boolean =>
    set.size === angles.length && angles.every((a) => set.has(a));

// The GEKO-76 matrix. Each side's rows are mutually exclusive; anything unlisted falls to G.
export function steherBauform(side: AnbauSide, occupied: Iterable<SteherAngle>): Bauform {
    const set = new Set(occupied);
    switch (side) {
        case "START":
            if (eq(set, 0, 180)) return "A";
            if (eq(set, 180)) return "B";
            if (eq(set, 0)) return "C";
            if (eq(set, 90, 180)) return "D";
            if (eq(set, 0, 90)) return "E";
            if (eq(set, 0, 90, 180)) return "F";
            return "G";
        case "L":
            if (eq(set)) return "B";
            if (eq(set, 0)) return "A";
            if (eq(set, 90)) return "D";
            if (eq(set, 0, 90)) return "F";
            return "G";
        case "R":
            if (eq(set)) return "C";
            if (eq(set, 180)) return "A";
            if (eq(set, 90, 180)) return "F";
            return "G";
    }
}

// The Bauformen reachable from a given side — the whitelist a manual-override UI must enforce
// (e.g. side L never offers C or E). Mirrors the rows above.
export function validBauformen(side: AnbauSide): Bauform[] {
    switch (side) {
        case "START":
            return ["A", "B", "C", "D", "E", "F", "G"];
        case "L":
            return ["A", "B", "D", "F", "G"];
        case "R":
            return ["A", "C", "F", "G"];
    }
}


File created successfully at: /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/modules/steherBauform.ts (file state is current in your context — no need to Read it back)

// GEKO-76 "layout_tauschen_steher": the Steher's Bauform (A–G) is a pure function of its own attach
// side (STARTPUNKT / L / R) and the set of occupied out-angles (0 / 90 / 180). This is the healing
// core — junction analysis feeds it (side + occupied angles), it returns the variant letter, geko maps
// the letter to a concrete part (SAP4180<letter>NA).

export type Bauform = "A" | "B" | "C" | "D" | "E" | "F" | "G";
export type AnbauSide = "START" | "L" | "R";
export type SteherAngle = 0 | 90 | 180;

// Exact match: the occupied set equals exactly the listed angles.
const eq = (set: Set<SteherAngle>, ...angles: SteherAngle[]): boolean =>
    set.size === angles.length && angles.every((a) => set.has(a));

// The GEKO-76 matrix. Each side's rows are mutually exclusive; anything unlisted falls to G.
export function steherBauform(side: AnbauSide, occupied: Iterable<SteherAngle>): Bauform {
    const set = new Set(occupied);
    switch (side) {
        case "START":
            if (eq(set, 0, 180)) return "A";
            if (eq(set, 180)) return "B";
            if (eq(set, 0)) return "C";
            if (eq(set, 90, 180)) return "D";
            if (eq(set, 0, 90)) return "E";
            if (eq(set, 0, 90, 180)) return "F";
            return "G";
        case "L":
            if (eq(set)) return "B";
            if (eq(set, 0)) return "A";
            if (eq(set, 90)) return "D";
            if (eq(set, 0, 90)) return "F";
            return "G";
        case "R":
            if (eq(set)) return "C";
            if (eq(set, 180)) return "A";
            if (eq(set, 90, 180)) return "F";
            return "G";
    }
}

// The Bauformen reachable from a given side — the whitelist a manual-override UI must enforce
// (e.g. side L never offers C or E). Mirrors the rows above.
export function validBauformen(side: AnbauSide): Bauform[] {
    switch (side) {
        case "START":
            return ["A", "B", "C", "D", "E", "F", "G"];
        case "L":
            return ["A", "B", "D", "F", "G"];
        case "R":
            return ["A", "C", "F", "G"];
    }
}


import { describe, it, expect } from "vitest";
import { steherBauform, validBauformen, type Bauform, type SteherAngle } from "./steherBauform";

describe("steherBauform — GEKO-76 matrix", () => {
    // Every row of the ticket's matrix, verbatim.
    const rows: Array<["START" | "L" | "R", SteherAngle[], Bauform]> = [
        ["START", [0, 180], "A"],
        ["START", [180], "B"],
        ["START", [0], "C"],
        ["START", [90, 180], "D"],
        ["START", [0, 90], "E"],
        ["START", [0, 90, 180], "F"],
        ["START", [90], "G"], // sonstige
        ["START", [], "G"], // sonstige
        ["L", [], "B"],
        ["L", [0], "A"],
        ["L", [90], "D"],
        ["L", [0, 90], "F"],
        ["L", [180], "G"], // sonstige
        ["R", [], "C"],
        ["R", [180], "A"],
        ["R", [90, 180], "F"],
        ["R", [0], "G"], // sonstige
    ];

    it.each(rows)("%s + %j → %s", (side, occupied, expected) => {
        expect(steherBauform(side, occupied)).toBe(expected);
    });

    it("is order-independent in the occupied set", () => {
        expect(steherBauform("START", [180, 0])).toBe("A");
        expect(steherBauform("START", [180, 90])).toBe("D");
    });

    it("side L never yields C or E (UI restriction from the ticket)", () => {
        const combos: SteherAngle[][] = [[], [0], [90], [180], [0, 90], [0, 180], [90, 180], [0, 90, 180]];
        for (const c of combos) expect(["C", "E"]).not.toContain(steherBauform("L", c));
    });

    it("side R never yields B, D or E", () => {
        const combos: SteherAngle[][] = [[], [0], [90], [180], [0, 90], [0, 180], [90, 180], [0, 90, 180]];
        for (const c of combos) expect(["B", "D", "E"]).not.toContain(steherBauform("R", c));
    });

    it("validBauformen matches the ticket (L offers A,B,D,F,G; C,E hidden)", () => {
        expect(validBauformen("L")).toEqual(["A", "B", "D", "F", "G"]);
        expect(validBauformen("R")).toEqual(["A", "C", "F", "G"]);
        expect(validBauformen("START")).toEqual(["A", "B", "C", "D", "E", "F", "G"]);
    });
});


File created successfully at: /Users/marcus.hinz/projects/adesso-cpq/packages/frontend/src/configurator/modules/steherBauform.test.ts (file state is current in your context — no need to Read it back)

import { describe, it, expect } from "vitest";
import { steherBauform, validBauformen, type Bauform, type SteherAngle } from "./steherBauform";

describe("steherBauform — GEKO-76 matrix", () => {
    // Every row of the ticket's matrix, verbatim.
    const rows: Array<["START" | "L" | "R", SteherAngle[], Bauform]> = [
        ["START", [0, 180], "A"],
        ["START", [180], "B"],
        ["START", [0], "C"],
        ["START", [90, 180], "D"],
        ["START", [0, 90], "E"],
        ["START", [0, 90, 180], "F"],
        ["START", [90], "G"], // sonstige
        ["START", [], "G"], // sonstige
        ["L", [], "B"],
        ["L", [0], "A"],
        ["L", [90], "D"],
        ["L", [0, 90], "F"],
        ["L", [180], "G"], // sonstige
        ["R", [], "C"],
        ["R", [180], "A"],
        ["R", [90, 180], "F"],
        ["R", [0], "G"], // sonstige
    ];

    it.each(rows)("%s + %j → %s", (side, occupied, expected) => {
        expect(steherBauform(side, occupied)).toBe(expected);
    });

    it("is order-independent in the occupied set", () => {
        expect(steherBauform("START", [180, 0])).toBe("A");
        expect(steherBauform("START", [180, 90])).toBe("D");
    });

    it("side L never yields C or E (UI restriction from the ticket)", () => {
        const combos: SteherAngle[][] = [[], [0], [90], [180], [0, 90], [0, 180], [90, 180], [0, 90, 180]];
        for (const c of combos) expect(["C", "E"]).not.toContain(steherBauform("L", c));
    });

    it("side R never yields B, D or E", () => {
        const combos: SteherAngle[][] = [[], [0], [90], [180], [0, 90], [0, 180], [90, 180], [0, 90, 180]];
        for (const c of combos) expect(["B", "D", "E"]).not.toContain(steherBauform("R", c));
    });

    it("validBauformen matches the ticket (L offers A,B,D,F,G; C,E hidden)", () => {
        expect(validBauformen("L")).toEqual(["A", "B", "D", "F", "G"]);
        expect(validBauformen("R")).toEqual(["A", "C", "F", "G"]);
        expect(validBauformen("START")).toEqual(["A", "B", "C", "D", "E", "F", "G"]);
    });
});


Bash

cd /Users/marcus.hinz/projects/adesso-cpq && pnpm --filter @ad-cpq/common-frontend exec vitest run src/configurator/modules/steherBauform.test.ts 2>&1 | grep -E 'Test Files|Tests |✗|×|failed' | head; echo "=== A–G Steher-Szenen vorhanden? ==="; cd packages/frontend/.storybook/products 2>/dev/null; for v in A B C D E F G; do f="schutzzaun_allround/SAP4180${v}NA.json"; [ -f "$f" ] && echo "$v: da" || echo "$v: FEHLT"; done

Test the matrix and check A–G scene availability


 Test Files  1 passed (1)
      Tests  21 passed (21)
=== A–G Steher-Szenen vorhanden? ===
A: da
B: da
C: da
D: da
E: da
F: da
G: da


Matrix steht — 21 Tests grün — und **alle 7** Steher-Szenen `SAP4180{A..G}NA` existieren. Volle Suite + Typecheck zur Absicherung.


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

Full suite and typecheck


 Test Files  10 passed (10)
      Tests  137 passed (137)
---done---


Matrix ist der verifizierte Kern — 137 Tests grün, Typecheck sauber, alle A–G-Szenen vorhanden. Ich exportiere sie noch fürs geko-UI.


Edit

export type { PlacementModule, CommandContext } from "./configurator/events/PlacementModule";

export type { PlacementModule, CommandContext } from "./configurator/events/PlacementModule";
export { steherBauform, validBauformen } from "./configurator/modules/steherBauform";
export type { Bauform, AnbauSide, SteherAngle } from "./configurator/modules/steherBauform";


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

export type { PlacementModule, CommandContext } from "./configurator/events/PlacementModule";
export { steherBauform, validBauformen } from "./configurator/modules/steherBauform";
export type { Bauform, AnbauSide, SteherAngle } from "./configurator/modules/steherBauform";

import "./css/theme.css";
import "./css/base.css";
import "./css/product-browser.css";

// Inputs
export { default as FieldWrapper } from "./components/inputs/FieldWrapper.vue";
export { default as StringInput } from "./components/inputs/StringInput.vue";
export { default as NumberInput } from "./components/inputs/NumberInput.vue";
export { default as SelectInput } from "./components/inputs/SelectInput.vue";
export { default as TextInput } from "./components/inputs/TextInput.vue";
export { default as ListSection } from "./components/inputs/ListSection.vue";
export type { Option } from "./components/inputs/SelectInput.vue";

// Visual
export { default as Fire3D } from "./components/visual/Fire3D.vue";
export { default as ProductConfigurator } from "./components/visual/ProductConfigurator.vue";
export { CommandProcessor } from "./configurator/events/CommandProcessor";
export { DefaultCommandProcessor } from "./configurator/events/DefaultCommandProcessor";
export { GekoPlacementModule } from "./configurator/modules/GekoPlacementModule";
export type { DockingRules, SteherRule, SteherRules, SteherContext, SteherPartResolver } from "./configurator/modules/GekoPlacementModule";
export type { PlacementModule, CommandContext } from "./configurator/events/PlacementModule";
export type { UIReaction, MenuEntry } from "./configurator/types";

// Building blocks
export { default as ProductBrowserCard } from "./components/building-blocks/ProductBrowser.vue";
export type { ProductBrowserTheme, CatalogNode } from "./components/building-blocks/ProductBrowser.vue";
export { default as ProductBrowserPanel } from "./components/building-blocks/ProductBrowserPanel.vue";
export { default as ProductSettingsPanel } from "./components/building-blocks/ProductSettingsPanel.vue";
export { default as ModalWindow } from "./components/building-blocks/ModalWindow.vue";
export { default as ToastNotification } from "./components/building-blocks/ToastNotification.vue";
export { default as AppDialog } from "./components/building-blocks/AppDialog.vue";
export { default as ConfirmDialog } from "./components/building-blocks/ConfirmDialog.vue";
export { default as ModalBackdrop } from "./components/building-blocks/ModalBackdrop.vue";
export { default as EntityCard } from "./components/building-blocks/EntityCard.vue";
export { default as CardList } from "./components/building-blocks/CardList.vue";
export { default as ValidationErrorIcon } from "./components/inputs/ValidationErrorIcon.vue";

// Base widgets (non-BLAST, themeable)
export { default as BaseButton } from "./components/base/BaseButton.vue";
export { default as IconButton } from "./components/base/IconButton.vue";
export { default as BaseInput } from "./components/base/BaseInput.vue";
export { default as BaseSelect } from "./components/base/BaseSelect.vue";
export { default as BaseTextarea } from "./components/base/BaseTextarea.vue";

// Theming
export { provideTheme, useTheme, themeToVars } from "./composables/useTheme";
export type { AppTheme } from "./composables/useTheme";

// Composables
export { useModal, createModal } from "./composables/useModal";
export type { ModalOptions } from "./composables/useModal";
export { provideDiagnosticFormatter, useDiagnosticFormatter } from "./composables/useDiagnosticFormatter";
export type { DiagnosticFormatter, DiagnosticFormatContext } from "./composables/useDiagnosticFormatter";
export { useCardListManager } from "./composables/useCardListManager";
export type { CardListManagerOptions } from "./composables/useCardListManager";

+export { steherBauform, validBauformen } from "./configurator/modules/steherBauform";

+export type { Bauform, AnbauSide, SteherAngle } from "./configurator/modules/steherBauform";


Erledigt und eingebaut:

**Gebaut (GEKO-76-Kern):** `steherBauform(side, occupiedAngles) → A–G` als reine, gegen jede Matrix-Zeile getestete Funktion (21 Tests), plus `validBauformen(side)` fürs Override-UI. Exportiert. Alle 7 Szenen `SAP4180{A..G}NA` sind da.

Das ist das Herz der Heilung — Junction-Analyse füttert es mit `(Seite, belegte Winkel)`, es liefert den Buchstaben, geko mappt auf die Szene.

**Nächster Schritt: die Junction-Analyse** (derive von „pro offenem PMI" auf „pro Junction aus dem Kanten-Graphen" umbauen). Bevor ich die Geometrie baue, zwei Dinge, die ich nicht aus dem Code ableiten kann — sonst wähle ich falsche Bauformen:

1. **Anbau-Seite eines Stehers (START / L / R):** Woran erkennt die Engine das in unserem Modell? START = der Typ-1-/Start-Steher eines frei platzierten Zauns; L/R = an welchem Ende (`CS_OUT_L` / `CS_OUT_R`) des Zauns der Steher heilt? Oder hängt es an der Kante (from/to), die zur Junction führt?

2. **Belegte Out-Winkel (0/90/180):** Wie kommt aus den Zaun-Kanten an einer Junction die Winkelmenge relativ zum Steher-Frame? Also: gegeben die Zäune, die an einem Steher-Punkt zusammenlaufen — wie mappt ihre Richtung auf `0/90/180` in der Steher-Nomenklatur?

Gib mir diese zwei Zuordnungen, dann baue ich die Junction-Heilung darauf und die Matrix greift.


der hook zieht nicht bei move
ich hätte erwartet, dass zwei neue steher ein und zwei verwaist ausgebaut werden


„Hook zieht nicht bei move" ist eine konkrete Behauptung — ich prüfe erst, ob `derive` beim Move überhaupt läuft, bevor ich rate. Schneller Test.


1	import { describe, it, expect } from "vitest";
2	import { DefaultCommandProcessor } from "../events/DefaultCommandProcessor";
3	import { GekoPlacementModule, type SteherRules, type SteherPartResolver } from "./GekoPlacementModule";
4	import productData from "../../../.storybook/products/schutzzaun_basic/SBS1044PRA.json";
5
6	// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (via the resolver,
7	// reusing the same scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open
8	// CS_OUT_R_0 end via CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
9	const RULES: SteherRules = [{ anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L" } }];
10
11	// Stand-in resolver: geko owns part choice. Here it returns the fixture scene, or null when the slot's
12	// override says "suppress".
13	const resolver: SteherPartResolver = (ctx) => (ctx.override === "suppress" ? null : productData);
14
15	const product = () => structuredClone(productData);
16	const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
17	const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
18	const parts = (scene: any): string[] =>
19	    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
20	const groupNodeOf = (scene: any, part: string) =>
21	    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
22	        .parentKey;
23	const steherOf = (scene: any) => parts(scene).find((id) => id.startsWith("h-steher"));
24	const anchorOf = (scene: any) => parts(scene).find((id) => !id.startsWith("h-steher"));
25
26	function setup() {
27	    const proc = new DefaultCommandProcessor();
28	    proc.register(new GekoPlacementModule({}, RULES, resolver));
29	    proc.setInitialScene(base());
30	    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
31	    const anchor = anchorOf(scene)!;
32	    const slotId = `steher@${anchor}:CS_OUT_R_0`;
33	    return { proc, scene, anchor, slotId };
34	}
35
36	describe("Steher derivation", () => {
37	    it("derives a Steher coupled to the anchor's group", () => {
38	        const { scene, anchor } = setup();
39	        const p = parts(scene);
40	        expect(p).toHaveLength(2); // anchor + derived Steher

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

// The fixture is a schutzzaun_basic-tuer. It stands in as both the anchor class and (via the resolver,
// reusing the same scene) the derived "Steher" part: a Steher is auto-docked onto every tuer's open
// CS_OUT_R_0 end via CS_EIN_L. That is enough to exercise the derivation machinery end-to-end.
const RULES: SteherRules = [{ anchorClass: "schutzzaun_basic-tuer", joins: { CS_OUT_R_0: "CS_EIN_L" } }];

// Stand-in resolver: geko owns part choice. Here it returns the fixture scene, or null when the slot's
// override says "suppress".
const resolver: SteherPartResolver = (ctx) => (ctx.override === "suppress" ? null : productData);

const product = () => structuredClone(productData);
const base = () => ({ _resourceClass: "sceneGraph_entity", handles: [] as any[] });
const sceneOf = (r: any[]) => (r.find((x: any) => x.cmd === "setScene") as any)?.args[0];
const parts = (scene: any): string[] =>
    scene.handles.filter((h: any) => (h.tags ?? []).includes("role-part")).map((h: any) => h.id);
const groupNodeOf = (scene: any, part: string) =>
    scene.handles.find((h: any) => h.id === part).components.find((c: any) => c._resourceClass === "component_transform")
        .parentKey;
const steherOf = (scene: any) => parts(scene).find((id) => id.startsWith("h-steher"));
const anchorOf = (scene: any) => parts(scene).find((id) => !id.startsWith("h-steher"));

function setup() {
    const proc = new DefaultCommandProcessor();
    proc.register(new GekoPlacementModule({}, RULES, resolver));
    proc.setInitialScene(base());
    const scene = sceneOf(proc.handleEvent({ eventTypeName: "add-product", handles: product().handles }));
    const anchor = anchorOf(scene)!;
    const slotId = `steher@${anchor}:CS_OUT_R_0`;
    return { proc, scene, anchor, slotId };
}

describe("Steher derivation", () => {
    it("derives a Steher coupled to the anchor's group", () => {
        const { scene, anchor } = setup();
        const p = parts(scene);
        expect(p).toHaveLength(2); // anchor + derived Steher


der hook zieht nicht bei move ich hätte erwartet, dass zwei neue steher ein und zwei verwaist ausgebaut werden


it("is never recorded — no undo step exists for the derived Steher", () => {

    it("re-heals on move-end (hook runs on every replay, incl. move)", () => {
        const { proc, scene, anchor } = setup();
        const groupKey = groupNodeOf(scene, anchor);
        const moved = sceneOf(
            proc.handleEvent({
                eventTypeName: "interaction-mode",
                modeName: "movement",
                lifecycleState: "update",
                subjects: [{ entity: { id: groupKey } }],
                positions: [{ values: [500, 0, 0] }],
                delta: { values: [500, 0, 0] },
            }),
        );
        expect(parts(moved)).toHaveLength(2); // Steher still there after move → derive ran on move-end
        const steher = steherOf(moved)!;
        expect(groupNodeOf(moved, steher)).toBe(groupNodeOf(moved, anchor)); // still coupled
    });

    it("is never recorded — no undo step exists for the derived Steher", () => {