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');
+ });
+});