From 317756f25413b674a62a7218c96c0fdbaaf2c453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:51:18 +0200 Subject: [PATCH] Hint when positioning objects stacked together --- .../EditorFunctions/EditorFunctions.spec.js | 71 +++++++++++ newIDE/app/src/EditorFunctions/index.js | 116 ++++++++++++++---- 2 files changed, 166 insertions(+), 21 deletions(-) diff --git a/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js b/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js index 86767cc165eb..eb1ff3581c65 100644 --- a/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js +++ b/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js @@ -1918,6 +1918,59 @@ describe('editorFunctions', () => { } }); + // The brush spreads instance origins, so a step smaller than the instance + // size leaves them overlapping. Instances are still created (it is only a + // hint), but the message must flag it so the model can switch to `point`. + it('hints when a line brush packs instances closer than their size', async () => { + const result = await putInstances({ + brush_kind: 'line', + brush_position: '0,0', + brush_end_position: '90,0', + instances_size: '100,100', + new_instances_count: 4, + }); + + expect(getInstancePositions(testScene)).toHaveLength(4); + expect(result.message).toEqual( + expect.stringContaining('overlap each other') + ); + }); + + it('hints when a grid brush packs instances closer than their size', async () => { + const result = await putInstances({ + brush_kind: 'grid', + brush_position: '0,0', + brush_end_position: '100,100', + instances_size: '100,100', + new_instances_count: 9, + }); + + expect(getInstancePositions(testScene)).toHaveLength(9); + expect(result.message).toEqual( + expect.stringContaining('overlap each other') + ); + }); + + it('does not hint about overlap when instances are spread apart by their size', async () => { + const result = await putInstances({ + brush_kind: 'line', + brush_position: '0,0', + brush_end_position: '300,0', + instances_size: '100,100', + new_instances_count: 4, + }); + + expect(getInstancePositions(testScene)).toEqual([ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 200, y: 0 }, + { x: 300, y: 0 }, + ]); + expect(result.message).not.toEqual( + expect.stringContaining('overlap each other') + ); + }); + it('moves an existing instance to a new position with the point brush', async () => { await putInstances({ brush_kind: 'point', @@ -2172,6 +2225,24 @@ describe('editorFunctions', () => { }); }); + // A line step smaller than the instance size on every axis leaves the + // instances overlapping. They are still created (it is only a hint), but + // the message must flag it so the model can switch to `point`. + it('hints when a line brush packs instances closer than their size', async () => { + const result = await putInstances({ + brush_kind: 'line', + brush_position: '0,0,0', + brush_end_position: '90,90,90', + instances_size: '100,100,100', + new_instances_count: 4, + }); + + expect(getInstancePositions(testScene)).toHaveLength(4); + expect(result.message).toEqual( + expect.stringContaining('overlap each other') + ); + }); + // Note: there is intentionally no grid test here. `grid` is not part of // supported3dBrushKinds, so the tool schema prevents the model from ever // sending it to put_3d_instances (unlike the 2D variant, which supports it). diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index fd9324761fb7..4fda8787fd70 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -2980,6 +2980,19 @@ const put2dInstances: EditorFunction = { } // Paint the new/modified instances with the brush. + const instancesSize = SafeExtractor.parseCommaSeparatedTwoFiniteNumbers( + instances_size + ); + const effectiveBrushSize = + instancesSize || + (objectSizeInfo && + objectSizeInfo.width !== null && + objectSizeInfo.height !== null + ? [objectSizeInfo.width, objectSizeInfo.height] + : null); + // Hints surfaced in the success message (e.g. instances ending up + // stacked) so the model can correct itself on the next turn. + const brushPlacementHints = []; if (brush_kind === 'line') { const instancesCount = modifiedAndCreatedInstances.length; @@ -2997,6 +3010,24 @@ const put2dInstances: EditorFunction = { instance.setX(brushPosition[0] + i * deltaX); instance.setY(brushPosition[1] + i * deltaY); }); + + // The brush spreads instance origins, so a step smaller than the + // instance size leaves them overlapping each other - often a mistake + // (a single large instance should use the `point` brush). + if ( + effectiveBrushSize && + instancesCount > 1 && + Math.abs(deltaX) < effectiveBrushSize[0] && + Math.abs(deltaY) < effectiveBrushSize[1] + ) { + brushPlacementHints.push( + `Note: these ${instancesCount} instances overlap each other (spacing ${Math.round( + Math.hypot(deltaX, deltaY) + )}px vs size ${effectiveBrushSize[0]}x${ + effectiveBrushSize[1] + }). If you wanted a single large instance, use the "point" brush with that size; if you wanted them spread out, move brush_end_position further away or lower new_instances_count.` + ); + } } } else if (brush_kind === 'grid') { const instancesCount = modifiedAndCreatedInstances.length; @@ -3047,6 +3078,27 @@ const put2dInstances: EditorFunction = { instance.setX(brushPosition[0] + column * gridColumnSize); instance.setY(brushPosition[1] + row * gridRowSize); }); + + // A column or row step smaller than the instance size leaves the grid + // overlapping on that axis - often a mistake (a single large instance + // should use the `point` brush). + if ( + effectiveBrushSize && + ((gridColumnCount > 1 && + Math.abs(gridColumnSize) < effectiveBrushSize[0]) || + (gridRowCount > 1 && + Math.abs(gridRowSize) < effectiveBrushSize[1])) + ) { + brushPlacementHints.push( + `Note: these ${instancesCount} instances overlap each other (grid spacing ${Math.round( + Math.abs(gridColumnSize) + )}x${Math.round(Math.abs(gridRowSize))}px vs size ${ + effectiveBrushSize[0] + }x${ + effectiveBrushSize[1] + }). If you wanted a single large instance, use the "point" brush with that size; if you wanted them spread out, move brush_end_position further away or lower new_instances_count.` + ); + } } } else if (brush_kind === 'random_in_circle') { modifiedAndCreatedInstances.forEach(instance => { @@ -3076,9 +3128,6 @@ const put2dInstances: EditorFunction = { } } - const instancesSize = SafeExtractor.parseCommaSeparatedTwoFiniteNumbers( - instances_size - ); const instancesRotation = SafeExtractor.extractNumberProperty( args, 'instances_rotation' @@ -3116,13 +3165,7 @@ const put2dInstances: EditorFunction = { attrs.push(`opacity ${instancesOpacity}/255`); if (instances_z_order !== null) attrs.push(`z-order ${instances_z_order}`); - const effectiveSize = instancesSize - ? instancesSize - : objectSizeInfo && - objectSizeInfo.width !== null && - objectSizeInfo.height !== null - ? [objectSizeInfo.width, objectSizeInfo.height] - : null; + const effectiveSize = effectiveBrushSize; if (brush_kind === 'point' && effectiveSize) { attrs.push( `origin at this position, each occupies ${getOccupiedSpaceDescription( @@ -3144,6 +3187,10 @@ const put2dInstances: EditorFunction = { ); } + brushPlacementHints.forEach(hint => { + changes.push(hint); + }); + // Check what changed for existing instances let movedToLayerCount = 0; let movedPositionCount = 0; @@ -3602,6 +3649,20 @@ const put3dInstances: EditorFunction = { } // Paint the new/modified instances with the brush. + const instancesSizeArray = SafeExtractor.parseCommaSeparatedThreeFiniteNumbers( + instances_size + ); + const effectiveBrushSize = + instancesSizeArray || + (objectSizeInfo && + objectSizeInfo.width !== null && + objectSizeInfo.height !== null && + objectSizeInfo.depth !== null + ? [objectSizeInfo.width, objectSizeInfo.height, objectSizeInfo.depth] + : null); + // Hints surfaced in the success message (e.g. instances ending up + // stacked) so the model can correct itself on the next turn. + const brushPlacementHints = []; if (brush_kind === 'line') { const instancesCount = modifiedAndCreatedInstances.length; @@ -3624,6 +3685,25 @@ const put3dInstances: EditorFunction = { instance.setY(brushPosition[1] + i * deltaY); instance.setZ(brushPosition[2] + i * deltaZ); }); + + // A step smaller than the instance size on every axis leaves the + // instances overlapping each other - often a mistake (a single large + // instance should use the `point` brush). + if ( + effectiveBrushSize && + instancesCount > 1 && + Math.abs(deltaX) < effectiveBrushSize[0] && + Math.abs(deltaY) < effectiveBrushSize[1] && + Math.abs(deltaZ) < effectiveBrushSize[2] + ) { + brushPlacementHints.push( + `Note: these ${instancesCount} instances overlap each other (spacing ${Math.round( + Math.hypot(deltaX, deltaY, deltaZ) + )}px vs size ${effectiveBrushSize[0]}x${effectiveBrushSize[1]}x${ + effectiveBrushSize[2] + }). If you wanted a single large instance, use the "point" brush with that size; if you wanted them spread out, move brush_end_position further away or lower new_instances_count.` + ); + } } } else if (brush_kind === 'random_in_sphere') { modifiedAndCreatedInstances.forEach(instance => { @@ -3662,9 +3742,6 @@ const put3dInstances: EditorFunction = { } } - const instancesSizeArray = SafeExtractor.parseCommaSeparatedThreeFiniteNumbers( - instances_size - ); const instancesRotationArray = instances_rotation ? instances_rotation.split(',').map(coord => parseFloat(coord) || 0) : null; @@ -3699,14 +3776,7 @@ const put3dInstances: EditorFunction = { instancesRotationArray[1] }°, ${instancesRotationArray[2]}°)` ); - const effectiveSize = instancesSizeArray - ? instancesSizeArray - : objectSizeInfo && - objectSizeInfo.width !== null && - objectSizeInfo.height !== null && - objectSizeInfo.depth !== null - ? [objectSizeInfo.width, objectSizeInfo.height, objectSizeInfo.depth] - : null; + const effectiveSize = effectiveBrushSize; if (brush_kind === 'point' && effectiveSize) { attrs.push( `origin at this position, each occupies ${getOccupiedSpaceDescription( @@ -3728,6 +3798,10 @@ const put3dInstances: EditorFunction = { ); } + brushPlacementHints.forEach(hint => { + changes.push(hint); + }); + // Check what changed for existing instances let movedToLayerCount = 0; let movedPositionCount = 0;