1	import {InteractionMode, InteractionModeSettings} from './interactionMode';
2	import {BasicMaterialAsset, CameraComponent, Handle, MeshGeometryAsset, selectorElementReferenceRepresentation} from '@fire-visual/engine';
3	import {Color, intersection, Matrix4_4, MeshFactory, Plane, Quaternion, Ray, valueEqual, Vector2, Vector3} from '@fire-visual/math';
4	import {SceneGraphModel} from '@fire-visual/engine/types/dataManager/model';
5
6	export interface PlacementModeSettings extends InteractionModeSettings {
7	    camera: CameraComponent;
8	    subject: Handle;
9	    defaultCursorTransform?: Matrix4_4;
10	    dragPoints: Array<{
11	        transform: Matrix4_4;
12	        dropPoints: Partial<DropPointProvider>[];
13	        /** The selector of the drag point entity itself (not the query used to find it). */
14	        selector?: string;
15	    }>;
16	    /**
17	     * When false, the "all drag points" state (index -1) is not allowed.
18	     * Cycling skips -1 and always keeps a specific drag point active.
19	     * Defaults to true.
20	     */
21	    allowAllDragPoints?: boolean;
22	    /**
23	     * When false, user input (nextDragPoint / previousDragPoint) cannot change
24	     * the active drag point, keeping "all drag points" active at all times.
25	     * Defaults to true.
26	     */
27	    allowDragPointCycling?: boolean;
28	    /**
29	     * Overrides the global maxJumpDistance from ModeOptionProvider for this
30	     * placement mode instance specifically.
31	     */
32	    maxJumpDistance?: number;
33	}
34
35	export class PlacementMode extends InteractionMode<PlacementModeSettings> {
36
37	    private get gizmoScale(): number {
38	        return this.modeOptionProvider?.gizmoScale ?? 1;
39	    }
40
41	    private get maxJumpDistance(): number {
42	        return this._settings.maxJumpDistance
43	            ?? this.modeOptionProvider?.options.placement.maxJumpDistance
44	            ?? 1;
45	    }
46
47	    private get highlightColor(): Color {
48	        return this.modeOptionProvider?.options.placement.highlightColor ?? Color.yellow;
49	    }
50
51	    private get dragPointActiveColor(): Color {
52	        return this.modeOptionProvider?.options.placement.dragPointActiveColor ?? Color.yellow;
53	    }
54
55	    private get dragPointInactiveColor(): Color {
56	        return this.modeOptionProvider?.options.placement.dragPointInactiveColor ?? Color.blue;
57	    }
58
59	    private get dragPointRadius(): number {
60	        return this.modeOptionProvider?.options.placement.dragPointRadius ?? 0.1;
61	    }
62
63	    private dropPoints: DropPoint[][] = [];
64	    // index of active drag point, -1 for "all drag points"
65	    private activeDragPointIndex: number;
66	    private dragPointHandles: Handle[] = [];
67	    private dragPointBlueMaterial: BasicMaterialAsset;
68	    private dragPointYellowMaterial: BasicMaterialAsset;
69	    private _grabActive: boolean = true;
70	    private _prevInteractionClick: boolean = false;
71	    private _lastPlacement: PlacementUpdateData | null = null;
72
73	    constructor(settings: PlacementModeSettings) {
74	        super(settings);
75	        this.activeDragPointIndex = settings.allowAllDragPoints === false ? 0 : -1;
76	    }
77
78	    getModeName(): string {
79	        return 'placement';
80	    }
81
82	    public processInput(input: PlacementInputMap): boolean {
83
84	        if (!input.pointerNormalizedPosition || !this._settings.camera) {
85	            return false;
86	        }
87
88	        // Rising edge: resume movement
89	        if (!this._prevInteractionClick && input.interactionClick) {
90	            this._grabActive = true;
91	        }
92	        if (!this._grabActive) {
93	            this._prevInteractionClick = input.interactionClick;
94	            return true;
95	        }
96
97	        this.cycleDragPointForInput(input);
98
99	        // Reset all visualizations first — false before true so shared underlying objects settle correctly
100	        this.dropPoints.forEach(group => group.forEach(dp => {
101	            dp.setVisualization(false);
102	            dp.setVisualizationHighlight(null);
103	        }));
104
105	        // Activate drop points that belong to the currently active drag point(s)
106	        if (this.activeDragPointIndex >= 0) {
107	            this.dropPoints[this.activeDragPointIndex]?.forEach(dp => dp.setVisualization(true));
108	        } else {
109	            this.dropPoints.forEach(group => group.forEach(dp => dp.setVisualization(true)));
110	        }
111
112	        // Cast ray from camera through pointer position
113	        const pointingRay = this._settings.camera.directionOfScreenPoint(input.pointerNormalizedPosition);
114
115	        // Find intersection of ray with XZ plane
116	        const xzPlane = Plane.createFromPoint(Vector3.zero, Vector3.up);
117	        const intersectionPoint = intersection.rayAgainstPlane(pointingRay, xzPlane);
118
119	        const assessments = this.mapActiveDragPoints(
120	            (dragPoint, i) => {
121	                const cursorFromDragPoint = dragPoint.transform.inverse.multiply(this.cursorTransform);
122	                return this.dropPoints[i].map(dropPoint => {
123	                    const offset = dropPoint.predictDragPointRotation(pointingRay).multiply(cursorFromDragPoint).multiplyPoint(Vector3.zero);
124	                    const offsetRay = Ray.create(pointingRay.origin.subtract(offset), pointingRay.direction);
125	                    return {
126	                        ...dropPoint.assessRay(offsetRay),
127	                        dragPointTransform: dragPoint.transform,
128	                        dragPointIndex: i,
129	                        dropPoint
130	                    }
131	                });
132	            }
133	        );
134	        // get assessment with shortest jump distance (shortest rayDistance in case of tie)
135	        const best = assessments.reduce(this.betterRayAssessment.bind(this), null);
136
137	        if (best != null && best.jumpDistance < this.maxJumpDistance) {
138	            best.dropPoint.setVisualizationHighlight(this.highlightColor);
139	        }
140
141	        if (this._settings.subject?.transform) {
142	            if (best != null && best.jumpDistance < this.maxJumpDistance) {
143	                // after this, we want subject.matrix * dragPoint.transform = best.matrix
144	                // thus subject.matrix = best.matrix * dragPoint.transform⁻¹
145	                const bestMatrix = Matrix4_4.compose(best.position, best.rotation, Vector3.one);
146	                this._settings.subject.transform.matrix = bestMatrix.multiply(best.dragPointTransform.inverse);
147	                this._lastPlacement = {
148	                    position: best.position,
149	                    rotation: best.rotation,
150	                    dragPointSelector: this._settings.dragPoints[best.dragPointIndex]?.selector ?? null,
151	                    dropPointSelector: best.dropPoint.selector()
152	                };
153	            } else if (intersectionPoint) {
154	                // Move subject to intersection point
155	                const targetLocation = Matrix4_4.compose(intersectionPoint, Quaternion.identity, Vector3.one);
156	                this._settings.subject.transform.matrix = targetLocation.multiply(this.cursorTransform.inverse);
157	                this._lastPlacement = {
158	                    position: intersectionPoint,
159	                    rotation: Quaternion.identity,
160	                    dragPointSelector: null,
161	                    dropPointSelector: null
162	                };
163	            }
164	        }
165
166	        // Update drag point sphere positions after the subject has moved.
167	        // Drag points are in subject-local space, so multiply by subject's world matrix.
168	        const subjectMatrix = this._settings.subject?.transform?.matrix ?? Matrix4_4.identity;
169	        this._settings.dragPoints.forEach((dragPoint, i) => {
170	            const handle = this.dragPointHandles[i];
171	            if (!handle) return;
172	            handle.transform.position = subjectMatrix.multiply(dragPoint.transform).multiplyPoint(Vector3.zero);
173	            const isActive = this.activeDragPointIndex < 0 || this.activeDragPointIndex === i;
174	            handle.renderer.materials = [isActive ? this.dragPointYellowMaterial : this.dragPointBlueMaterial];
175	        });
176
177	        // Falling edge: stop movement and emit last valid placement
178	        if (this._prevInteractionClick && !input.interactionClick) {
179	            this._grabActive = false;
180	            if (this._lastPlacement) {
181	                this.emitUpdateEvent({
182	                    subject: selectorElementReferenceRepresentation(this.settings.subject),
183	                    ...this._lastPlacement
184	                });
185	            }
186	        }
187
188	        this._prevInteractionClick = input.interactionClick;
189	        return true;
190	    }
191
192	    public async startMode(): Promise<void> {
193	        this.dropPoints = [];
194	        for(const dragPoint of this._settings.dragPoints) {
195	            this.dropPoints.push(this.extractDropPoints(dragPoint.dropPoints));
196	        }
197
198	        await this.createDragPointVisuals();
199
200	        await super.startMode();
201	    }
202
203	    public async stopMode(): Promise<void> {
204	        this.dropPoints = [];
205	        this.dragPointHandles = [];
206	        await super.stopMode();
207	    }
208
209	    private cycleDragPointForInput(input: PlacementInputMap) {
210	        if (this._settings.allowDragPointCycling !== false) {
211	            if (input.nextDragPoint) {
212	                this.nextDragPoint();
213	            }
214
215	            if (input.previousDragPoint) {
216	                this.previousDragPoint();
217	            }
218	        }
219	    }
220
221	    public nextDragPoint(): void {
222	        this.activeDragPointIndex = this.nextDragPointIndex();
223	    }
224
225	    public previousDragPoint(): void {
226	        this.activeDragPointIndex = this.previousDragPointIndex();
227	    }
228
229	    private nextDragPointIndex(): number {
230	        const last = this._settings.dragPoints.length - 1;
231	        if (this._settings.allowAllDragPoints === false) {
232	            return this.activeDragPointIndex >= last ? 0 : this.activeDragPointIndex + 1;
233	        }
234	        return this.activeDragPointIndex >= last ? -1 : this.activeDragPointIndex + 1;
235	    }
236
237	    private previousDragPointIndex(): number {
238	        const last = this._settings.dragPoints.length - 1;
239	        if (this._settings.allowAllDragPoints === false) {
240	            return this.activeDragPointIndex <= 0 ? last : this.activeDragPointIndex - 1;
241	        }
242	        return this.activeDragPointIndex <= -1 ? last : this.activeDragPointIndex - 1;
243	    }
244
245	    private mapActiveDragPoints<T>(func: (dragPoint: { transform: Matrix4_4 }, index: number) => T[]): T[] {
246	        if (this.activeDragPointIndex >= 0) {
247	            return func(this._settings.dragPoints[this.activeDragPointIndex], this.activeDragPointIndex);
248	        } else {
249	            return this._settings.dragPoints.flatMap((x, i) => func(x, i));
250	        }
251	    }
252
253	    private async createDragPointVisuals(): Promise<void> {
254	        if (this._settings.dragPoints.length === 0) return;
255
256	        const r = this.dragPointRadius * this.gizmoScale;
257	        const sphereGeometry = new MeshGeometryAsset(MeshFactory.sphere(new Vector3(r, r, r)));
258	        this.dragPointBlueMaterial = new BasicMaterialAsset({diffuse: this.dragPointInactiveColor, transparent: true, opacity: 0.75});
259	        this.dragPointYellowMaterial = new BasicMaterialAsset({diffuse: this.dragPointActiveColor, transparent: true, opacity: 0.75});
260
261	        const sceneModel: SceneGraphModel = {
262	            _resourceClass: 'sceneGraph_entity',
263	            handles: this._settings.dragPoints.map((_, i) => ({
264	                tags: [`drag-point-${i}`],
265	                components: [
266	                    {_resourceClass: 'component_transform', translation: [0, 0, 0]},
267	                    {_resourceClass: 'component_renderer', geometry: sphereGeometry}
268	                ]
269	            }))
270	        };
271
272	        const rootHandle = await this.addInteractionScene(sceneModel);
273	        if (rootHandle) {
274	            this.dragPointHandles = this._settings.dragPoints.map((_, i) =>
275	                rootHandle.find(`.drag-point-${i}`) as unknown as Handle
276	            );
277	        }
278	    }
279
280	    private extractDropPoints(dropPointProviders: Partial<DropPointProvider>[]): DropPoint[] {
281	        return dropPointProviders
282	            .map(provider => provider.getDropPoint?.())
283	            .filter(dropPoint => dropPoint != null);
284	    }
285
286	    private betterRayAssessment<T extends RayAssessment>(prev: T | null, current: T): T {
287	        if (!prev) {
288	            return current;
289	        }
290	        if (valueEqual(prev.jumpDistance, current.jumpDistance, 0.05)) {
291	            if (prev.rayDistance < current.rayDistance) {
292	                return prev;
293	            } else {
294	                return current;
295	            }
296	        } else if (prev.jumpDistance < current.jumpDistance) {
297	            return prev;
298	        } else {
299	            return current;
300	        }
301	    }
302
303	    public get defaultCursorTransform(): Matrix4_4 {
304	        return this._settings.defaultCursorTransform ?? this._settings.dragPoints[0].transform;
305	    }
306
307	    public get cursorTransform(): Matrix4_4 {
308	        if (this.activeDragPointIndex < 0) {
309	            return this.defaultCursorTransform;
310	        } else {
311	            return this._settings.dragPoints[this.activeDragPointIndex].transform;
312	        }
313	    }
314	}
315
316	export interface DropPoint {
317	    assessRay(ray: Ray<Vector3>): RayAssessment;
318	    predictDragPointRotation(ray: Ray<Vector3>): Matrix4_4;
319	    setVisualization(state: boolean): void;
320	    setVisualizationHighlight(color: Color | null): void;
321	    /** Returns the selector of the drop point entity itself. */
322	    selector(): string | null;
323	}
324
325	export type PlacementUpdateData = {
326	    position: Vector3;
327	    rotation: Quaternion;
328	    /** Selector of the active drag point entity, or null if none was active. */
329	    dragPointSelector: string | null;
330	    /** Selector of the snapped drop point entity, or null if snapping to XZ plane. */
331	    dropPointSelector: string | null;
332	};
333
334	export type RayAssessment = {
335	    jumpDistance: number,
336	    rayDistance: number,
337	    position: Vector3,
338	    rotation: Quaternion
339	};
340
341	export interface DropPointProvider {
342	    getDropPoint(): DropPoint | null;
343	}
344
345	type PlacementInputMap = {
346	    pointerNormalizedPosition: Vector2;
347	    nextDragPoint: boolean;
348	    previousDragPoint: boolean;
349	    interactionClick: boolean;
350	};
351

import {InteractionMode, InteractionModeSettings} from './interactionMode';
import {BasicMaterialAsset, CameraComponent, Handle, MeshGeometryAsset, selectorElementReferenceRepresentation} from '@fire-visual/engine';
import {Color, intersection, Matrix4_4, MeshFactory, Plane, Quaternion, Ray, valueEqual, Vector2, Vector3} from '@fire-visual/math';
import {SceneGraphModel} from '@fire-visual/engine/types/dataManager/model';

export interface PlacementModeSettings extends InteractionModeSettings {
    camera: CameraComponent;
    subject: Handle;
    defaultCursorTransform?: Matrix4_4;
    dragPoints: Array<{
        transform: Matrix4_4;
        dropPoints: Partial<DropPointProvider>[];
        /** The selector of the drag point entity itself (not the query used to find it). */
        selector?: string;
    }>;
    /**
     * When false, the "all drag points" state (index -1) is not allowed.
     * Cycling skips -1 and always keeps a specific drag point active.
     * Defaults to true.
     */
    allowAllDragPoints?: boolean;
    /**
     * When false, user input (nextDragPoint / previousDragPoint) cannot change
     * the active drag point, keeping "all drag points" active at all times.
     * Defaults to true.
     */
    allowDragPointCycling?: boolean;
    /**
     * Overrides the global maxJumpDistance from ModeOptionProvider for this
     * placement mode instance specifically.
     */
    maxJumpDistance?: number;
}

export class PlacementMode extends InteractionMode<PlacementModeSettings> {

    private get gizmoScale(): number {
        return this.modeOptionProvider?.gizmoScale ?? 1;
    }

    private get maxJumpDistance(): number {
        return this._settings.maxJumpDistance
            ?? this.modeOptionProvider?.options.placement.maxJumpDistance
            ?? 1;
    }

    private get highlightColor(): Color {
        return this.modeOptionProvider?.options.placement.highlightColor ?? Color.yellow;
    }

    private get dragPointActiveColor(): Color {
        return this.modeOptionProvider?.options.placement.dragPointActiveColor ?? Color.yellow;
    }

    private get dragPointInactiveColor(): Color {
        return this.modeOptionProvider?.options.placement.dragPointInactiveColor ?? Color.blue;
    }

    private get dragPointRadius(): number {
        return this.modeOptionProvider?.options.placement.dragPointRadius ?? 0.1;
    }

    private dropPoints: DropPoint[][] = [];
    // index of active drag point, -1 for "all drag points"
    private activeDragPointIndex: number;
    private dragPointHandles: Handle[] = [];
    private dragPointBlueMaterial: BasicMaterialAsset;
    private dragPointYellowMaterial: BasicMaterialAsset;
    private _grabActive: boolean = true;
    private _prevInteractionClick: boolean = false;
    private _lastPlacement: PlacementUpdateData | null = null;

    constructor(settings: PlacementModeSettings) {
        super(settings);
        this.activeDragPointIndex = settings.allowAllDragPoints === false ? 0 : -1;
    }

    getModeName(): string {
        return 'placement';
    }

    public processInput(input: PlacementInputMap): boolean {

        if (!input.pointerNormalizedPosition || !this._settings.camera) {
            return false;
        }

        // Rising edge: resume movement
        if (!this._prevInteractionClick && input.interactionClick) {
            this._grabActive = true;
        }
        if (!this._grabActive) {
            this._prevInteractionClick = input.interactionClick;
            return true;
        }

        this.cycleDragPointForInput(input);

        // Reset all visualizations first — false before true so shared underlying objects settle correctly
        this.dropPoints.forEach(group => group.forEach(dp => {
            dp.setVisualization(false);
            dp.setVisualizationHighlight(null);
        }));

        // Activate drop points that belong to the currently active drag point(s)
        if (this.activeDragPointIndex >= 0) {
            this.dropPoints[this.activeDragPointIndex]?.forEach(dp => dp.setVisualization(true));
        } else {
            this.dropPoints.forEach(group => group.forEach(dp => dp.setVisualization(true)));
        }

        // Cast ray from camera through pointer position
        const pointingRay = this._settings.camera.directionOfScreenPoint(input.pointerNormalizedPosition);

        // Find intersection of ray with XZ plane
        const xzPlane = Plane.createFromPoint(Vector3.zero, Vector3.up);
        const intersectionPoint = intersection.rayAgainstPlane(pointingRay, xzPlane);

        const assessments = this.mapActiveDragPoints(
            (dragPoint, i) => {
                const cursorFromDragPoint = dragPoint.transform.inverse.multiply(this.cursorTransform);
                return this.dropPoints[i].map(dropPoint => {
                    const offset = dropPoint.predictDragPointRotation(pointingRay).multiply(cursorFromDragPoint).multiplyPoint(Vector3.zero);
                    const offsetRay = Ray.create(pointingRay.origin.subtract(offset), pointingRay.direction);
                    return {
                        ...dropPoint.assessRay(offsetRay),
                        dragPointTransform: dragPoint.transform,
                        dragPointIndex: i,
                        dropPoint
                    }
                });
            }
        );
        // get assessment with shortest jump distance (shortest rayDistance in case of tie)
        const best = assessments.reduce(this.betterRayAssessment.bind(this), null);

        if (best != null && best.jumpDistance < this.maxJumpDistance) {
            best.dropPoint.setVisualizationHighlight(this.highlightColor);
        }

        if (this._settings.subject?.transform) {
            if (best != null && best.jumpDistance < this.maxJumpDistance) {
                // after this, we want subject.matrix * dragPoint.transform = best.matrix
                // thus subject.matrix = best.matrix * dragPoint.transform⁻¹
                const bestMatrix = Matrix4_4.compose(best.position, best.rotation, Vector3.one);
                this._settings.subject.transform.matrix = bestMatrix.multiply(best.dragPointTransform.inverse);
                this._lastPlacement = {
                    position: best.position,
                    rotation: best.rotation,
                    dragPointSelector: this._settings.dragPoints[best.dragPointIndex]?.selector ?? null,
                    dropPointSelector: best.dropPoint.selector()
                };
            } else if (intersectionPoint) {
                // Move subject to intersection point
                const targetLocation = Matrix4_4.compose(intersectionPoint, Quaternion.identity, Vector3.one);
                this._settings.subject.transform.matrix = targetLocation.multiply(this.cursorTransform.inverse);
                this._lastPlacement = {
                    position: intersectionPoint,
                    rotation: Quaternion.identity,
                    dragPointSelector: null,
                    dropPointSelector: null
                };
            }
        }

        // Update drag point sphere positions after the subject has moved.
        // Drag points are in subject-local space, so multiply by subject's world matrix.
        const subjectMatrix = this._settings.subject?.transform?.matrix ?? Matrix4_4.identity;
        this._settings.dragPoints.forEach((dragPoint, i) => {
            const handle = this.dragPointHandles[i];
            if (!handle) return;
            handle.transform.position = subjectMatrix.multiply(dragPoint.transform).multiplyPoint(Vector3.zero);
            const isActive = this.activeDragPointIndex < 0 || this.activeDragPointIndex === i;
            handle.renderer.materials = [isActive ? this.dragPointYellowMaterial : this.dragPointBlueMaterial];
        });

        // Falling edge: stop movement and emit last valid placement
        if (this._prevInteractionClick && !input.interactionClick) {
            this._grabActive = false;
            if (this._lastPlacement) {
                this.emitUpdateEvent({
                    subject: selectorElementReferenceRepresentation(this.settings.subject),
                    ...this._lastPlacement
                });
            }
        }

        this._prevInteractionClick = input.interactionClick;
        return true;
    }

    public async startMode(): Promise<void> {
        this.dropPoints = [];
        for(const dragPoint of this._settings.dragPoints) {
            this.dropPoints.push(this.extractDropPoints(dragPoint.dropPoints));
        }

        await this.createDragPointVisuals();

        await super.startMode();
    }

    public async stopMode(): Promise<void> {
        this.dropPoints = [];
        this.dragPointHandles = [];
        await super.stopMode();
    }

    private cycleDragPointForInput(input: PlacementInputMap) {
        if (this._settings.allowDragPointCycling !== false) {
            if (input.nextDragPoint) {
                this.nextDragPoint();
            }

            if (input.previousDragPoint) {
                this.previousDragPoint();
            }
        }
    }

    public nextDragPoint(): void {
        this.activeDragPointIndex = this.nextDragPointIndex();
    }

    public previousDragPoint(): void {
        this.activeDragPointIndex = this.previousDragPointIndex();
    }

    private nextDragPointIndex(): number {
        const last = this._settings.dragPoints.length - 1;
        if (this._settings.allowAllDragPoints === false) {
            return this.activeDragPointIndex >= last ? 0 : this.activeDragPointIndex + 1;
        }
        return this.activeDragPointIndex >= last ? -1 : this.activeDragPointIndex + 1;
    }

    private previousDragPointIndex(): number {
        const last = this._settings.dragPoints.length - 1;
        if (this._settings.allowAllDragPoints === false) {
            return this.activeDragPointIndex <= 0 ? last : this.activeDragPointIndex - 1;
        }
        return this.activeDragPointIndex <= -1 ? last : this.activeDragPointIndex - 1;
    }

    private mapActiveDragPoints<T>(func: (dragPoint: { transform: Matrix4_4 }, index: number) => T[]): T[] {
        if (this.activeDragPointIndex >= 0) {
            return func(this._settings.dragPoints[this.activeDragPointIndex], this.activeDragPointIndex);
        } else {
            return this._settings.dragPoints.flatMap((x, i) => func(x, i));
        }
    }

    private async createDragPointVisuals(): Promise<void> {
        if (this._settings.dragPoints.length === 0) return;

        const r = this.dragPointRadius * this.gizmoScale;
        const sphereGeometry = new MeshGeometryAsset(MeshFactory.sphere(new Vector3(r, r, r)));
        this.dragPointBlueMaterial = new BasicMaterialAsset({diffuse: this.dragPointInactiveColor, transparent: true, opacity: 0.75});
        this.dragPointYellowMaterial = new BasicMaterialAsset({diffuse: this.dragPointActiveColor, transparent: true, opacity: 0.75});

        const sceneModel: SceneGraphModel = {
            _resourceClass: 'sceneGraph_entity',
            handles: this._settings.dragPoints.map((_, i) => ({
                tags: [`drag-point-${i}`],
                components: [
                    {_resourceClass: 'component_transform', translation: [0, 0, 0]},
                    {_resourceClass: 'component_renderer', geometry: sphereGeometry}
                ]
            }))
        };

        const rootHandle = await this.addInteractionScene(sceneModel);
        if (rootHandle) {
            this.dragPointHandles = this._settings.dragPoints.map((_, i) =>
                rootHandle.find(`.drag-point-${i}`) as unknown as Handle
            );
        }
    }

    private extractDropPoints(dropPointProviders: Partial<DropPointProvider>[]): DropPoint[] {
        return dropPointProviders
            .map(provider => provider.getDropPoint?.())
            .filter(dropPoint => dropPoint != null);
    }

    private betterRayAssessment<T extends RayAssessment>(prev: T | null, current: T): T {
        if (!prev) {
            return current;
        }
        if (valueEqual(prev.jumpDistance, current.jumpDistance, 0.05)) {
            if (prev.rayDistance < current.rayDistance) {
                return prev;
            } else {
                return current;
            }
        } else if (prev.jumpDistance < current.jumpDistance) {
            return prev;
        } else {
            return current;
        }
    }

    public get defaultCursorTransform(): Matrix4_4 {
        return this._settings.defaultCursorTransform ?? this._settings.dragPoints[0].transform;
    }

    public get cursorTransform(): Matrix4_4 {
        if (this.activeDragPointIndex < 0) {
            return this.defaultCursorTransform;
        } else {
            return this._settings.dragPoints[this.activeDragPointIndex].transform;
        }
    }
}

export interface DropPoint {
    assessRay(ray: Ray<Vector3>): RayAssessment;
    predictDragPointRotation(ray: Ray<Vector3>): Matrix4_4;
    setVisualization(state: boolean): void;
    setVisualizationHighlight(color: Color | null): void;
    /** Returns the selector of the drop point entity itself. */
    selector(): string | null;
}

export type PlacementUpdateData = {
    position: Vector3;
    rotation: Quaternion;
    /** Selector of the active drag point entity, or null if none was active. */
    dragPointSelector: string | null;
    /** Selector of the snapped drop point entity, or null if snapping to XZ plane. */
    dropPointSelector: string | null;
};

export type RayAssessment = {
    jumpDistance: number,
    rayDistance: number,
    position: Vector3,
    rotation: Quaternion
};

export interface DropPointProvider {
    getDropPoint(): DropPoint | null;
}

type PlacementInputMap = {
    pointerNormalizedPosition: Vector2;
    nextDragPoint: boolean;
    previousDragPoint: boolean;
    interactionClick: boolean;
};


Die Sourcen liegen unter project/fire-visual


Kern gefunden. Der gelbe Ball ist **nicht** aus den PMI-Daten, sondern ein von fire erzeugter DragPoint-Marker (`placementMode.ts:253` `createDragPointVisuals`):

```
const r = this.dragPointRadius * this.gizmoScale;   // Kugel-Radius
const sphereGeometry = MeshFactory.sphere(new Vector3(r, r, r));
```

Größe = `dragPointRadius` (Default 0.1) × `gizmoScale`. Deshalb änderte das PMI-Löschen nichts. Ich prüfe `gizmoScale` — ob der kamera-/szenenabhängig skaliert:


Bash

cd /Users/marcus.hinz/projects/fire-visual
grep -rniE 'gizmoScale|dragPointRadius' threed --include='*.ts' 2>/dev/null | grep -viE 'node_modules|\.d\.ts|placementMode\.ts' | head -20

Find gizmoScale and dragPointRadius definitions


threed/interaction/module/tests/movementMode.test.ts:285:describe('MovementMode – interactionGizmoScaleFactor', () => {
threed/interaction/module/tests/movementMode.test.ts:307:                dragPointRadius: 0.1
threed/interaction/module/tests/movementMode.test.ts:309:            interactionGizmoScaleFactor: scale
threed/interaction/module/tests/rotationMode.test.ts:327:describe('RotationMode – interactionGizmoScaleFactor', () => {
threed/interaction/module/tests/rotationMode.test.ts:350:                dragPointRadius: 0.1
threed/interaction/module/tests/rotationMode.test.ts:352:            interactionGizmoScaleFactor: scale
threed/interaction/module/src/options/modeOptionProvider.ts:11:    public get gizmoScale(): number {
threed/interaction/module/src/options/modeOptionProvider.ts:12:        return this._options.interactionGizmoScaleFactor ?? 1;
threed/interaction/module/src/feature/interactionFeature.ts:32:    interactionGizmoScaleFactor: number;
threed/interaction/module/src/feature/interactionFeature.ts:56:            dragPointRadius: 0.1
threed/interaction/module/src/feature/interactionFeature.ts:63:            dragPointRadius: {_type: 'number', default: 0.1}
threed/interaction/module/src/feature/interactionFeature.ts:66:    interactionGizmoScaleFactor: {_type: 'number', default: 1},
threed/interaction/module/src/feature/interactionFeature.ts:149:            interactionGizmoScaleFactor: this._settings.interactionGizmoScaleFactor
threed/interaction/module/src/interactionMode/movementMode.ts:76:    private get gizmoScale(): number {
threed/interaction/module/src/interactionMode/movementMode.ts:77:        return this.modeOptionProvider?.gizmoScale ?? 1;
threed/interaction/module/src/interactionMode/movementMode.ts:145:        const scale = this.gizmoScale;
threed/interaction/module/src/interactionMode/movementMode.ts:326:        const s = this.gizmoScale;
threed/interaction/module/src/options/modeOptions.ts:8:    dragPointRadius: number;
threed/interaction/module/src/options/modeOptions.ts:13:    interactionGizmoScaleFactor: number;
threed/interaction/module/src/interactionMode/rotationMode.ts:55:    private get gizmoScale(): number {
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


`gizmoScale` = `interactionGizmoScaleFactor ?? 1` (`modeOptionProvider.ts:12`) — ein fixer Config-Wert, **nicht** kamera- oder szenenabhängig. Damit steht fest:

- Der gelbe Ball ist der aktive DragPoint-Marker.
- Sein Radius = `dragPointRadius` (0.1) × `interactionGizmoScaleFactor` (0.1) — beides fixe Werte aus `modeOptions`, unabhängig von PMI-/Katalogdaten. Deshalb wirkte weder das Ausreißer-Löschen noch der Katalog-Inhalt.

Der Hebel für die Größe liegt in der cpq-Konfiguration dieser Optionen. Ich prüfe, was cpq beim Init setzt:


cd /Users/marcus.hinz/projects/adesso-cpq
grep -rniE 'interactionGizmoScaleFactor|dragPointRadius|gizmoScale|interactionFeature|InteractionFeature|placement' packages/frontend/src --include='*.ts' --include='*.vue' 2>/dev/null | grep -viE '\.test\.|DefaultCommandProcessor' | head -20

Find fire interaction options config in cpq


packages/frontend/src/configurator/events/eventData.ts:1:// The placement event's selectors are absolute paths down to the PMI; we only need the
packages/frontend/src/configurator/events/eventData.ts:16:export function extractPlacementData(event: any) {


cpq überschreibt die Optionen nicht — es gelten die Defaults (`dragPointRadius` 0.1, `gizmoScale` 1), Ball-Radius also fix 0.1 in Weltkoordinaten. Ob das „riesig" wirkt, hängt an der Szenen-Einheit. Ich sehe nach, wie Fire3D die Katalog-Szene lädt/skaliert:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Fire3D.vue: Engine-Setup / Scale / Environment / Load ==="
grep -niE 'scale|environment|unit|mm|meter|scene|load|feature|interaction|new .*Engine|createScene|import' packages/frontend/src/components/visual/Fire3D.vue | head -40

Inspect Fire3D scene loading and scale


=== Fire3D.vue: Engine-Setup / Scale / Environment / Load ===
35:import { onBeforeUnmount, onMounted, provide, ref } from "vue";
36:import { createVisual3D } from "@ad-cpq/visual-runtime";
37:import FireControls from "./FireControls.vue";
38:import FireContextMenu from "./FireContextMenu.vue";
39:import ToastNotification from "../building-blocks/ToastNotification.vue";
40:import AppDialog from "../building-blocks/AppDialog.vue";
41:import { useMousePosition } from "../../composables/useMousePosition";
42:import { VISUAL_KEY, type CameraMode } from "./fire3d.types";
43:import type { UIReaction, MenuEntry } from "../../configurator/types";
44:import { CommandProcessor } from "../../configurator/events/CommandProcessor";
45:import { DefaultCommandProcessor } from "../../configurator/events/DefaultCommandProcessor";
50:    processor?: CommandProcessor;
84:const currentSceneGraph = ref<any>(null);
90:const processor = props.processor ?? new DefaultCommandProcessor();
106:// Reactions are awaited sequentially: a sendCommand only starts after the previous one
107:// resolved. This keeps a "stop_interaction_mode" before a following "setScene", and moves
108:// scene rebuilds off the synchronous engine event/processInput stack so an active gizmo is
113:            case "sendCommand":
114:                if (reaction.cmd === "setScene") {
115:                    await visual.value?.setScene(reaction.args[0]);
139:// Handle product save - send command with variables
144:        // Prepare the command payload with variables
145:        const commandPayload = {
146:            command: "updateProduct",
152:        const result = await props.onSaveProduct(commandPayload);
154:        // If we got a new scene/product, update the visualization
155:        if (result && result.scene) {
156:            visual.value?.setScene(result.scene);
157:            currentSceneGraph.value = result.scene;
198:    setScene: async (scene: unknown) => {
200:        processor.setInitialScene(scene);
201:        currentSceneGraph.value = scene;
202:        return visual.value?.setScene(scene);
204:    setBaseline: (scene: unknown) => {
205:        processor.setInitialScene(scene);


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== visual-runtime Ort ==="; find packages -type d -name 'visual-runtime' -not -path '*/node_modules/*' 2>/dev/null; find packages -path '*visual*runtime*' -name 'package.json' -not -path '*/node_modules/*' 2>/dev/null | head
echo "=== interaction-Optionen / scale / feature-Setup in visual-runtime ==="
grep -rniE 'interactionGizmoScaleFactor|dragPointRadius|InteractionFeature|addFeature|scale|environmentConfig|unit' packages/visual-runtime/src 2>/dev/null | grep -viE '\.test\.|node_modules' | head -25

Locate visual-runtime and interaction option config


=== visual-runtime Ort ===
=== interaction-Optionen / scale / feature-Setup in visual-runtime ===


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== wo ist @ad-cpq/visual-runtime ==="
grep -rl '"@ad-cpq/visual-runtime"' packages --include='package.json' -l 2>/dev/null | grep -v node_modules
find packages -maxdepth 2 -name package.json -not -path '*/node_modules/*' -exec grep -l 'visual-runtime\|visual_runtime' {} \; 2>/dev/null
echo "=== createVisual3D Quelle ==="
grep -rniE 'createVisual3D' packages --include='*.ts' 2>/dev/null | grep -v node_modules | head

Find visual-runtime package source


=== wo ist @ad-cpq/visual-runtime ===
packages/frontend/package.json
packages/runtime/visual/package.json
packages/frontend/package.json
=== createVisual3D Quelle ===
packages/frontend/dist/components/visual/fire3d.types.d.ts:1:import { createVisual3D, CameraTransformation } from '@ad-cpq/visual-runtime';
packages/frontend/dist/components/visual/fire3d.types.d.ts:4:export type Visual3D = Awaited<ReturnType<typeof createVisual3D>>;
packages/frontend/src/components/visual/fire3d.types.ts:1:import type { createVisual3D, CameraTransformation } from "@ad-cpq/visual-runtime";
packages/frontend/src/components/visual/fire3d.types.ts:5:export type Visual3D = Awaited<ReturnType<typeof createVisual3D>>;
packages/runtime/visual/dist/index.d.ts:69:declare function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D>;
packages/runtime/visual/dist/index.d.ts:71:export { type CameraMode, type CameraTransformation, type Observable, type Visual3D, type Visual3DCamera, type Visual3DConfig, createVisual3D };
packages/runtime/visual/src/index.ts:1:export { createVisual3D } from "./visual-3d";
packages/runtime/visual/src/visual-3d.ts:258:// into a separate chunk that is only fetched when createVisual3D() is first called,
packages/runtime/visual/src/visual-3d.ts:271:export async function createVisual3D(container: HTMLElement, config: Visual3DConfig): Promise<Visual3D> {


cd /Users/marcus.hinz/projects/adesso-cpq
grep -niE 'interactionGizmoScaleFactor|dragPointRadius|InteractionFeature|Feature|scale|gizmo|placement|start_placement' packages/runtime/visual/src/visual-3d.ts | head -30

Search interaction feature config in visual-3d.ts


56:    scale: [number, number, number];


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== Dateien im visual-runtime src ==="; find packages/runtime/visual/src -type f | head
echo "=== interaction-Feature / Optionen irgendwo im paket ==="
grep -rniE 'interactionGizmoScaleFactor|dragPointRadius|InteractionFeature|addFeature|new .*Feature' packages/runtime/visual/src 2>/dev/null | head -20
echo "=== scale Kontext in visual-3d.ts (Zeile ~50-60) ==="
sed -n '48,68p' packages/runtime/visual/src/visual-3d.ts

Explore visual-runtime feature setup and scale usage


=== Dateien im visual-runtime src ===
packages/runtime/visual/src/visual-3d.ts
packages/runtime/visual/src/index.ts
=== interaction-Feature / Optionen irgendwo im paket ===
=== scale Kontext in visual-3d.ts (Zeile ~50-60) ===
// Kept explicit (not inferred from fire internals) so they remain stable
// even if the underlying fire implementation changes.

export type CameraMode = "orbit" | "first-person";

export interface CameraTransformation {
    translation: [number, number, number];
    rotation: [number, number, number, number];
    scale: [number, number, number];
}

export interface Observable<T> {
    subscribe(handler: (value: T) => void): { unsubscribe(): void };
}

export interface Visual3DCamera {
    setMode(mode: CameraMode): void;
    setType(type: number): void;
    setPosition(x: number, y: number, z: number, up?: number[]): Promise<void>;
    getTransformation(): Promise<CameraTransformation>;
    fitToObject(selector: string): Promise<void>;


Weder cpq-Frontend noch visual-runtime setzen die Optionen — es bleiben die Defaults. Ich prüfe noch die Environment-/Story-Config (dort wird fire oft parametrisiert):


cd /Users/marcus.hinz/projects/adesso-cpq
grep -rniE 'interactionGizmoScaleFactor|dragPointRadius|gizmoScale' packages/frontend/.storybook packages/runtime 2>/dev/null | grep -v node_modules | head
echo "=== environment config Dateien ==="
find packages/frontend/.storybook -iname 'environment*.json' -o -iname 'environment*config*' 2>/dev/null | grep -v node_modules | head
echo "=== root-scale/Skalierung beim Laden der Szene (mm->m)? in visual-3d.ts ==="
grep -niE '0.001|/ 1000|\* 0\.|rootScale|setScene|scale' packages/runtime/visual/src/visual-3d.ts | head

Search environment config and scene scaling


packages/frontend/.storybook/fire/environment.config.json:1554:                "interactionGizmoScaleFactor": 400,
packages/frontend/.storybook/fire/environment.config.json:1558:                    "dragPointRadius": 40
packages/runtime/visual/dist/browser-XYJYSCYI.js:29160:    get gizmoScale() {
packages/runtime/visual/dist/browser-XYJYSCYI.js:29161:      return this._options.interactionGizmoScaleFactor ?? 1;
packages/runtime/visual/dist/browser-XYJYSCYI.js:29303:    get gizmoScale() {
packages/runtime/visual/dist/browser-XYJYSCYI.js:29304:      return this.modeOptionProvider?.gizmoScale ?? 1;
packages/runtime/visual/dist/browser-XYJYSCYI.js:29330:      const t3 = this.settings.target[0].getTransformationProvider().position, n3 = l2.Transformation.fromMatrix(this.settings.pivot), r3 = this.gizmoScale, i3 = 0.15 * r3, s3 = this._settings.target.map((e4) => ({ position: e4.getTransformationProvider().position || l2.Vector3.zero, rotation: e4.getTransformationProvider().rotation || l2.Quaternion.identity })), o3 = [{ anchor: l2.Vector3.zero, normal: this.settings.camera.directionOfScreenPoint(new l2.Vector2(0, 0)).direction, highlight: [true, true, true], enabled: this.cameraPlaneEnabled }, { anchor: this.settings.pivot.right.add(this.settings.pivot.up).normalized().multiply(r3), normal: this.settings.pivot.forward, highlight: [true, true, false], enabled: this.xyPlaneEnabled }, { anchor: this.settings.pivot.forward.add(this.settings.pivot.up).normalized().multiply(r3), normal: this.settings.pivot.right, highlight: [false, true, true], enabled: this.yzPlaneEnabled }, { anchor: this.settings.pivot.forward.add(this.settings.pivot.right).normalized().multiply(r3), normal: this.settings.pivot.up, highlight: [true, false, true], enabled: this.xzPlaneEnabled }].filter((e4) => e4.enabled).map((t4) => {
packages/runtime/visual/dist/browser-XYJYSCYI.js:29380:      const e3 = this.gizmoScale, t3 = new i2.MeshGeometryAsset(l2.MeshFactory.arrow(0.2 * e3, 0.3 * e3, 2 * e3));
packages/runtime/visual/dist/browser-XYJYSCYI.js:29399:    get gizmoScale() {
packages/runtime/visual/dist/browser-XYJYSCYI.js:29400:      return this.modeOptionProvider?.gizmoScale ?? 1;
=== environment config Dateien ===
packages/frontend/.storybook/fire/environment.config.json
=== root-scale/Skalierung beim Laden der Szene (mm->m)? in visual-3d.ts ===
18: *   visual3d.setScene(scene)
56:    scale: [number, number, number];
75:    setScene(scene: unknown): Promise<void>;
299:        setScene: (scene) => vi.updateScene(scene),


Gefunden — die Ursache steht in `packages/frontend/.storybook/fire/environment.config.json`:


1545	                                "step": "up",
1546	                                "shift": true
1547	                            }
1548	                        }
1549	                    ]
1550	                }
1551	            },
1552	            {
1553	                "typeKey": "interaction",
1554	                "interactionGizmoScaleFactor": 400,
1555	                "placement": {
1556	                    "highlightColor": [0, 1, 0, 1],
1557	                    "maxJumpDistance": 500,
1558	                    "dragPointRadius": 40
1559	                },
1560	                "cursor": {
1561	                    "selection": "none"
1562	                }
1563	            },
1564	            {

                                "step": "up",
                                "shift": true
                            }
                        }
                    ]
                }
            },
            {
                "typeKey": "interaction",
                "interactionGizmoScaleFactor": 400,
                "placement": {
                    "highlightColor": [0, 1, 0, 1],
                    "maxJumpDistance": 500,
                    "dragPointRadius": 40
                },
                "cursor": {
                    "selection": "none"
                }
            },
            {


Das ist die Ursache. Die interaction-Config setzt:

- `interactionGizmoScaleFactor: 400`
- `placement.dragPointRadius: 40`

Und `placementMode` rechnet `r = dragPointRadius × gizmoScale` = **40 × 400 = 16000** Weltkoordinaten-Einheiten Kugelradius. Die Einzeltür misst ~1044 × 2167 mm — der DragPoint-Ball ist mit 16 m Radius rund 8× so groß wie die ganze Tür und füllt die Ansicht. Das ist der „Riesen gelbe Ball" (gelb = `dragPointActiveColor`, Default; das `highlightColor` hier ist grün).

Datenunabhängig — deshalb änderte weder das PMI-Ausreißer-Löschen noch der Katalog-Inhalt etwas.

Der Wert wird doppelt skaliert: `dragPointRadius: 40` ist bereits ein absoluter mm-Radius, wird aber zusätzlich mit dem Faktor 400 multipliziert. Die anderen Gizmos (Rotation/Movement) haben kleine Basiswerte (0.15–2) und vertragen den ×400-Faktor; `dragPointRadius` nicht. Stellhebel ist also `dragPointRadius` in `environment.config.json:1558` — für ~40 mm Ball müsste er `0.1` sein.


kannst du das mal korrigieren?


Edit

                    "maxJumpDistance": 500,
                    "dragPointRadius": 40

                    "maxJumpDistance": 500,
                    "dragPointRadius": 0.1


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

                    "maxJumpDistance": 500,
                    "dragPointRadius": 40

                    "maxJumpDistance": 500,
                    "dragPointRadius": 0.1

-                    "dragPointRadius": 40

+                    "dragPointRadius": 0.1


`dragPointRadius: 40` → `0.1` in `environment.config.json:1558`. Ball-Radius jetzt `0.1 × 400 = 40 mm` (statt 16000 mm).


Nimm mal bei den DrapPoint dier Start- Poiunts raus


        return [
            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
            { selector: `.name-CS_EIN_START_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_START_R${openOnly}`, dropPoints: drops("L") },
        ];

        return [
            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
        ];


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

        return [
            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
            { selector: `.name-CS_EIN_START_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_START_R${openOnly}`, dropPoints: drops("L") },
        ];

        return [
            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
        ];

-            { selector: `.name-CS_EIN_START_L${openOnly}`, dropPoints: drops("R") },

-            { selector: `.name-CS_EIN_START_R${openOnly}`, dropPoints: drops("L") },


Der Methodenkommentar erwähnt die Foot-Variante noch — ich passe ihn an:


440	    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
441	    // neighbour's open CS_OUT points: CS_EIN_L (and its foot variant CS_EIN_START_L) meet the
442	    // neighbour's right-side OUT points, CS_EIN_R/CS_EIN_START_R the left-side ones — matching point
443	    // frames. The angle suffix picks the direction: _0 in line (Flucht), _90 to the front, _270 to
444	    // the back. Self-snap and already-occupied points are excluded.
445	    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
446	        const openOnly = this.openEndExclusions();
447	        const drops = (side: "R" | "L"): string[] =>
448	            ["0", "90", "270"].map(
449	                (angle) => `.name-CS_OUT_${side}_${angle}:not(${subject} .name-CS_OUT_${side}_${angle})${openOnly}`,
450	            );
451	        return [
452	            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
453	            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
454	        ];
455	    }

    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
    // neighbour's open CS_OUT points: CS_EIN_L (and its foot variant CS_EIN_START_L) meet the
    // neighbour's right-side OUT points, CS_EIN_R/CS_EIN_START_R the left-side ones — matching point
    // frames. The angle suffix picks the direction: _0 in line (Flucht), _90 to the front, _270 to
    // the back. Self-snap and already-occupied points are excluded.
    private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {
        const openOnly = this.openEndExclusions();
        const drops = (side: "R" | "L"): string[] =>
            ["0", "90", "270"].map(
                (angle) => `.name-CS_OUT_${side}_${angle}:not(${subject} .name-CS_OUT_${side}_${angle})${openOnly}`,
            );
        return [
            { selector: `.name-CS_EIN_L${openOnly}`, dropPoints: drops("R") },
            { selector: `.name-CS_EIN_R${openOnly}`, dropPoints: drops("L") },
        ];
    }


    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
    // neighbour's open CS_OUT points: CS_EIN_L (and its foot variant CS_EIN_START_L) meet the
    // neighbour's right-side OUT points, CS_EIN_R/CS_EIN_START_R the left-side ones — matching point
    // frames. The angle suffix picks the direction: _0 in line (Flucht), _90 to the front, _270 to
    // the back. Self-snap and already-occupied points are excluded.

    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
    // neighbour's open CS_OUT points: CS_EIN_L meets the neighbour's right-side OUT points, CS_EIN_R
    // the left-side ones — matching point frames. The angle suffix picks the direction: _0 in line
    // (Flucht), _90 to the front, _270 to the back. Self-snap and already-occupied points are excluded.


    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
    // neighbour's open CS_OUT points: CS_EIN_L (and its foot variant CS_EIN_START_L) meet the
    // neighbour's right-side OUT points, CS_EIN_R/CS_EIN_START_R the left-side ones — matching point
    // frames. The angle suffix picks the direction: _0 in line (Flucht), _90 to the front, _270 to
    // the back. Self-snap and already-occupied points are excluded.

    // Placement drag/drop wiring (root-level points only). A part's left/right ends dock onto a
    // neighbour's open CS_OUT points: CS_EIN_L meets the neighbour's right-side OUT points, CS_EIN_R
    // the left-side ones — matching point frames. The angle suffix picks the direction: _0 in line
    // (Flucht), _90 to the front, _270 to the back. Self-snap and already-occupied points are excluded.

-    // neighbour's open CS_OUT points: CS_EIN_L (and its foot variant CS_EIN_START_L) meet the

-    // neighbour's right-side OUT points, CS_EIN_R/CS_EIN_START_R the left-side ones — matching point

-    // frames. The angle suffix picks the direction: _0 in line (Flucht), _90 to the front, _270 to

-    // the back. Self-snap and already-occupied points are excluded.

+    // neighbour's open CS_OUT points: CS_EIN_L meets the neighbour's right-side OUT points, CS_EIN_R

+    // the left-side ones — matching point frames. The angle suffix picks the direction: _0 in line

+    // (Flucht), _90 to the front, _270 to the back. Self-snap and already-occupied points are excluded.

private placementDragPoints(subject: string): Array<{ selector: string; dropPoints: string[] }> {

const openOnly = this.openEndExclusions();

const drops = (side: "R" | "L"): string[] =>


Bash

cd /Users/marcus.hinz/projects/adesso-cpq
grep -rn 'CS_EIN_START' packages/frontend/src 2>/dev/null | head

Check remaining CS_EIN_START references


(Bash completed with no output)


Erledigt. `CS_EIN_START_L/R` als DragPoints entfernt — es bleiben nur `CS_EIN_L` und `CS_EIN_R`. Kommentar angepasst, keine weiteren `CS_EIN_START`-Referenzen im Code.


You've made several edits across files — /diff shows all your changes at a glance without scrolling back through the transcript.


und die skallierung mal auf 0.2


`dragPointRadius` von `0.1` auf `0.2` (Ball-Radius damit `0.2 × 400 = 80 mm`):


Edit

                    "maxJumpDistance": 500,
                    "dragPointRadius": 0.1

                    "maxJumpDistance": 500,
                    "dragPointRadius": 0.2


                    "maxJumpDistance": 500,
                    "dragPointRadius": 0.1

                    "maxJumpDistance": 500,
                    "dragPointRadius": 0.2

-                    "dragPointRadius": 0.1

+                    "dragPointRadius": 0.2


`dragPointRadius: 0.2` gesetzt (Ball-Radius 80 mm). Falls du stattdessen `interactionGizmoScaleFactor` (400) meintest, sag Bescheid.


werden drag und droppoint gelg eingefärbt?


Bash

cd /Users/marcus.hinz/projects/fire-visual
echo "=== setVisualization / Farbe in dropPoint-Modulen ==="
grep -rniE 'setVisualization|Color\.|yellow|blue|green|highlight|material|diffuse|activeColor|inactiveColor' threed/pmi/module/src/dropPoint --include='*.ts' 2>/dev/null | grep -viE 'test|\.d\.ts' | head -30

Inspect drop point visualization colors


=== setVisualization / Farbe in dropPoint-Modulen ===
threed/pmi/module/src/dropPoint/pmiDropPoint.ts:25:    setVisualization(state: boolean) {
threed/pmi/module/src/dropPoint/pmiDropPoint.ts:29:    setVisualizationHighlight(color: Color | null) {
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


1	import {Color, Matrix4_4, Quaternion, Ray, Vector3} from '@fire-visual/math';
2	import type {PMIComponent} from '../component';
3	import type {DropPoint, RayAssessment} from '@fire-visual/interaction';
4
5	export abstract class PmiDropPoint implements DropPoint {
6	    public readonly LTW: Matrix4_4;
7	    protected dragPointRotation: Quaternion;
8
9	    constructor(public readonly pmiComponent: PMIComponent) {
10	        this.LTW = pmiComponent.LTW;
11	        this.dragPointRotation = Matrix4_4.decompose(this.LTW)[1];
12	    }
13
14	    public get WTL(): Matrix4_4 {
15	        // a Matrix4_4 caches its inverse, no need to do it here
16	        return this.LTW.inverse;
17	    }
18
19	    abstract assessRay(ray: Ray<Vector3>): RayAssessment;
20
21	    predictDragPointRotation(_ray: Ray<Vector3>): Matrix4_4 {
22	        return Matrix4_4.fromQuaternion(this.dragPointRotation);
23	    }
24
25	    setVisualization(state: boolean) {
26	        this.pmiComponent.active = state;
27	    }
28
29	    setVisualizationHighlight(color: Color | null) {
30	        this.pmiComponent.colorOverride = color;
31	    }
32
33	    selector(): string | null {
34	        return this.pmiComponent.selector;
35	    }
36	}
37
38

import {Color, Matrix4_4, Quaternion, Ray, Vector3} from '@fire-visual/math';
import type {PMIComponent} from '../component';
import type {DropPoint, RayAssessment} from '@fire-visual/interaction';

export abstract class PmiDropPoint implements DropPoint {
    public readonly LTW: Matrix4_4;
    protected dragPointRotation: Quaternion;

    constructor(public readonly pmiComponent: PMIComponent) {
        this.LTW = pmiComponent.LTW;
        this.dragPointRotation = Matrix4_4.decompose(this.LTW)[1];
    }

    public get WTL(): Matrix4_4 {
        // a Matrix4_4 caches its inverse, no need to do it here
        return this.LTW.inverse;
    }

    abstract assessRay(ray: Ray<Vector3>): RayAssessment;

    predictDragPointRotation(_ray: Ray<Vector3>): Matrix4_4 {
        return Matrix4_4.fromQuaternion(this.dragPointRotation);
    }

    setVisualization(state: boolean) {
        this.pmiComponent.active = state;
    }

    setVisualizationHighlight(color: Color | null) {
        this.pmiComponent.colorOverride = color;
    }

    selector(): string | null {
        return this.pmiComponent.selector;
    }
}


Nein, nicht beide.

- **DragPoints:** aktive gelb (`dragPointActiveColor`, Default `Color.yellow`), inaktive blau (`dragPointInactiveColor`). Das ist der gelbe Ball.
- **DropPoints:** nicht gelb. `setVisualization(true)` schaltet sie nur sichtbar (PMI-Grundfarbe), und der Snap-Treffer setzt `highlightColor` — die ist in `environment.config.json` auf `[0,1,0,1]` = **grün** gesetzt, nicht gelb.

Gelb ist also nur der aktive DragPoint-Marker.


setzte die higllightcolor aml auf ein kalres grün
ich sehe nix davon


Die `highlightColor` steht bereits auf `[0, 1, 0, 1]` — das ist reines, klares Grün (RGBA, volle Deckkraft). Klarer geht Grün nicht; eine Änderung des Werts brächte nichts.

Dass du nichts siehst, liegt also nicht an der Farbe. Das Highlight wird nur gesetzt, wenn ein Snap-Treffer vorliegt (`best.jumpDistance < maxJumpDistance`, hier 500) und färbt dann den getroffenen DropPoint-PMI via `colorOverride` — sichtbar nur, sofern dieser DropPoint-PMI überhaupt als Geometrie gerendert wird. Der Punkt ist das DropPoint-Rendering, nicht der Farbwert.


aber die Türen snappen - oderr liegt das an uns --> Die snappen gar nicht im graph, sondern wir bauen das nach dem balegen?


244	    }
245
246	    private handlePlacementEnd(event: any): UIReaction[] {
247	        const data = extractPlacementData(event);
248	        const stop: UIReaction = { type: "sendCommand", cmd: "stop_interaction_mode", args: [] };
249	        this.replay();
250
251	        // The subject is always a top-level group node (an element to re-place is detached into its
252	        // own group first — see handleDetachElement — so the engine never gets a nested subject).
253	        const movingGroupId = this.resolveGroupId(data.handleId);
254	        if (!movingGroupId) return [stop];
255	        const placement = this.placementEvent(data, movingGroupId);
256	        if (placement) this.eventStore.push(placement);
257
258	        const scene = this.replay();
259	        return [stop, { type: "sendCommand", cmd: "setScene", args: [scene] }];
260	    }
261
262	    // Pull a part out of its group into its own top-level group, then start placement on that group's
263	    // node (a valid top-level subject). The freed neighbour points are open again post-detach.
264	    private handleDetachElement(event: any): UIReaction[] {
265	        this.replay();
266	        const detach = this.groups.buildDetachEvent(event.elementId);
267	        if (!detach) return [];
268	        this.eventStore.push(detach);
269	        const scene = this.replay();
270
271	        const groupId = detach.newGroups.find((ng) => ng.members.includes(event.elementId))?.groupId;
272	        const groupKey = groupId ? this.groups.groupKeyOf(groupId) : null;
273	        const setScene: UIReaction = { type: "sendCommand", cmd: "setScene", args: [scene] };
274	        if (!groupKey) return [setScene];
275
276	        const subject = "#" + groupKey;
277	        return [
278	            setScene,
279	            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
280	            {
281	                type: "sendCommand",
282	                cmd: "start_placement_mode",
283	                args: [
284	                    {
285	                        subject,
286	                        allowAllDragPoints: true,
287	                        dragPoints: this.placementDragPoints(subject),
288	                    },
289	                ],
290	            },
291	        ];
292	    }
293
294	    // The merge (on snap) or move (free) event for a moving group.
295	    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {
296	        if (data.dropHandleId) {
297	            const anchorRoot = this.groups.elementRootOf(data.dropHandleId);
298	            const joinRoot = data.dragHandleId ? this.groups.elementRootOf(data.dragHandleId) : null;

    }

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

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

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

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

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

        const subject = "#" + groupKey;
        return [
            setScene,
            { type: "sendCommand", cmd: "set_selection", args: [[subject]] },
            {
                type: "sendCommand",
                cmd: "start_placement_mode",
                args: [
                    {
                        subject,
                        allowAllDragPoints: true,
                        dragPoints: this.placementDragPoints(subject),
                    },
                ],
            },
        ];
    }

    // The merge (on snap) or move (free) event for a moving group.
    private placementEvent(data: any, movingGroupId: string): GroupEvent | null {
        if (data.dropHandleId) {
            const anchorRoot = this.groups.elementRootOf(data.dropHandleId);
            const joinRoot = data.dragHandleId ? this.groups.elementRootOf(data.dragHandleId) : null;


298	            const joinRoot = data.dragHandleId ? this.groups.elementRootOf(data.dragHandleId) : null;
299	            const targetGroupId = anchorRoot ? this.groups.groupIdOfElement(anchorRoot) : null;
300	            if (anchorRoot && joinRoot && targetGroupId && targetGroupId !== movingGroupId && data.dropPmiId && data.dragPmiId) {
301	                // The snapped points fix the join: the dragged point is placed onto the drop point.
302	                // Direction (any axis) follows from the point positions; the relative rotation (e.g. a
303	                // 90° branch onto a post) follows from the points' frame orientations.
304	                return {
305	                    type: "group-merge",
306	                    timestamp: Date.now(),
307	                    targetGroupId,
308	                    movingGroupId,
309	                    anchorRootId: anchorRoot,
310	                    joinRootId: joinRoot,
311	                    anchorPmiId: data.dropPmiId,
312	                    joinPmiId: data.dragPmiId,
313	                    rot: this.groups.dockRotation(anchorRoot, data.dropPmiId, joinRoot, data.dragPmiId),
314	                };
315	            }
316	            return null;
317	        }
318	        // No snap: free reposition of the group to the drop position.
319	        return {
320	            type: "group-move",
321	            timestamp: Date.now(),
322	            groupId: movingGroupId,
323	            position: data.position,
324	            rotation: this.groups.transformOf(movingGroupId)?.rot ?? IDENTITY_QUAT,
325	        };
326	    }
327
328	    private handleUndo(): UIReaction[] {
329	        if (!this.eventStore.canUndo) return [];
330	        this.eventStore.undo();
331	        const scene = this.replay();
332	        return [
333	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
334	            { type: "sendCommand", cmd: "setScene", args: [scene] },
335	            { type: "notification", message: "Action reverted" },
336	        ];
337	    }
338
339	    private handleRedo(): UIReaction[] {
340	        if (!this.eventStore.canRedo) return [];
341	        this.eventStore.redo();
342	        const scene = this.replay();
343	        return [
344	            { type: "sendCommand", cmd: "stop_interaction_mode", args: [] },
345	            { type: "sendCommand", cmd: "setScene", args: [scene] },
346	            { type: "notification", message: "Action reapplied" },
347	        ];
348	    }
349

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

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

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


Beides trifft zu — es sind zwei getrennte Schritte:

**1. Live-Snap während des Ziehens — macht fire, nicht der Graph.**
In `placementMode.processInput` bewertet fire die aktiven DropPoints, nimmt den besten Treffer (`best.jumpDistance < maxJumpDistance`) und setzt `subject.transform.matrix` live darauf — die Tür rastet visuell ein und der getroffene DropPoint wird grün (`highlightColor`). Das ist reine Interaction-Mode-Geometrie, im Szenegraph steht davon nichts.

**2. Verbau — bauen wir nach dem Ablegen.**
Beim Loslassen emittiert fire ein Placement-Event mit `drag-/dropPointSelector`. `handlePlacementEnd` (DefaultCommandProcessor) macht daraus:
- bei Snap-Treffer (`data.dropHandleId` + PMI-IDs) → `group-merge`-Event (die beiden Gruppen werden fest verbunden, Rotation aus `dockRotation`)
- ohne Treffer → `group-move` (freies Repositionieren)

Das Event geht in den `eventStore`, dann `replay()` → neuer Szenegraph → `setScene`.

Also: Der **Graph selbst hat keine Snap-Automatik**. Das Einrasten ist eine fire-Interaktion; die dauerhafte Docking-Beziehung entsteht erst danach, indem wir aus dem Placement-Ergebnis ein `group-merge` erzeugen und den Graph neu aufbauen. Deine Vermutung für den Verbau-Teil stimmt.

Wenn die Türen visuell gar nicht einrasten, liegt es am Schritt 1 (fire findet keinen DropPoint-Treffer — z. B. `jumpDistance` > 500 oder DropPoints werden nicht gefunden/gerendert); dann bekommt auch Schritt 2 kein `dropHandleId` und erzeugt nur `group-move`.


Dann muss ja gesnappt sein
ich seh nix grünes


Deine Logik stimmt — wenn sich die Gruppen verbinden (`group-merge`), war `dropHandleId` gesetzt, also hat fire gesnappt und `setVisualizationHighlight(grün)` aufgerufen. Dann ist die Frage, warum das Grün unsichtbar bleibt. Ich prüfe, wie der DropPoint gerendert wird:


cd /Users/marcus.hinz/projects/fire-visual
echo "=== active / colorOverride in pmiComponent ==="
grep -niE 'active|colorOverride|get diameter|visible|render' threed/pmi/module/src/component/pmiComponent.ts | head -20

Inspect pmiComponent active/colorOverride/diameter


=== active / colorOverride in pmiComponent ===
47:    private _colorOverride: Color | null = null;
91:            active: this.active,
257:    public get diameter(): number {
407:        return this.active && this.type !== PMIType.none && this.settings.selectable;
439:            case 'active':
440:                return value != undefined ? this.active == (value.toLowerCase() == 'true') : true;
472:    public set colorOverride(color: Color | null) {
474:        this._colorOverride = color;
477:    public get colorOverride(): Color | null {
478:        return this._colorOverride;
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


Das erklärt es. Der Unterschied zwischen gelbem DragPoint und grünem DropPoint liegt in der Größe:

- **Gelber DragPoint** = ein *separater* Marker, den fire in `createDragPointVisuals` baut, Radius `dragPointRadius × gizmoScale` — also skaliert (deshalb war er als Riesenball sichtbar).
- **Grüner DropPoint** = der PMI-Punkt *selbst*. `setVisualizationHighlight` setzt nur `pmiComponent.colorOverride = grün` und `active = true`. Gerendert wird er über die PointGeometryFactory als Kugel mit `component.diameter` — Default **0.05**, weil die `CS_OUT_*`-PMIs keine `diameter`-Property haben.

Und PMI-Größen werden **nicht** mit `gizmoScale` skaliert — im PMI-Code steht explizit „DEFAULT_RADIUS and PADDING should be environment scaled when we have the setting for it" (`pmiSelectable.uiGizmo.ts:11`), also noch nicht implementiert.

In der mm-Szene (die Tür misst ~2000 mm — belegt durch den 16000er-Riesenball, ~8× Türhöhe) ist eine Kugel mit Durchmesser 0.05 mm schlicht unsichtbar. Der Snap passiert, das Grün wird gesetzt — man sieht es nur nicht, weil der DropPoint 0,05 mm groß ist.

Sichtbar wird er, indem die `CS_OUT_*`-PMIs eine `diameter`-Property in Szenen-Größenordnung (z. B. 30–50) bekommen, oder das DropPoint-Highlight einen skalierten Marker analog zum DragPoint erhält.


können wir das mal ändern, dass ich sehe, wann da was snapped


Für „sehen, wann was snappt" gibt es zwei grundverschiedene Wege — mit deutlich unterschiedlichem Aufwand. Wichtig vorab: cpq lädt fire als kompiliertes Bundle (`@ad-cpq/visual-runtime/dist`), eine Änderung am fire-visual-Source wirkt erst nach Rebuild von fire-visual **und** visual-runtime.


AskUserQuestion

Question: Welchen Weg für das Snap-Signal?

Choices:
fire-visual: der ohnehin sichtbare gelbe DragPoint-Ball wird grün, sobald ein Snap aktiv ist (best.jumpDistance < maxJumpDistance). Minimale Änderung, wirkt für alle Produkte. Braucht Rebuild fire-visual + visual-runtime.
fire-visual: das DropPoint-Highlight rendert einen skalierten Marker analog zum DragPoint, statt den 0.05er PMI. Zeigt zusätzlich alle aktiven Drop-Ziele. Größere Änderung, Rebuild nötig.
Den CS_OUT_*-PMIs in den Szenegraph-JSONs eine diameter-Property (z. B. 40) geben. Wirkt sofort ohne Rebuild, aber nur für die bearbeiteten Produkte; DropPoints werden während Placement als Kugeln sichtbar.


Your questions have been answered: "Welchen Weg für das Snap-Signal?"="Katalog-Daten: diameter". You can now continue with these answers in mind.

Question: Welchen Weg für das Snap-Signal?

Choices:
fire-visual: der ohnehin sichtbare gelbe DragPoint-Ball wird grün, sobald ein Snap aktiv ist (best.jumpDistance < maxJumpDistance). Minimale Änderung, wirkt für alle Produkte. Braucht Rebuild fire-visual + visual-runtime.
fire-visual: das DropPoint-Highlight rendert einen skalierten Marker analog zum DragPoint, statt den 0.05er PMI. Zeigt zusätzlich alle aktiven Drop-Ziele. Größere Änderung, Rebuild nötig.
Den CS_OUT_*-PMIs in den Szenegraph-JSONs eine diameter-Property (z. B. 40) geben. Wirkt sofort ohne Rebuild, aber nur für die bearbeiteten Produkte; DropPoints werden während Placement als Kugeln sichtbar.


Zuerst das exakte Property-Format klären: wie `diameter` als PMI-Property serialisiert wird (usage-String) und ob es das im Katalog schon gibt.


Bash

cd /Users/marcus.hinz/projects/fire-visual
echo "=== PMIUsage enum ==="
grep -rniE 'enum PMIUsage|diameter|position|rotation|size' threed/pmi/module/src/component/*.ts 2>/dev/null | grep -iE 'PMIUsage|diameter =|position =|rotation =|=.*diameter|=.*position' | head
echo "=== wie liest diameter (getPropertyValueByUsage) ==="
sed -n '255,270p' threed/pmi/module/src/component/pmiComponent.ts

Find PMIUsage enum values for diameter


=== PMIUsage enum ===
threed/pmi/module/src/component/pmiSyncMask.ts:10:    rotation = 1 << 13,
threed/pmi/module/src/component/pmiComponent.ts:123:        const radius = this.diameter / 2;
threed/pmi/module/src/component/pmiComponent.ts:129:                const size = this.existsPropertyByUsage(PMIUsage.size)
threed/pmi/module/src/component/pmiComponent.ts:130:                    ? this.getPropertyValueByUsage(PMIUsage.size) as unknown as Vector3
threed/pmi/module/src/component/pmiComponent.ts:135:                const size2d = this.existsPropertyByUsage(PMIUsage.size)
threed/pmi/module/src/component/pmiComponent.ts:136:                    ? this.getPropertyValueByUsage(PMIUsage.size) as unknown as Vector2
threed/pmi/module/src/component/pmiComponent.ts:147:                const arrowLength = this.existsPropertyByUsage(PMIUsage.size)
threed/pmi/module/src/component/pmiComponent.ts:148:                    ? this.getPropertyValueByUsage(PMIUsage.size) as unknown as number
threed/pmi/module/src/component/pmiComponent.ts:163:        return this.existsPropertyByUsage(PMIUsage.position);
threed/pmi/module/src/component/pmiComponent.ts:171:        if (!this.existsPropertyByUsage(PMIUsage.position)) {
=== wie liest diameter (getPropertyValueByUsage) ===
    }

    public get diameter(): number {
        if (!this.existsPropertyByUsage(PMIUsage.diameter)) {
            switch (this.type) {
                case PMIType.point:
                    return 0.05;
                case PMIType.adapter:
                    return 0.2;
                default:
                    return 0.05;
            }
        }
        return this.getPropertyValueByUsage<NumberProperty>(TypedPropertyType.float, PMIUsage.diameter);
    }
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


cd /Users/marcus.hinz/projects/fire-visual
echo "=== PMIUsage enum Definition ==="
grep -rniE 'PMIUsage' threed/pmi/module/src/component/index.ts threed/pmi/module/src/component/*.ts 2>/dev/null | grep -iE 'enum|export' | head
f=$(grep -rl 'enum PMIUsage' threed/pmi/module/src 2>/dev/null | head -1); echo "Datei: $f"
grep -n 'diameter\|position\|rotation\|size\|none' "$f" 2>/dev/null | head

Find PMIUsage enum string values


=== PMIUsage enum Definition ===
threed/pmi/module/src/component/index.ts:5:export {PMIUsage} from './pmiUsage';
threed/pmi/module/src/component/index.ts:5:export {PMIUsage} from './pmiUsage';
threed/pmi/module/src/component/pmiUsage.ts:1:export enum PMIUsage {
Datei: threed/pmi/module/src/component/pmiUsage.ts
2:    none = 0,
3:    //Usage is needs to be Unique on an PMI-Component, to allow for Matrixtypes for position and rotation without introducing special-cases
4:    position,
5:    rotation,
6:    diameter,
9:    size,
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


`PMIUsage` ist numerisch (diameter=3), in der JSON steht `usage` aber als String. Ich hole das rohe Property-Format eines `CS_OUT`-PMI, um `diameter` exakt passend einzufügen, und prüfe ob es irgendwo schon eine diameter-Property gibt:


cd /Users/marcus.hinz/projects/adesso-cpq
echo "=== existiert usage:diameter schon irgendwo? ==="
grep -l '"usage":"diameter"' packages/frontend/.storybook/products/schutzzaun_basic/*.json 2>/dev/null | head
grep -rho '"usage":"[a-z]*"' packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json | sort | uniq -c
echo "=== rohes CS_OUT_R_0 PMI-Objekt (erste Fundstelle) ==="
python3 -c "
import json
d=json.load(open('packages/frontend/.storybook/products/schutzzaun_basic/SBS1044SLA.json'))
def walk(o):
    if isinstance(o,dict):
        for c in o.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi' and c.get('id')=='CS_OUT_R_0':
                print(json.dumps(c,ensure_ascii=False)); raise SystemExit
        for v in o.values(): walk(v)
    elif isinstance(o,list):
        for v in o: walk(v)
walk(d)
"

Get raw CS_OUT PMI object and check existing diameter usage


=== existiert usage:diameter schon irgendwo? ===
 232 "usage":"position"
 232 "usage":"rotation"
=== rohes CS_OUT_R_0 PMI-Objekt (erste Fundstelle) ===
{"_resourceClass": "component_pmi", "id": "CS_OUT_R_0", "tags": ["name-CS_OUT_R_0"], "type": "point", "properties": {"position": {"type": "vector3", "value": [1114.0, 1067.0, -20.0], "usage": "position"}, "rotation": {"type": "quaternion", "value": [-0.0, -90.0, 0.0], "usage": "rotation"}}}


Ich verifiziere den `type`-String für float und ermittle den Umfang in schutzzaun_basic:


cd /Users/marcus.hinz/projects/fire-visual
echo "=== TypedPropertyType float-String ==="
grep -rniE 'float|vector3|quaternion' $(grep -rl 'enum TypedPropertyType\|TypedPropertyType =' threed 2>/dev/null | grep -v node_modules | head -1) 2>/dev/null | head
grep -rn "float" threed/*/module/src/**/property*.ts 2>/dev/null | grep -iE "'float'|\"float\"|float =" | head
echo "--- Fallback: wie wird 'float' als property.type geparst ---"
grep -rniE "case 'float'|'float':|float:.*Property|TypedPropertyType\.float" threed --include='*.ts' 2>/dev/null | grep -v node_modules | grep -v '\.d\.ts' | head

Verify float property type string


=== TypedPropertyType float-String ===
threed/pmi/dist/types/dataManager/pmiPropertyModel.d.ts:1:import { EnumType, QuaternionType, TypedPropertyType, Vector2Type, Vector3Type } from '@fire-visual/engine';
threed/pmi/dist/types/dataManager/pmiPropertyModel.d.ts:3:export type PMIPropertyModelType = PMIBoolPropertyModel | PMIIntPropertyModel | PMIFloatPropertyModel | PMIStringPropertyModel | PMIVec3PropertyModel | PMIVec2PropertyModel | PMIQuatPropertyModel;
threed/pmi/dist/types/dataManager/pmiPropertyModel.d.ts:17:export type PMIFloatPropertyModel = PMIPropertyModel<TypedPropertyType.float, number>;
threed/pmi/dist/types/dataManager/pmiPropertyModel.d.ts:19:export type PMIVec3PropertyModel = PMIPropertyModel<TypedPropertyType.vector3, Vector3Type>;
threed/pmi/dist/types/dataManager/pmiPropertyModel.d.ts:21:export type PMIQuatPropertyModel = PMIPropertyModel<TypedPropertyType.quaternion, QuaternionType>;
--- Fallback: wie wird 'float' als property.type geparst ---
threed/pmi/module/tests/pmiComponent.test.ts:152:                    type: TypedPropertyType.float,
threed/pmi/module/tests/pmiComponent.test.ts:168:        expect(() => component.setProperty('number', TypedPropertyType.float, 4)).not.toThrow();
threed/pmi/module/tests/pmiComponent.test.ts:179:                    type: TypedPropertyType.float,
threed/pmi/module/tests/pmiComponent.test.ts:338:                    type: TypedPropertyType.float,
threed/pmi/module/tests/pmiComponent.test.ts:415:                diam: {type: TypedPropertyType.float, value: 0.1, usage: PMIUsage.diameter}
threed/pmi/module/tests/pmiComponent.test.ts:428:                diam: {type: TypedPropertyType.float, value: 0.4, usage: PMIUsage.diameter}
threed/pmi/module/tests/pmiComponent.test.ts:518:                size: {type: TypedPropertyType.float, value: 3, usage: PMIUsage.size}
threed/pmi/module/tests/render.test.ts:259:                    type: TypedPropertyType.float,
threed/pmi/module/tests/render.test.ts:375:                    type: TypedPropertyType.float,
threed/pmi/module/tests/render.test.ts:472:                    type: TypedPropertyType.float,
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


Format steht (`{"type":"float","value":…,"usage":"diameter"}`, `diameter = Radius×2`). Ich setze `diameter: 40` an allen `CS_OUT_*`-PMIs in schutzzaun_basic, mit Backup vorweg:


cd /Users/marcus.hinz/projects/adesso-cpq
DIR=packages/frontend/.storybook/products/schutzzaun_basic
BK=/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/d39343b7-d69e-4d28-8db5-a5803a4e8931/scratchpad/dropdot-backup
mkdir -p "$BK"
python3 << EOF
import json, glob, os, shutil
DIR="$DIR"; BK="$BK"; DIAM=40
files=sorted(glob.glob(os.path.join(DIR,"*.json")))
tot_files=0; tot_pmi=0; changed_files=0
def add_diam(node):
    n=0
    if isinstance(node,dict):
        for c in node.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi' and str(c.get('id','')).startswith('CS_OUT_'):
                props=c.setdefault('properties',{})
                has=any(isinstance(p,dict) and p.get('usage')=='diameter' for p in props.values())
                if not has:
                    props['diameter']={"type":"float","value":DIAM,"usage":"diameter"}
                    n+=1
        for v in node.values(): n+=add_diam(v)
    elif isinstance(node,list):
        for v in node: n+=add_diam(v)
    return n
for f in files:
    tot_files+=1
    shutil.copy(f, os.path.join(BK, os.path.basename(f)+".bak"))
    d=json.load(open(f,encoding='utf-8'))
    n=add_diam(d)
    if n:
        json.dump(d, open(f,'w',encoding='utf-8'), ensure_ascii=False, separators=(',',':'))
        changed_files+=1; tot_pmi+=n
print(f"Dateien gesamt: {tot_files}, geändert: {changed_files}, CS_OUT-PMIs mit diameter=40: {tot_pmi}")
print(f"Backup: {BK}")
EOF

Add diameter to CS_OUT PMIs in schutzzaun_basic with backup


Error: Exit code 1
Traceback (most recent call last):
  File "<stdin>", line 22, in <module>
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/json/__init__.py", line 293, in load
    return loads(fp.read(),
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc9 in position 16667: invalid continuation byte


Eine Datei ist nicht UTF-8 kodiert (Byte 0xc9), der Lauf brach mittendrin ab — Teiländerung. Backups der bereits verarbeiteten liegen vor. Ich mache robust weiter: Encoding je Datei erkennen und erhalten, Backup nur wenn noch keins existiert, idempotent (bereits gesetzte `diameter` werden übersprungen):


cd /Users/marcus.hinz/projects/adesso-cpq
DIR=packages/frontend/.storybook/products/schutzzaun_basic
BK=/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/d39343b7-d69e-4d28-8db5-a5803a4e8931/scratchpad/dropdot-backup
python3 << EOF
import json, glob, os
DIR="$DIR"; BK="$BK"; DIAM=40
os.makedirs(BK, exist_ok=True)
files=sorted(glob.glob(os.path.join(DIR,"*.json")))
def add_diam(node):
    n=0
    if isinstance(node,dict):
        for c in node.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi' and str(c.get('id','')).startswith('CS_OUT_'):
                props=c.setdefault('properties',{})
                if not any(isinstance(p,dict) and p.get('usage')=='diameter' for p in props.values()):
                    props['diameter']={"type":"float","value":DIAM,"usage":"diameter"}; n+=1
        for v in node.values(): n+=add_diam(v)
    elif isinstance(node,list):
        for v in node: n+=add_diam(v)
    return n
tot_files=len(files); changed=0; tot_pmi=0; enc_nonutf=[]
for f in files:
    raw=open(f,'rb').read()
    try:
        raw.decode('utf-8'); enc='utf-8'
    except UnicodeDecodeError:
        enc='latin-1'; enc_nonutf.append(os.path.basename(f))
    d=json.loads(raw.decode(enc))
    n=add_diam(d)
    if n:
        bpath=os.path.join(BK, os.path.basename(f)+".bak")
        if not os.path.exists(bpath): open(bpath,'wb').write(raw)  # Original sichern, nie überschreiben
        open(f,'wb').write(json.dumps(d,ensure_ascii=False,separators=(',',':')).encode(enc))
        changed+=1; tot_pmi+=n
print(f"Dateien: {tot_files}, geändert: {changed}, CS_OUT-PMIs mit diameter=40 ergänzt: {tot_pmi}")
print(f"Nicht-UTF8 (latin-1) Dateien: {len(enc_nonutf)} -> {enc_nonutf[:10]}")
EOF

Robustly add diameter with encoding handling


Error: Exit code 1
Traceback (most recent call last):
  File "<stdin>", line 24, in <module>
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/json/decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/json/decoder.py", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 2 column 1 (char 2)


Noch eine Datei ist kein valides JSON. Ich mache es pro Datei fehlertolerant — defekte überspringen und namentlich melden:


cd /Users/marcus.hinz/projects/adesso-cpq
DIR=packages/frontend/.storybook/products/schutzzaun_basic
BK=/private/tmp/claude-502/-Users-marcus-hinz-claude-projects-robotunits/d39343b7-d69e-4d28-8db5-a5803a4e8931/scratchpad/dropdot-backup
python3 << EOF
import json, glob, os
DIR="$DIR"; BK="$BK"; DIAM=40
os.makedirs(BK, exist_ok=True)
files=sorted(glob.glob(os.path.join(DIR,"*.json")))
def add_diam(node):
    n=0
    if isinstance(node,dict):
        for c in node.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi' and str(c.get('id','')).startswith('CS_OUT_'):
                props=c.setdefault('properties',{})
                if not any(isinstance(p,dict) and p.get('usage')=='diameter' for p in props.values()):
                    props['diameter']={"type":"float","value":DIAM,"usage":"diameter"}; n+=1
        for v in node.values(): n+=add_diam(v)
    elif isinstance(node,list):
        for v in node: n+=add_diam(v)
    return n
changed=0; tot_pmi=0; nonutf=[]; bad=[]
for f in files:
    raw=open(f,'rb').read()
    try:
        raw.decode('utf-8'); enc='utf-8'
    except UnicodeDecodeError:
        enc='latin-1'; nonutf.append(os.path.basename(f))
    try:
        d=json.loads(raw.decode(enc))
    except json.JSONDecodeError as e:
        bad.append((os.path.basename(f), len(raw), str(e))); continue
    n=add_diam(d)
    if n:
        bpath=os.path.join(BK, os.path.basename(f)+".bak")
        if not os.path.exists(bpath): open(bpath,'wb').write(raw)
        open(f,'wb').write(json.dumps(d,ensure_ascii=False,separators=(',',':')).encode(enc))
        changed+=1; tot_pmi+=n
print(f"Dateien: {len(files)}, geändert: {changed}, CS_OUT diameter ergänzt: {tot_pmi}")
print(f"latin-1 kodiert: {len(nonutf)} {nonutf[:10]}")
print(f"defekt/kein JSON ({len(bad)}):")
for b in bad: print("  ", b)
EOF

Fault-tolerant diameter addition with per-file error handling


Dateien: 78, geändert: 0, CS_OUT diameter ergänzt: 0
latin-1 kodiert: 1 ['schutzzaun_basic.json']
defekt/kein JSON (1):
   ('schutzzaun_basic.json', 164553, 'Expecting value: line 2 column 1 (char 2)')


Aufschluss: „schutzzaun_basic.json" ist alphabetisch die letzte Datei (Kleinschreibung sortiert nach den `SB*`-Dateien) und keine Szenegraph-Datei — der **erste** Lauf hat also alle echten Produkt-JSONs bereits geändert, bevor er an dieser letzten Datei abbrach. Dieser Lauf war idempotent (0 neue). Ich verifiziere den tatsächlichen Stand:


cd /Users/marcus.hinz/projects/adesso-cpq
DIR=packages/frontend/.storybook/products/schutzzaun_basic
python3 << 'EOF'
import json, glob, os
DIR="packages/frontend/.storybook/products/schutzzaun_basic"
files=sorted(glob.glob(os.path.join(DIR,"*.json")))
with_d=0; without_d=0; files_ok=0; files_with_csout=0; invalid=[]
def count(node):
    got=0; miss=0
    if isinstance(node,dict):
        for c in node.get('components',[]) or []:
            if isinstance(c,dict) and c.get('_resourceClass')=='component_pmi' and str(c.get('id','')).startswith('CS_OUT_'):
                props=c.get('properties',{}) or {}
                if any(isinstance(p,dict) and p.get('usage')=='diameter' for p in props.values()): got+=1
                else: miss+=1
        for v in node.values():
            g,m=count(v); got+=g; miss+=m
    elif isinstance(node,list):
        for v in node:
            g,m=count(v); got+=g; miss+=m
    return got,miss
for f in files:
    raw=open(f,'rb').read()
    try: enc='utf-8'; s=raw.decode('utf-8')
    except: enc='latin-1'; s=raw.decode('latin-1')
    try: d=json.loads(s)
    except Exception as e: invalid.append(os.path.basename(f)); continue
    files_ok+=1
    g,m=count(d); with_d+=g; without_d+=m
    if g or m: files_with_csout+=1
print(f"Valide JSON-Dateien: {files_ok}/{len(files)}  (nicht parsebar: {invalid})")
print(f"Dateien mit CS_OUT: {files_with_csout}")
print(f"CS_OUT-PMIs MIT diameter: {with_d}   OHNE: {without_d}")
EOF
echo "=== Was ist schutzzaun_basic.json? (erste 2 Zeilen, roh) ==="
head -c 200 packages/frontend/.storybook/products/schutzzaun_basic/schutzzaun_basic.json | cat -v | head

Verify diameter coverage and identify the odd file


Valide JSON-Dateien: 77/78  (nicht parsebar: ['schutzzaun_basic.json'])
Dateien mit CS_OUT: 77
CS_OUT-PMIs MIT diameter: 770   OHNE: 0
=== Was ist schutzzaun_basic.json? (erste 2 Zeilen, roh) ===
^M
### Artikel Definition^M
# Artikel SBE0174PNA^M
json set json_string ARTIKEL SBE0174PNA kataloggruppe "Element"^M
json set json_string ARTIKEL SBE0174PNA artikelgruppe "SBE1507PNA"^M
json set json_strin


Erledigt und verifiziert.

- **77 Szenegraph-Dateien** in schutzzaun_basic, **770 `CS_OUT_*`-DropPoints** haben jetzt `diameter: 40` (0 ohne). Alle Dateien valides JSON.
- `schutzzaun_basic.json` ist **keine** Szenegraph-Datei, sondern ein Tcl-artiges Definitions-Skript (CRLF, latin-1, `json set json_string …`) mit `.json`-Endung im Produktordner — enthält keine PMIs, wurde übersprungen und ist unangetastet.
- **Backup** aller geänderten Originale: `…/scratchpad/dropdot-backup/*.bak`

Effekt: Während des Platzierens werden die aktiven DropPoints als Kugeln (`diameter 40` → Radius 20 mm) sichtbar, der gesnappte grün (`highlightColor`). Zum Vergleich: der gelbe DragPoint-Ball hat Radius 80 mm — falls die DropPoints zu klein wirken, `diameter` höher setzen.

Gilt nur für schutzzaun_basic; dockt eine Tür an ein Feld eines anderen Zauntyps, hätte dessen `CS_OUT` noch kein `diameter`. Änderungen sind uncommittet.


Die sind jetzt immer grün
--> nicht nur bei snap


Das heißt die DropPoint-Grundfarbe (ohne Highlight) ist selbst grün — durch `diameter=40` sind jetzt alle aktiven DropPoints sichtbar, und der `colorOverride` (echtes Grün) unterscheidet sich nicht von der Grundfarbe. Ich prüfe, woher die Grundfarbe kommt:


cd /Users/marcus.hinz/projects/fire-visual
echo "=== colorOverride Anwendung + Default-Farbe im PMI-Rendering ==="
grep -rniE 'colorOverride|color|green|Color\.|material|diffuse|LineMaterial|defaultColor' threed/pmi/module/src --include='*.ts' 2>/dev/null | grep -viE 'test|\.d\.ts|colorOverride\b.*Color \| null' | grep -iE 'color|green|material|diffuse' | head -25

Find PMI base color and colorOverride application


=== colorOverride Anwendung + Default-Farbe im PMI-Rendering ===
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:7:    LineMaterialAsset,
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:13:import {Color, Transformation} from '@fire-visual/math';
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:48:                material: this.createMaterial(component)
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:57:                component.registerSyncCallback(PMISyncMask.color, this.updateMaterial.bind(this, lineRenderer, component)),
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:67:    private updateMaterial(lineRenderer: LineRendererComponent, component: PMIComponent): void {
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:68:        lineRenderer.material = this.createMaterial(component, lineRenderer);
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:80:    private createMaterial(component: PMIComponent, lineRenderer?: LineRendererComponent): LineMaterialAsset {
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:81:        if (component.existsPropertyByUsage(PMIUsage.color) || lineRenderer?.geometry.colors?.length == 0 || component.colorOverride != null) {
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:82:            return this.assetProvider.get(LineMaterialAsset, {diffuse: component.colorOverride ?? this.getByUsage(component, PMIUsage.color, Color.green)});
threed/pmi/module/src/ui/pmiComponent.uiGizmo.ts:84:        return this.assetProvider.get(LineMaterialAsset, {vertexColor: true});
threed/pmi/module/src/ui/pmiGeometryFactory/box.geometryFactory.ts:16:        const geometry = LineFactory.box(this.getByUsage(component, PMIUsage.size, new Vector3(.5,.5,.5)), {vertices: [], colors: []});
threed/pmi/module/src/ui/pmiGeometryFactory/matrix.geometryFactory.ts:5:import {Color, LineGeometry, MeshFactory, Vector3} from '@fire-visual/math';
threed/pmi/module/src/ui/pmiGeometryFactory/matrix.geometryFactory.ts:44:            colors: [
threed/pmi/module/src/ui/pmiGeometryFactory/matrix.geometryFactory.ts:45:                ...xLine.vertices.flatMap(() => Color.red),
threed/pmi/module/src/ui/pmiGeometryFactory/matrix.geometryFactory.ts:46:                ...yLine.vertices.flatMap(() => Color.green),
threed/pmi/module/src/ui/pmiGeometryFactory/matrix.geometryFactory.ts:47:                ...zLine.vertices.flatMap(() => Color.blue)
threed/pmi/module/src/ui/pmiGeometryFactory/adapter.geometryFactory.ts:5:import {Color, LineFactory, Vector3} from "@fire-visual/math";
threed/pmi/module/src/ui/pmiGeometryFactory/adapter.geometryFactory.ts:17:            geometry = LineFactory.sphere(new Vector3(diameter, diameter, diameter), {vertices: [], colors: []});
threed/pmi/module/src/ui/pmiGeometryFactory/adapter.geometryFactory.ts:20:            geometry.colors.push(Color.white, Color.white);
threed/pmi/module/src/ui/pmiGeometryFactory/adapter.geometryFactory.ts:26:                geometry.colors.push(Color.white, Color.white);
threed/pmi/module/src/component/pmiType.ts:4:    material,
threed/pmi/module/src/component/pmiPropertyType.ts:1:import {BoolProperty, ColorProperty, NumberProperty, QuaternionProperty, StringProperty, Vector2Property, Vector3Property} from '@fire-visual/engine';
threed/pmi/module/src/component/pmiPropertyType.ts:4:export type PMIPropertyType = BoolPMIProperty | NumberPMIProperty | StringPMIProperty | Vector3PMIProperty | Vector2PMIProperty | ColorPMIProperty | QuaternionPMIProperty;
threed/pmi/module/src/component/pmiPropertyType.ts:16:export type ColorPMIProperty = ColorProperty & PMIProperty;
threed/pmi/module/src/component/pmiComponent.ts:5:    ColorProperty,
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


Die sind jetzt immer grün --> nicht nur bei snap


Gefunden — `pmiComponent.uiGizmo.ts:82`: ohne `color`-Property und ohne Highlight ist die Default-Diffusfarbe **`Color.green`**. Deshalb sind alle sichtbaren DropPoints grün, ununterscheidbar vom grünen Snap-Highlight.

Fix datenseitig: den `CS_OUT_*`-PMIs eine neutrale `color`-Property (usage `color`) geben — dann ist die Grundfarbe neutral, nur der gesnappte grün. Ich verifiziere zuerst das Serialisierungsformat der color-Property:


cd /Users/marcus.hinz/projects/fire-visual
echo "=== TypedPropertyType color + Color value-Format ==="
grep -rniE "usage: PMIUsage\.color|PMIUsage\.color|TypedPropertyType\.color|type:.*color|'color'|color:.*value" threed/pmi/module/tests --include='*.ts' 2>/dev/null | head
echo "=== color property in tests (Beispiel) ==="
grep -rniB1 -A2 'PMIUsage.color' threed/pmi/module/tests/*.ts 2>/dev/null | head -20
echo "=== Color Typ: RGB oder RGBA? Color.green Definition ==="
grep -rniE 'green|static.*Color|readonly green|green =' $(grep -rl 'class Color\|Color =' threed/math 2>/dev/null | grep -v node_modules | grep -v '\.d\.ts' | head -1) 2>/dev/null | grep -i green | head

Verify color property serialization format