From e2e1e95c82253b66eb649cadd86232900b79b7b6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:52:33 +0000 Subject: [PATCH] Add STEM-focused Blockly blocks: Look At, Move Towards, and Distance - Implement `lookAt`, `moveTowards`, and `getDistance` in `BabylonSceneManager`. - Add `look_at`, `move_towards`, and `get_distance` Blockly blocks. - Rename 'Utils' toolbox category to 'Math'. - Add comprehensive Playwright tests in `tests/stem.spec.js`. - Address code review feedback with overshoot clamping in `moveTowards`. Co-authored-by: brettfxio <105930054+brettfxio@users.noreply.github.com> --- _layouts/default.html | 115 ++++++++++++++++++++++++++++++++++++++++-- tests/stem.spec.js | 107 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 tests/stem.spec.js diff --git a/_layouts/default.html b/_layouts/default.html index ef706c4..b86bcb2 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -1485,7 +1485,15 @@

Asset Manager

{ kind: 'block', type: 'set_texture_from_asset', - } + }, + { + kind: 'block', + type: 'look_at', + }, + { + kind: 'block', + type: 'move_towards', + } ] }, @@ -1562,9 +1570,13 @@

Asset Manager

}, { kind: 'category', - name: 'Utils', - categorystyle: 'colour_category', + name: 'Math', + categorystyle: 'math_category', contents: [ + { + kind: 'block', + type: 'get_distance', + }, { kind: 'block', type: 'colour_picker', @@ -2849,6 +2861,44 @@

Asset Manager

mesh.rotation.set(x * (Math.PI / 180), y * (Math.PI / 180), z * (Math.PI / 180)); } } + + lookAt(target, destination) { + const targetMesh = this._getMesh(target); + const destMesh = this._getMesh(destination); + if (targetMesh && destMesh) { + targetMesh.lookAt(destMesh.getAbsolutePosition()); + } + } + + moveTowards(target, destination, speed) { + const targetMesh = this._getMesh(target); + const destMesh = this._getMesh(destination); + if (targetMesh && destMesh) { + const direction = destMesh.getAbsolutePosition().subtract(targetMesh.getAbsolutePosition()); + const distance = direction.length(); + if (distance > 0.01) { + const stepSize = speed * (this.engine.getDeltaTime() / 1000); + if (stepSize >= distance) { + // If we're close enough to reach or overshoot, just jump to the destination + // To avoid parent/local space issues, we add the remaining direction vector + targetMesh.position.addInPlace(direction); + } else { + direction.normalize(); + const moveStep = direction.scale(stepSize); + targetMesh.position.addInPlace(moveStep); + } + } + } + } + + getDistance(target1, target2) { + const mesh1 = this._getMesh(target1); + const mesh2 = this._getMesh(target2); + if (mesh1 && mesh2) { + return BABYLON.Vector3.Distance(mesh1.getAbsolutePosition(), mesh2.getAbsolutePosition()); + } + return 0; + } changeColor(target, color) { let mesh = this._getMesh(target); if (mesh) { @@ -5863,6 +5913,45 @@

Asset Manager

"colour": "#5B80A5", "tooltip": "Sets the main text of an existing popup.", "helpUrl": "" + }, + { + "type": "look_at", + "message0": "make %1 look at %2", + "args0": [ + { "type": "input_value", "name": "OBJECT", "check": ["String", "Mesh"] }, + { "type": "input_value", "name": "TARGET", "check": ["String", "Mesh"] } + ], + "previousStatement": null, + "nextStatement": null, + "colour": 210, + "tooltip": "Makes one object face another object.", + "helpUrl": "" + }, + { + "type": "move_towards", + "message0": "move %1 towards %2 with speed %3", + "args0": [ + { "type": "input_value", "name": "OBJECT", "check": ["String", "Mesh"] }, + { "type": "input_value", "name": "TARGET", "check": ["String", "Mesh"] }, + { "type": "input_value", "name": "SPEED", "check": "Number" } + ], + "previousStatement": null, + "nextStatement": null, + "colour": 210, + "tooltip": "Moves one object towards another object at a given speed.", + "helpUrl": "" + }, + { + "type": "get_distance", + "message0": "distance between %1 and %2", + "args0": [ + { "type": "input_value", "name": "OBJECT1", "check": ["String", "Mesh"] }, + { "type": "input_value", "name": "OBJECT2", "check": ["String", "Mesh"] } + ], + "output": "Number", + "colour": 230, + "tooltip": "Returns the distance between two objects.", + "helpUrl": "" } ]); @@ -6401,6 +6490,26 @@

Asset Manager

return [`sceneManager.getPos${axis}(${target})`, javascript.Order.FUNCTION_CALL]; }; + javascript.javascriptGenerator.forBlock['look_at'] = function (block, generator) { + const object = generator.valueToCode(block, 'OBJECT', generator.ORDER_ATOMIC) || 'null'; + const target = generator.valueToCode(block, 'TARGET', generator.ORDER_ATOMIC) || 'null'; + return `sceneManager.lookAt(${object}, ${target});\n`; + }; + + javascript.javascriptGenerator.forBlock['move_towards'] = function (block, generator) { + const object = generator.valueToCode(block, 'OBJECT', generator.ORDER_ATOMIC) || 'null'; + const target = generator.valueToCode(block, 'TARGET', generator.ORDER_ATOMIC) || 'null'; + const speed = generator.valueToCode(block, 'SPEED', generator.ORDER_ATOMIC) || 0; + return `sceneManager.moveTowards(${object}, ${target}, ${speed});\n`; + }; + + javascript.javascriptGenerator.forBlock['get_distance'] = function (block, generator) { + const object1 = generator.valueToCode(block, 'OBJECT1', generator.ORDER_ATOMIC) || 'null'; + const object2 = generator.valueToCode(block, 'OBJECT2', generator.ORDER_ATOMIC) || 'null'; + const code = `sceneManager.getDistance(${object1}, ${object2})`; + return [code, generator.ORDER_ATOMIC]; + }; + javascript.javascriptGenerator.forBlock['get_collided_object'] = function (block, generator) { const collidedObjectVar = generator.nameDB_.getName('collided_object', Blockly.VARIABLE_CATEGORY_NAME); return [collidedObjectVar, javascript.Order.ATOMIC]; diff --git a/tests/stem.spec.js b/tests/stem.spec.js new file mode 100644 index 0000000..908950b --- /dev/null +++ b/tests/stem.spec.js @@ -0,0 +1,107 @@ +const { test, expect } = require('@playwright/test'); + +test.describe('Engine STEM Features', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Handle the hero overlay + const startButton = page.locator('#start-button'); + if (await startButton.isVisible()) { + await startButton.click(); + } + }); + + test('Distance measurement and move towards work', async ({ page }) => { + // Switch to preview tab + await page.click('#preview-tab'); + + const consoleMessages = []; + page.on('console', msg => consoleMessages.push(msg.text())); + + const testCode = ` + (async () => { + const box1 = sceneManager.createBox('box1', 0, 0, 0); + const box2 = sceneManager.createBox('box2', 10, 0, 0); + + // Force world matrix update to get correct absolute positions + box1.computeWorldMatrix(true); + box2.computeWorldMatrix(true); + + const dist = sceneManager.getDistance('box1', 'box2'); + console.log('DISTANCE_INITIAL: ' + dist); + + sceneManager.everyFrame('box1', (mesh) => { + sceneManager.moveTowards('box1', 'box2', 10); // increased speed + }); + + // Wait a bit + await new Promise(r => setTimeout(r, 500)); + + // Again force update for measurement + box1.computeWorldMatrix(true); + box2.computeWorldMatrix(true); + + const distMid = sceneManager.getDistance('box1', 'box2'); + console.log('DISTANCE_MID: ' + distMid); + + if (distMid < dist && dist > 0) { + console.log('STEM_MOVE_SUCCESS'); + } else { + console.log('STEM_MOVE_FAILURE: initial=' + dist + ' mid=' + distMid); + } + })(); + `; + + await page.evaluate((code) => { + window.doRun(code); + }, testCode); + + await expect.poll(() => consoleMessages).toContain('STEM_MOVE_SUCCESS'); + }); + + test('Look at works', async ({ page }) => { + await page.click('#preview-tab'); + + const consoleMessages = []; + page.on('console', msg => consoleMessages.push(msg.text())); + + const testCode = ` + (async () => { + const box1 = sceneManager.createBox('box1', 0, 0, 0); + const box2 = sceneManager.createBox('box2', 10, 0, 10); // placed at an angle + + box1.computeWorldMatrix(true); + box2.computeWorldMatrix(true); + + const initialRot = box1.rotation.clone(); + const initialQuat = box1.rotationQuaternion ? box1.rotationQuaternion.clone() : null; + + sceneManager.lookAt('box1', 'box2'); + + const finalRot = box1.rotation; + const finalQuat = box1.rotationQuaternion; + + console.log('ROTATION_INITIAL: ' + initialRot); + console.log('ROTATION_FINAL: ' + finalRot); + + let changed = !finalRot.equals(initialRot); + if (finalQuat) { + if (!initialQuat || !finalQuat.equals(initialQuat)) { + changed = true; + } + } + + if (changed) { + console.log('STEM_LOOKAT_SUCCESS'); + } else { + console.log('STEM_LOOKAT_FAILURE'); + } + })(); + `; + + await page.evaluate((code) => { + window.doRun(code); + }, testCode); + + await expect.poll(() => consoleMessages).toContain('STEM_LOOKAT_SUCCESS'); + }); +});