diff --git a/GDJS/Runtime/gameplay-tests/gameplay-test-runner.ts b/GDJS/Runtime/gameplay-tests/gameplay-test-runner.ts index 8b84cb13de92..4a1aa182f5e1 100644 --- a/GDJS/Runtime/gameplay-tests/gameplay-test-runner.ts +++ b/GDJS/Runtime/gameplay-tests/gameplay-test-runner.ts @@ -674,6 +674,9 @@ namespace gdjs { _startTimeMs: number = 0; _stopped: boolean = false; _assertions: Array = []; + /** What the harness noticed about the test itself while it ran (see + * `_addRunWarning`), reported with the warnings about the game. */ + _runWarnings: Array = []; _consoleLogs: Array = []; _consoleLogsTotalChars: number = 0; _eventLog: Array = []; @@ -986,6 +989,41 @@ namespace gdjs { return objectCounts; } + /** + * Something noticed while the test ran, reported once whatever the + * assertions said. + */ + private _addRunWarning(message: string): void { + if (this._runWarnings.length >= MAX_WARNINGS) return; + if (this._runWarnings.indexOf(message) !== -1) return; + this._runWarnings.push(message); + } + + /** + * A `getNearby` radius that cannot tell anything apart: it reaches + * further than the whole screen AND let every instance through, so a + * check on what it returns passes wherever the game put them. + */ + private _warnOnUnselectiveRadius( + objectName: string, + referenceObjectName: string, + radius: float, + keptCount: integer, + instancesCount: integer + ): void { + if (keptCount < instancesCount) return; + const screenDiagonal = Math.hypot( + this._runtimeGame.getGameResolutionWidth(), + this._runtimeGame.getGameResolutionHeight() + ); + if (!(radius > screenDiagonal)) return; + this._addRunWarning( + `getNearby("${objectName}", "${referenceObjectName}", ${radius}) kept every instance of "${objectName}": that radius is larger than the whole screen (${Math.round( + screenDiagonal + )}px diagonal), so it says nothing about where they are. Use a radius of the size of what is being checked.` + ); + } + /** * What the harness noticed about the game by itself, whatever the test * asserted: a test can pass on the values it checks while the game is @@ -2239,8 +2277,9 @@ namespace gdjs { const reference = this._makeObjectSnapshot(referenceInstances[0], 0); const referenceZ = reference.centerZ || 0; + const instances = this._getInstances(objectName); const nearby: Array = []; - for (const object of this._getInstances(objectName)) { + for (const object of instances) { const snapshot = this._makeObjectSnapshot(object, childrenDepth); const relativeX = snapshot.centerX - reference.centerX; const relativeY = snapshot.centerY - reference.centerY; @@ -2263,6 +2302,13 @@ namespace gdjs { }); } nearby.sort((a, b) => a.distance - b.distance); + this._warnOnUnselectiveRadius( + objectName, + referenceObjectName, + radius, + nearby.length, + instances.length + ); return nearby; } @@ -3565,7 +3611,7 @@ namespace gdjs { gameTimeMs: Math.round(this._gameTimeMs), assertions: this._assertions, errors: errors.slice(0, MAX_ERRORS), - warnings: this._getWarnings(), + warnings: [...this._runWarnings, ...this._getWarnings()], consoleLogs: this._consoleLogs, eventLog: this._eventLog, finalState: { diff --git a/GDJS/tests/tests/gameplaytestharness.js b/GDJS/tests/tests/gameplaytestharness.js index 49f2d0d8e6b1..605027bbd834 100644 --- a/GDJS/tests/tests/gameplaytestharness.js +++ b/GDJS/tests/tests/gameplaytestharness.js @@ -1872,6 +1872,37 @@ describe('gdjs.gameplayTests', () => { expect(tankCanon.x).to.be(140); }); + it('warns about a getNearby radius that keeps everything and covers the screen', async () => { + const harness = await makeHarnessWithSpawnedTank(); + harness.spawn('MyObject', 100, 200); + await harness.stepFrames(1); + + // The game is 800x600: a 100000px radius is not a proximity check. + harness.getNearby('CombinedTank', 'MyObject', 100000); + + expect(harness._runWarnings.length).to.be(1); + expect(harness._runWarnings[0]).to.contain( + 'kept every instance of "CombinedTank"' + ); + expect(harness._runWarnings[0]).to.contain( + 'larger than the whole screen (1000px diagonal)' + ); + }); + + it('says nothing about a radius that leaves an instance out, or one the screen holds', async () => { + const harness = await makeHarnessWithSpawnedTank(); + harness.spawn('MyObject', 100, 200); + harness.spawn('CombinedTank', 50000, 200, undefined, 'UI'); + await harness.stepFrames(1); + + // Far bigger than the screen, but it does tell the two tanks apart. + harness.getNearby('CombinedTank', 'MyObject', 2000); + // Keeps everything, but within what the screen shows. + harness.getNearby('MyObject', 'CombinedTank', 900); + + expect(harness._runWarnings.length).to.be(0); + }); + it('moves a point with the Z and Z scale of a parent without a THREE object', () => { const harness = makeStartedHarness(makeRuntimeGame()); // A 3D custom object places its children at diff --git a/newIDE/app/src/EditorFunctions/Extensions/CustomObjectFunctions.spec.js b/newIDE/app/src/EditorFunctions/Extensions/CustomObjectFunctions.spec.js index 1a1b9db539b0..990a9c7af0cb 100644 --- a/newIDE/app/src/EditorFunctions/Extensions/CustomObjectFunctions.spec.js +++ b/newIDE/app/src/EditorFunctions/Extensions/CustomObjectFunctions.spec.js @@ -458,6 +458,17 @@ describe('CustomObjectFunctions', () => { }); expect(result.success).toBe(true); + // The area, where the object turns and where its children ended up: + // "Top" is not centered on "Body", which only the boxes tell. + expect(result.message).toContain( + 'Fitted the area of the default variant to its children: 0;0;0 to 40;40;60 (children moved so (0;0;0) is their minimum corner).' + ); + expect(result.message).toContain( + 'Its center of rotation, the center of that area unless its events set another one, is at 20;20;30 from its own position' + ); + expect(result.message).toContain( + 'Children: "Body" X 0 to 40, Y 0 to 40, Z 0 to 40, middle 20;20;20; "Top" X 10 to 30, Y 10 to 30, Z 40 to 60, middle 20;20;50.' + ); // Children spanning 10..50 on X and Y, 0..60 on Z, moved to start at 0. expect(getArea(dialog)).toEqual({ minX: 0, @@ -481,6 +492,9 @@ describe('CustomObjectFunctions', () => { }); expect(result.success).toBe(true); + expect(result.message).toContain( + 'Its center of rotation, the center of that area unless its events set another one, is now its own position (0;0;0).' + ); // A symmetric area: its center (the center of rotation) is the origin. expect(getArea(dialog)).toEqual({ minX: -20, diff --git a/newIDE/app/src/EditorFunctions/Extensions/FitAreaToChildren.js b/newIDE/app/src/EditorFunctions/Extensions/FitAreaToChildren.js index c12ce26c923f..a97246f0068e 100644 --- a/newIDE/app/src/EditorFunctions/Extensions/FitAreaToChildren.js +++ b/newIDE/app/src/EditorFunctions/Extensions/FitAreaToChildren.js @@ -30,12 +30,30 @@ type Box = {| min: Array, max: Array |}; const roundBound = (value: number): number => Math.round(value * 1e6) / 1e6; -// The box of the children, or the child objects whose size could not be -// measured: moving the children and writing an area on a guessed size would -// silently shrink the custom object to nothing. -type ChildInstancesBox = - | {| box: Box | null, unmeasurableObjectNames: [] |} - | {| box: null, unmeasurableObjectNames: Array |}; +const roundCoordinate = (value: number): number => + Math.round(value * 100) / 100; + +// What the instances of one child object occupy, all of them together. +type ChildBox = {| objectName: string, box: Box, instancesCount: number |}; + +// The box of the children (`box` is null when there is no instance at all), or +// the child objects whose size could not be measured: moving the children and +// writing an area on a guessed size would silently shrink the custom object to +// nothing, so nothing is done when there is any. +type ChildInstancesBox = {| + box: Box | null, + unmeasurableObjectNames: Array, + childBoxes: Array, +|}; + +const growBox = (box: Box | null, added: Box): Box => { + if (!box) return { min: [...added.min], max: [...added.max] }; + for (let axis = 0; axis < 3; axis++) { + box.min[axis] = Math.min(box.min[axis], added.min[axis]); + box.max[axis] = Math.max(box.max[axis], added.max[axis]); + } + return box; +}; const forEachInstance = ( variant: gdEventsBasedObjectVariant, @@ -133,6 +151,7 @@ const getChildInstancesBox = ( const objects = variant.getObjects(); let box: Box | null = null; const unmeasurableObjectNames: Array = []; + const childBoxes: Array = []; forEachInstance(variant, instance => { const objectName = instance.getObjectName(); @@ -198,26 +217,64 @@ const getChildInstancesBox = ( ); const instanceBox = getInstanceBox(instance, minimumCorner, size, center); - const currentBox = box; - if (!currentBox) { - box = { min: [...instanceBox.min], max: [...instanceBox.max] }; + box = growBox(box, instanceBox); + const childBox = childBoxes.find( + candidate => candidate.objectName === objectName + ); + if (childBox) { + growBox(childBox.box, instanceBox); + childBox.instancesCount++; } else { - for (let axis = 0; axis < 3; axis++) { - currentBox.min[axis] = Math.min( - currentBox.min[axis], - instanceBox.min[axis] - ); - currentBox.max[axis] = Math.max( - currentBox.max[axis], - instanceBox.max[axis] - ); - } + childBoxes.push({ + objectName, + box: growBox(null, instanceBox), + instancesCount: 1, + }); } }); if (unmeasurableObjectNames.length > 0) - return { box: null, unmeasurableObjectNames }; - return { box, unmeasurableObjectNames: [] }; + return { box: null, unmeasurableObjectNames, childBoxes: [] }; + return { box, unmeasurableObjectNames: [], childBoxes }; +}; + +// At most this many child objects are described: the boxes are there to be +// compared with each other, which nobody does over a long list. +const MAX_DESCRIBED_CHILD_BOXES = 10; + +/** + * Where the children ended up, so that a part meant to be centered on another + * (a turret on a hull) can be seen not to be. Each child object is given the + * box of all of its instances, moved like them. + */ +const getChildBoxesDescription = ( + childBoxes: Array, + offsets: Array, + axesCount: number +): string => { + if (childBoxes.length === 0) return ''; + const axes = ['X', 'Y', 'Z']; + const described = childBoxes + .slice(0, MAX_DESCRIBED_CHILD_BOXES) + .map(({ objectName, box, instancesCount }) => { + const bounds = []; + const middle = []; + for (let axis = 0; axis < axesCount; axis++) { + const min = box.min[axis] + offsets[axis]; + const max = box.max[axis] + offsets[axis]; + bounds.push( + `${axes[axis]} ${roundCoordinate(min)} to ${roundCoordinate(max)}` + ); + middle.push(roundCoordinate((min + max) / 2)); + } + return `"${objectName}"${ + instancesCount > 1 ? ` (${instancesCount} instances)` : '' + } ${bounds.join(', ')}, middle ${middle.join(';')}`; + }); + const notDescribedCount = childBoxes.length - described.length; + return `Children: ${described.join('; ')}${ + notDescribedCount > 0 ? ` and ${notDescribedCount} more` : '' + }.`; }; /** @@ -234,7 +291,7 @@ const fitVariantAreaToChildren = ( variantLabel: string, pixiResourcesLoader: any ): string | null => { - const { box, unmeasurableObjectNames } = getChildInstancesBox( + const { box, unmeasurableObjectNames, childBoxes } = getChildInstancesBox( project, variant, pixiResourcesLoader @@ -299,16 +356,36 @@ const fitVariantAreaToChildren = ( variant.setAreaMaxY(areaMax[1]); variant.setAreaMaxZ(areaMax[2]); - const area = isRenderedIn3D - ? `${areaMin[0]};${areaMin[1]};${areaMin[2]} to ${areaMax[0]};${ - areaMax[1] - };${areaMax[2]}` - : `${areaMin[0]};${areaMin[1]} to ${areaMax[0]};${areaMax[1]}`; - return `Fitted the area of ${variantLabel} to its children: ${area}${ + const axesCount = isRenderedIn3D ? 3 : 2; + const formatPoint = (point: Array): string => + point + .slice(0, axesCount) + .map(roundCoordinate) + .join(';'); + const area = `${formatPoint(areaMin)} to ${formatPoint(areaMax)}`; + const zeroPoint = formatPoint([0, 0, 0]); + // The custom object turns around the center of its area (unless its own + // events move that center at runtime): said with the area, the one moment + // the agent can act on it. + const areaCenter = areaMin.map((min, axis) => (min + areaMax[axis]) / 2); + const rotationCenter = mode === 'centered_on_origin' - ? ' (children moved so (0;0;0) is their center, which is also the center of rotation of the custom object)' - : ' (children moved so (0;0;0) is their minimum corner)' - }.`; + ? `Its center of rotation, the center of that area unless its events set another one, is now its own position (${zeroPoint}).` + : `Its center of rotation, the center of that area unless its events set another one, is at ${formatPoint( + areaCenter + )} from its own position (\`centered_on_origin\` puts the two together).`; + + return [ + `Fitted the area of ${variantLabel} to its children: ${area}${ + mode === 'centered_on_origin' + ? ` (children moved so (${zeroPoint}) is their center)` + : ` (children moved so (${zeroPoint}) is their minimum corner)` + }.`, + rotationCenter, + getChildBoxesDescription(childBoxes, offsets, axesCount), + ] + .filter(Boolean) + .join(' '); }; /** diff --git a/newIDE/app/src/EditorFunctions/InstanceAnchor.js b/newIDE/app/src/EditorFunctions/InstanceAnchor.js new file mode 100644 index 000000000000..2a189ca88193 --- /dev/null +++ b/newIDE/app/src/EditorFunctions/InstanceAnchor.js @@ -0,0 +1,91 @@ +// @flow +import { type ObjectSizeInfo } from './Utils'; + +/** + * Which point of the instances a brush position places: + * - `origin`: their origin, which is what an instance x;y;z is - the default, + * and the only anchor needing nothing to be known of the object. + * - `min_corner`: the corner of their box with the minimum coordinates. + * - `center`: the middle of their box on every axis. + * - `bottom_center`: the middle on X and Y and the minimum on Z, what rests a + * 3D object on a surface (3D only: in 2D, Y is the axis pointing down). + */ +export type InstanceAnchor = + | 'origin' + | 'min_corner' + | 'center' + | 'bottom_center'; + +export const INSTANCE_ANCHORS_2D: Array = [ + 'origin', + 'min_corner', + 'center', +]; + +export const INSTANCE_ANCHORS_3D: Array = [ + 'origin', + 'min_corner', + 'center', + 'bottom_center', +]; + +// The anchor point, as a fraction of the box size on each axis. +const ANCHOR_FRACTIONS: { [InstanceAnchor]: Array } = { + origin: [0, 0, 0], + min_corner: [0, 0, 0], + center: [0.5, 0.5, 0.5], + bottom_center: [0.5, 0.5, 0], +}; + +/** + * What to add to a position given by `anchor` to get the position of the + * origin of an instance of `size`, which is the position an instance holds. + * The box is the unrotated, unflipped one, like the offsets of + * `objectSizeInfo`. + * + * `null` when the box of the object is unknown: the position could only be + * guessed, and every instance placed from it would be somewhere else. + */ +export const getAnchorOffset = ( + anchor: InstanceAnchor, + size: $ReadOnlyArray, + objectSizeInfo: ObjectSizeInfo | null +): Array | null => { + const zeroOffsets = size.map(() => 0); + if (anchor === 'origin') return zeroOffsets; + const fractions = ANCHOR_FRACTIONS[anchor]; + if (!fractions || !objectSizeInfo) return null; + + const defaultSizes = [ + objectSizeInfo.width, + objectSizeInfo.height, + objectSizeInfo.depth, + ]; + const origins = [ + objectSizeInfo.originX, + objectSizeInfo.originY, + objectSizeInfo.originZ, + ]; + const offsets = []; + for (let axis = 0; axis < size.length; axis++) { + const origin = origins[axis]; + // An axis the object has no extent on (the Z of a 2D object): its box is + // its position there, whatever the anchor. + if (origin === null) { + offsets.push(0); + continue; + } + const sizeOnAxis = size[axis]; + if (!Number.isFinite(sizeOnAxis)) return null; + let scaledOrigin = 0; + if (origin !== 0) { + const defaultSize = defaultSizes[axis]; + // The origin is given at the default size of the object: scale it to + // the size this instance really has. + if (!defaultSize) return null; + scaledOrigin = (origin * sizeOnAxis) / defaultSize; + } + offsets.push(scaledOrigin - fractions[axis] * sizeOnAxis); + } + return offsets; +}; diff --git a/newIDE/app/src/EditorFunctions/InstanceAnchor.spec.js b/newIDE/app/src/EditorFunctions/InstanceAnchor.spec.js new file mode 100644 index 000000000000..64600e341b65 --- /dev/null +++ b/newIDE/app/src/EditorFunctions/InstanceAnchor.spec.js @@ -0,0 +1,113 @@ +// @flow +import { getAnchorOffset } from './InstanceAnchor'; +import { type ObjectSizeInfo } from './Utils'; + +// A 3D model 80x40x20 whose origin is in the middle of its width, a quarter +// down its height and at the bottom of its depth: what a model authored around +// its own middle gives. +const modelSizeInfo: ObjectSizeInfo = { + width: 80, + height: 40, + depth: 20, + originX: 40, + originY: 10, + originZ: 0, + centerX: 40, + centerY: 10, + centerZ: 10, +}; + +// A 2D object positioned by the corner of its box, like a sprite with no +// origin point of its own. +const cornerSizeInfo: ObjectSizeInfo = { + width: 100, + height: 50, + depth: null, + originX: 0, + originY: 0, + originZ: null, + centerX: 50, + centerY: 25, + centerZ: null, +}; + +describe('getAnchorOffset', () => { + it('moves nothing for the origin anchor', () => { + expect(getAnchorOffset('origin', [80, 40, 20], modelSizeInfo)).toEqual([ + 0, + 0, + 0, + ]); + // Even without knowing anything of the object. + expect(getAnchorOffset('origin', [80, 40], null)).toEqual([0, 0]); + }); + + it('puts the box of an object on the position by its center', () => { + // The origin is 40;10;0 in the box, its middle is 40;20;10. + expect(getAnchorOffset('center', [80, 40, 20], modelSizeInfo)).toEqual([ + 0, + -10, + -10, + ]); + expect(getAnchorOffset('center', [100, 50], cornerSizeInfo)).toEqual([ + -50, + -25, + ]); + }); + + it('rests a 3D object on the position by the bottom of its box', () => { + expect( + getAnchorOffset('bottom_center', [80, 40, 20], modelSizeInfo) + ).toEqual([0, -10, 0]); + }); + + it('puts the minimum corner of the box on the position', () => { + expect(getAnchorOffset('min_corner', [80, 40, 20], modelSizeInfo)).toEqual([ + 40, + 10, + 0, + ]); + // An object positioned by that corner already does not move. + expect(getAnchorOffset('min_corner', [100, 50], cornerSizeInfo)).toEqual([ + 0, + 0, + ]); + }); + + it('scales the origin of the object to the size the instance has', () => { + // Half the default size on X, twice it on Y: the origin follows. + expect(getAnchorOffset('min_corner', [40, 80, 20], modelSizeInfo)).toEqual([ + 20, + 20, + 0, + ]); + expect(getAnchorOffset('center', [40, 80, 20], modelSizeInfo)).toEqual([ + 0, + -20, + -10, + ]); + }); + + it('leaves an axis the object has no extent on alone', () => { + // A 2D object placed in 3D: its box is its position on Z. + expect(getAnchorOffset('center', [100, 50, 30], cornerSizeInfo)).toEqual([ + -50, + -25, + 0, + ]); + }); + + it('gives no offset when the box of the object is unknown', () => { + expect(getAnchorOffset('center', [80, 40, 20], null)).toBe(null); + // A text has no size of its own: its origin cannot be scaled. + const noSizeInfo: ObjectSizeInfo = { + ...modelSizeInfo, + width: null, + height: null, + }; + expect(getAnchorOffset('center', [80, 40, 20], noSizeInfo)).toBe(null); + expect(getAnchorOffset('center', [Number.NaN, 40, 20], modelSizeInfo)).toBe( + null + ); + }); +}); diff --git a/newIDE/app/src/EditorFunctions/Put2dInstances.spec.js b/newIDE/app/src/EditorFunctions/Put2dInstances.spec.js index b81b43e79774..a7f1574df5b8 100644 --- a/newIDE/app/src/EditorFunctions/Put2dInstances.spec.js +++ b/newIDE/app/src/EditorFunctions/Put2dInstances.spec.js @@ -480,3 +480,129 @@ describe('put_2d_instances (instances_hidden)', () => { expect(created.isHidden()).toBe(true); }); }); + +describe('put_2d_instances (brush_position_anchor)', () => { + let project: gdProject; + let testScene: gdLayout; + + // A custom object whose area is symmetric on X and starts at 0 on Y: its + // origin is in the middle of its width and at the top of its height. + beforeEach(() => { + // $FlowFixMe[invalid-constructor] + project = new gd.ProjectHelper.createNewGDJSProject(); + const extension = project.insertNewEventsFunctionsExtension('UI', 0); + const panel = extension.getEventsBasedObjects().insertNew('Panel', 0); + panel.setAreaMinX(-50); + panel.setAreaMaxX(50); + panel.setAreaMinY(0); + panel.setAreaMaxY(40); + testScene = project.insertNewLayout('TestScene', 0); + testScene.getObjects().insertNewObject(project, 'UI::Panel', 'Panel', 0); + testScene + .getObjects() + .insertNewObject(project, 'TextObject::Text', 'Title', 1); + }); + + afterEach(() => { + project.delete(); + }); + + const putInstances = async (args: any) => + await editorFunctions.put_2d_instances.launchFunction({ + ...makeFakeLaunchFunctionOptionsWithProject(project), + args: { + scene_name: 'TestScene', + object_name: 'Panel', + layer_name: '', + brush_kind: 'point', + new_instances_count: 1, + ...args, + }, + }); + + const getPlacedPosition = () => { + const positions = []; + const functor = new gd.InitialInstanceJSFunctor(); + // $FlowFixMe[cannot-write] + functor.invoke = instancePtr => { + const instance: gdInitialInstance = gd.wrapPointer( + // $FlowFixMe[incompatible-type] + instancePtr, + gd.InitialInstance + ); + positions.push([instance.getX(), instance.getY()]); + }; + // $FlowFixMe[incompatible-type] + testScene.getInitialInstances().iterateOverInstances(functor); + functor.delete(); + return positions[0]; + }; + + it('centers the box of the instances on the position', async () => { + const result = await putInstances({ + brush_position: '100,100', + brush_position_anchor: 'center', + }); + + expect(result.success).toBe(true); + // The origin is already in the middle of the width: only Y moves. + expect(getPlacedPosition()).toEqual([100, 80]); + expect(result.message).toEqual( + expect.stringContaining( + 'anchored by their center on 100, 100, origin at this position, each occupies X 50 to 150, Y 80 to 120' + ) + ); + }); + + it('puts the minimum corner of the box on the position', async () => { + const result = await putInstances({ + brush_position: '0,0', + brush_position_anchor: 'min_corner', + }); + + expect(result.success).toBe(true); + expect(getPlacedPosition()).toEqual([50, 0]); + }); + + it('refuses an anchor on an object with no size of its own, until the instances get one', async () => { + // A text is as big as what it displays: nothing knows its box here. + const refused = await putInstances({ + object_name: 'Title', + brush_position: '100,100', + brush_position_anchor: 'center', + }); + + expect(refused.success).toBe(false); + expect(refused.message).toEqual( + expect.stringContaining( + '`brush_position_anchor: "center"` needs the box of "Title", which is unknown. Give the instances a size with `instances_size`' + ) + ); + + const sized = await putInstances({ + object_name: 'Title', + brush_position: '100,100', + brush_position_anchor: 'center', + instances_size: '200,40', + }); + + expect(sized.success).toBe(true); + // A text is positioned by the corner of its box: centering it moves it by + // half the size given to its instances. + expect(getPlacedPosition()).toEqual([0, 80]); + }); + + it('refuses the anchors of 3D objects', async () => { + const result = await putInstances({ + brush_position: '0,0', + brush_position_anchor: 'bottom_center', + }); + + expect(result.success).toBe(false); + expect(result.message).toEqual( + expect.stringContaining( + '`brush_position_anchor` must be one of: origin, min_corner, center (got "bottom_center").' + ) + ); + }); +}); diff --git a/newIDE/app/src/EditorFunctions/Put3dInstances.spec.js b/newIDE/app/src/EditorFunctions/Put3dInstances.spec.js index 635bfc0eb501..4d42225c4b31 100644 --- a/newIDE/app/src/EditorFunctions/Put3dInstances.spec.js +++ b/newIDE/app/src/EditorFunctions/Put3dInstances.spec.js @@ -412,3 +412,176 @@ describe('put_3d_instances (instances_hidden)', () => { expect(created.isHidden()).toBe(true); }); }); + +describe('put_3d_instances (brush_position_anchor)', () => { + let project: gdProject; + let testScene: gdLayout; + + // A custom object whose area is symmetric on X and Y and starts at 0 on Z: + // its origin is in the middle of its width and its height, and at the bottom + // of its depth - like the 3D models a scene is built from. + beforeEach(() => { + // $FlowFixMe[invalid-constructor] + project = new gd.ProjectHelper.createNewGDJSProject(); + const extension = project.insertNewEventsFunctionsExtension('Kit', 0); + const turret = extension.getEventsBasedObjects().insertNew('Turret', 0); + turret.markAsRenderedIn3D(true); + turret.setAreaMinX(-60); + turret.setAreaMaxX(60); + turret.setAreaMinY(-40); + turret.setAreaMaxY(40); + turret.setAreaMinZ(0); + turret.setAreaMaxZ(70); + testScene = project.insertNewLayout('TestScene', 0); + testScene.getObjects().insertNewObject(project, 'Kit::Turret', 'Turret', 0); + testScene.getObjects().insertNewObject(project, 'Sprite', 'Player', 1); + }); + + afterEach(() => { + project.delete(); + }); + + const putInstances = async (args: any) => + await editorFunctions.put_3d_instances.launchFunction({ + ...makeFakeLaunchFunctionOptionsWithProject(project), + args: { + scene_name: 'TestScene', + object_name: 'Turret', + layer_name: '', + brush_kind: 'point', + new_instances_count: 1, + ...args, + }, + }); + + const getPlacedInstance = () => { + const instances = []; + const functor = new gd.InitialInstanceJSFunctor(); + // $FlowFixMe[cannot-write] + functor.invoke = instancePtr => { + const instance: gdInitialInstance = gd.wrapPointer( + // $FlowFixMe[incompatible-type] + instancePtr, + gd.InitialInstance + ); + instances.push({ + id: instance.getPersistentUuid().slice(0, 10), + position: [instance.getX(), instance.getY(), instance.getZ()], + }); + }; + // $FlowFixMe[incompatible-type] + testScene.getInitialInstances().iterateOverInstances(functor); + functor.delete(); + return instances[0]; + }; + + const getPlacedPosition = () => { + const placed = getPlacedInstance(); + return placed ? placed.position : undefined; + }; + + it('places instances by their origin without an anchor', async () => { + const result = await putInstances({ brush_position: '0,0,0' }); + + expect(result.success).toBe(true); + expect(getPlacedPosition()).toEqual([0, 0, 0]); + }); + + it('centers the box of the instances on the position', async () => { + const result = await putInstances({ + brush_position: '0,0,0', + brush_position_anchor: 'center', + }); + + expect(result.success).toBe(true); + // The origin is already in the middle on X and Y: only Z moves, by half + // the depth of the object. + expect(getPlacedPosition()).toEqual([0, 0, -35]); + expect(result.message).toEqual( + expect.stringContaining( + 'anchored by their center on 0, 0, 0, origin at this position, each occupies X -60 to 60, Y -40 to 40, Z -35 to 35' + ) + ); + }); + + it('rests the instances on the position with the bottom_center anchor', async () => { + const result = await putInstances({ + brush_position: '10,20,30', + brush_position_anchor: 'bottom_center', + }); + + expect(result.success).toBe(true); + expect(getPlacedPosition()).toEqual([10, 20, 30]); + }); + + it('puts the minimum corner of the box on the position', async () => { + const result = await putInstances({ + brush_position: '0,0,0', + brush_position_anchor: 'min_corner', + }); + + expect(result.success).toBe(true); + expect(getPlacedPosition()).toEqual([60, 40, 0]); + }); + + it('scales the origin of the object to the size of the instances', async () => { + const result = await putInstances({ + brush_position: '0,0,0', + brush_position_anchor: 'min_corner', + instances_size: '60,40,35', + }); + + expect(result.success).toBe(true); + expect(getPlacedPosition()).toEqual([30, 20, 0]); + }); + + it('moves an existing instance by its anchor, at the size it has', async () => { + await putInstances({ + brush_position: '0,0,0', + instances_size: '60,40,35', + }); + const placed = getPlacedInstance(); + expect(placed.position).toEqual([0, 0, 0]); + + const result = await putInstances({ + brush_position: '100,100,100', + brush_position_anchor: 'center', + new_instances_count: 0, + existing_instance_ids: placed.id, + }); + + // Half the default size: its box is 60x40x35 around the position. + expect(result.success).toBe(true); + expect(getPlacedPosition()).toEqual([100, 100, 82.5]); + }); + + it('refuses an anchor when the box of the object is unknown', async () => { + const result = await putInstances({ + object_name: 'Player', + brush_position: '0,0,0', + brush_position_anchor: 'center', + }); + + expect(result.success).toBe(false); + expect(result.message).toEqual( + expect.stringContaining( + '`brush_position_anchor: "center"` needs the box of "Player", which is unknown' + ) + ); + expect(getPlacedPosition()).toBe(undefined); + }); + + it('refuses an anchor it does not know', async () => { + const result = await putInstances({ + brush_position: '0,0,0', + brush_position_anchor: 'top_left', + }); + + expect(result.success).toBe(false); + expect(result.message).toEqual( + expect.stringContaining( + '`brush_position_anchor` must be one of: origin, min_corner, center, bottom_center (got "top_left").' + ) + ); + }); +}); diff --git a/newIDE/app/src/EditorFunctions/ScopeInstancesAndLayers.spec.js b/newIDE/app/src/EditorFunctions/ScopeInstancesAndLayers.spec.js index fa166a0c2b47..5686a4ee8077 100644 --- a/newIDE/app/src/EditorFunctions/ScopeInstancesAndLayers.spec.js +++ b/newIDE/app/src/EditorFunctions/ScopeInstancesAndLayers.spec.js @@ -269,9 +269,7 @@ describe('scoped instances, layers and groups', () => { expect(describeResult.instancesForScopeLabel).toBe( 'custom object "UI::Dialog" (default variant)' ); - expect(describeResult.positionSemantics).toContain( - '(0;0) is the position of the custom object' - ); + expect(describeResult.positionSemantics).toContain('(0;0) is its origin'); expect((describeResult.instances || []).map(({ name }) => name)).toEqual([ 'Back', ]); diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index 41ef6c4ef2be..9c86d6c32145 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -98,7 +98,14 @@ import { import { ensureModel3DMeasurementLoaded, ensureModel3DMeasurementsLoaded, + isModel3DObjectMeasured, } from './Model3DSizeInfo'; +import { + getAnchorOffset, + INSTANCE_ANCHORS_2D, + INSTANCE_ANCHORS_3D, + type InstanceAnchor, +} from './InstanceAnchor'; import { executeScript } from './ScriptExecution/ScriptRunner'; import { buildExposedScriptFunctions } from './ScriptExecution/ExposedFunctions'; import { @@ -644,21 +651,35 @@ const INSTANCE_POSITION_SEMANTICS_MESSAGE = // Inside a custom object, positions are local to it: without this the AI // would place children in scene coordinates. const CUSTOM_OBJECT_INSTANCE_POSITION_SEMANTICS_MESSAGE = - 'These instances are the children of a custom object: they live in its local space, where (0;0) is the position of the custom object (they are never scene coordinates). ' + + 'These instances are the children of a custom object: they live in its own space, where (0;0) is its origin (they are never scene coordinates). ' + 'The default size of the custom object is its area (areaMinX to areaMaxX, areaMinY to areaMaxY, areaMinZ to areaMaxZ) when the variant defines one, otherwise the bounding box of the children. ' + - 'The custom object turns around the CENTER of that area, so make the area symmetric (areaMin = -areaMax on an axis) for an object rotating around its own position, like a turret. ' + + 'The custom object turns around the CENTER of that area (unless its own events set another center of rotation), so make the area symmetric (areaMin = -areaMax on an axis) for an object rotating around its own position, like a turret. ' + + 'At runtime the custom object places its children with its whole transform (its position, its angle around that center of rotation, its scale and its flips): a position taken out of the custom object (returned by one of its functions, given to a scene action) is NOT a scene position until it is converted with all of these. ' + '`change_custom_object({ fit_area_to_children })` sets the area from the children for you. ' + 'Resizing an instance of the custom object scales its children proportionally, unless `isInnerAreaFollowingParentSize` is set (children then keep their position and the area follows the parent size - the UI/layout case). ' + 'Rotation and flipping are applied by the parent, and in 3D the z of a child is relative to the z of the parent. ' + 'Layers are internal to the custom object (at runtime children are reported on the layer of the parent) and the z-order of children is relative inside the parent. ' + INSTANCE_POSITION_SEMANTICS_MESSAGE; +const roundPosition = (value: number) => Math.round(value * 100) / 100; + +/** The size an instance really has, falling back on the one of its object. */ +const getInstanceSize = ( + instance: gdInitialInstance, + objectSize: $ReadOnlyArray +): Array => + objectSize.map((size, axis) => { + if (axis === 2) + return instance.hasCustomDepth() ? instance.getCustomDepth() : size; + if (!instance.hasCustomSize()) return size; + return axis === 0 ? instance.getCustomWidth() : instance.getCustomHeight(); + }); + const getOccupiedSpaceDescription = ( position: $ReadOnlyArray, size: $ReadOnlyArray, objectSizeInfo: ObjectSizeInfo | null ): string => { - const round = (value: number) => Math.round(value * 100) / 100; const axes = ['X', 'Y', 'Z']; const originOffsets = [0, 0, 0]; if (objectSizeInfo) { @@ -684,11 +705,72 @@ const getOccupiedSpaceDescription = ( return size .map((sizeOnAxis, i) => { const min = position[i] - originOffsets[i]; - return `${axes[i]} ${round(min)} to ${round(min + sizeOnAxis)}`; + return `${axes[i]} ${roundPosition(min)} to ${roundPosition( + min + sizeOnAxis + )}`; }) .join(', '); }; +/** + * The anchor a `put_2d_instances`/`put_3d_instances` call asks for, checked + * against the anchors of that brush and against what is known of the object: + * any anchor but `origin` needs its box, which an object with no size of its + * own or a 3D model that could not be read does not give. + */ +const resolveInstanceAnchor = ({ + args, + allowedAnchors, + object, + objectName, + project, + objectSizeInfo, + size, +}: {| + args: any, + allowedAnchors: $ReadOnlyArray, + object: gdObject | null, + objectName: string | null, + project: gdProject, + objectSizeInfo: ObjectSizeInfo | null, + size: $ReadOnlyArray | null, +|}): + | {| success: true, anchor: InstanceAnchor |} + | {| success: false, failure: EditorFunctionGenericOutput |} => { + const anchorName = SafeExtractor.extractStringProperty( + args, + 'brush_position_anchor' + ); + if (!anchorName) return { success: true, anchor: 'origin' }; + if (!allowedAnchors.includes(anchorName)) { + return { + success: false, + failure: makeGenericFailure( + `\`brush_position_anchor\` must be one of: ${allowedAnchors.join( + ', ' + )} (got "${anchorName}").` + ), + }; + } + const anchor = ((anchorName: any): InstanceAnchor); + if (anchor === 'origin') return { success: true, anchor }; + + const isModelRead = !object || isModel3DObjectMeasured(object, project); + if (!size || !isModelRead || !getAnchorOffset(anchor, size, objectSizeInfo)) { + return { + success: false, + failure: makeGenericFailure( + `\`brush_position_anchor: "${anchor}"\` needs the box of ${ + objectName ? `"${objectName}"` : 'the object' + }, which is unknown${ + isModelRead ? '' : ' (its 3D model could not be read)' + }. Give the instances a size with \`instances_size\`, or place them by their \`origin\` (the default anchor).` + ), + }; + } + return { success: true, anchor }; +}; + // A custom object renders its child INSTANCES, not its child objects: a // variant declaring children with no instance placed renders nothing at all // (and the engine falls back to a 1x1x1 size, so nothing is visible and no @@ -4458,6 +4540,29 @@ const put2dInstances: EditorFunction = { const brushEndPosition = SafeExtractor.parseCommaSeparatedTwoFiniteNumbers( brush_end_position ); + const instancesSize = SafeExtractor.parseCommaSeparatedTwoFiniteNumbers( + instances_size + ); + // The size the instances will have, which scales the origin and the + // anchor offsets of the object. + const effectiveSize = + instancesSize || + (objectSizeInfo && + objectSizeInfo.width !== null && + objectSizeInfo.height !== null + ? [objectSizeInfo.width, objectSizeInfo.height] + : null); + const anchorResolution = resolveInstanceAnchor({ + args, + allowedAnchors: INSTANCE_ANCHORS_2D, + object: namedObject, + objectName: object_name, + project, + objectSizeInfo, + size: effectiveSize, + }); + if (anchorResolution.success === false) return anchorResolution.failure; + const { anchor } = anchorResolution; // The `line` and `grid` brushes need an end position to spread instances. // Fail early (before creating any instance) so the caller retries with a @@ -4717,9 +4822,6 @@ const put2dInstances: EditorFunction = { // The "none" brush keeps existing instances in place. } - const instancesSize = SafeExtractor.parseCommaSeparatedTwoFiniteNumbers( - instances_size - ); const instancesRotation = SafeExtractor.extractNumberProperty( args, 'instances_rotation' @@ -4753,6 +4855,34 @@ const put2dInstances: EditorFunction = { } }); + // The brush placed the instances by their anchor: move each of them to + // put its origin (what its position really is) there, at the size it + // ended up with. + if (anchor !== 'origin' && isPlacementBrush && effectiveSize) { + modifiedAndCreatedInstances.forEach(instance => { + const offsets = getAnchorOffset( + anchor, + getInstanceSize(instance, effectiveSize), + objectSizeInfo + ); + if (!offsets) return; + instance.setX(instance.getX() + offsets[0]); + instance.setY(instance.getY() + offsets[1]); + }); + } + + // The position the instances really hold, which the brush position only + // is when they are placed by their origin. + const anchorOffsets = + anchor === 'origin' || !effectiveSize + ? null + : getAnchorOffset(anchor, effectiveSize, objectSizeInfo); + const originBrushPosition = anchorOffsets + ? brushPosition.map((value, axis) => + roundPosition(value + anchorOffsets[axis]) + ) + : brushPosition; + // Track specific changes that were made if (newInstancesCount > 0) { const attrs = []; @@ -4766,20 +4896,17 @@ const put2dInstances: EditorFunction = { attrs.push(instancesHidden ? 'hidden at start' : 'visible at start'); 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; + if (anchor !== 'origin') + attrs.push( + `anchored by their ${anchor} on ${brushPosition.join(', ')}` + ); if ( (brush_kind === 'point' || brush_kind === 'none') && effectiveSize ) { attrs.push( `origin at this position, each occupies ${getOccupiedSpaceDescription( - brushPosition, + originBrushPosition, effectiveSize, objectSizeInfo )}` @@ -4795,7 +4922,7 @@ const put2dInstances: EditorFunction = { createdInstanceIds.length > 1 ? 's' : '' }: ${createdInstanceIds.join( ', ' - )}) using ${brush_kind} brush at ${brushPosition.join( + )}) using ${brush_kind} brush at ${originBrushPosition.join( ', ' )} on ${getLayerNameForMessage(layerName)}${ attrs.length > 0 ? ` (${attrs.join(', ')})` : '' @@ -5388,6 +5515,30 @@ const put3dInstances: EditorFunction = { const brushEndPosition = SafeExtractor.parseCommaSeparatedThreeFiniteNumbers( brush_end_position ); + const instancesSizeArray = SafeExtractor.parseCommaSeparatedThreeFiniteNumbers( + instances_size + ); + // The size the instances will have, which scales the origin and the + // anchor offsets of the object. + const effectiveSize = + instancesSizeArray || + (objectSizeInfo && + objectSizeInfo.width !== null && + objectSizeInfo.height !== null && + objectSizeInfo.depth !== null + ? [objectSizeInfo.width, objectSizeInfo.height, objectSizeInfo.depth] + : null); + const anchorResolution = resolveInstanceAnchor({ + args, + allowedAnchors: INSTANCE_ANCHORS_3D, + object: namedObject, + objectName: object_name, + project, + objectSizeInfo, + size: effectiveSize, + }); + if (anchorResolution.success === false) return anchorResolution.failure; + const { anchor } = anchorResolution; // The `line` brush needs an end position to spread instances. Fail early // (before creating any instance) so the caller retries with a valid @@ -5606,9 +5757,6 @@ const put3dInstances: EditorFunction = { // The "none" brush keeps existing instances in place. } - const instancesSizeArray = SafeExtractor.parseCommaSeparatedThreeFiniteNumbers( - instances_size - ); const instancesRotationArray = instances_rotation ? instances_rotation.split(',').map(coord => parseFloat(coord) || 0) : null; @@ -5635,6 +5783,35 @@ const put3dInstances: EditorFunction = { } }); + // The brush placed the instances by their anchor: move each of them to + // put its origin (what its position really is) there, at the size it + // ended up with. + if (anchor !== 'origin' && isPlacementBrush && effectiveSize) { + modifiedAndCreatedInstances.forEach(instance => { + const offsets = getAnchorOffset( + anchor, + getInstanceSize(instance, effectiveSize), + objectSizeInfo + ); + if (!offsets) return; + instance.setX(instance.getX() + offsets[0]); + instance.setY(instance.getY() + offsets[1]); + instance.setZ(instance.getZ() + offsets[2]); + }); + } + + // The position the instances really hold, which the brush position only + // is when they are placed by their origin. + const anchorOffsets = + anchor === 'origin' || !effectiveSize + ? null + : getAnchorOffset(anchor, effectiveSize, objectSizeInfo); + const originBrushPosition = anchorOffsets + ? brushPosition.map((value, axis) => + roundPosition(value + anchorOffsets[axis]) + ) + : brushPosition; + // Track specific changes that were made if (newInstancesCount > 0) { const attrs = []; @@ -5652,21 +5829,17 @@ const put3dInstances: EditorFunction = { ); if (instancesHidden !== null) attrs.push(instancesHidden ? 'hidden at start' : 'visible at start'); - const effectiveSize = instancesSizeArray - ? instancesSizeArray - : objectSizeInfo && - objectSizeInfo.width !== null && - objectSizeInfo.height !== null && - objectSizeInfo.depth !== null - ? [objectSizeInfo.width, objectSizeInfo.height, objectSizeInfo.depth] - : null; + if (anchor !== 'origin') + attrs.push( + `anchored by their ${anchor} on ${brushPosition.join(', ')}` + ); if ( (brush_kind === 'point' || brush_kind === 'none') && effectiveSize ) { attrs.push( `origin at this position, each occupies ${getOccupiedSpaceDescription( - brushPosition, + originBrushPosition, effectiveSize, objectSizeInfo )}` @@ -5682,7 +5855,7 @@ const put3dInstances: EditorFunction = { createdInstanceIds.length > 1 ? 's' : '' }: ${createdInstanceIds.join( ', ' - )}) using ${brush_kind} brush at ${brushPosition.join( + )}) using ${brush_kind} brush at ${originBrushPosition.join( ', ' )} on ${getLayerNameForMessage(layerName)}${ attrs.length > 0 ? ` (${attrs.join(', ')})` : ''