diff --git a/style.css b/style.css index 381f45946..e3f52892d 100644 --- a/style.css +++ b/style.css @@ -551,6 +551,10 @@ button { fill: var(--color-bg); } +.gizmo-button.active { + background-color: var(--color-focus); +} + #colorPickerButton svg { /* filter: invert(1) brightness(2); Make palette icon white */ fill: #ffffff !important; @@ -1320,4 +1324,4 @@ body.color-picker-open #renderCanvas { gap: 8px; overflow: visible !important; /* override old scrollbar-hiding rule */ list-style: none; -} \ No newline at end of file +} diff --git a/ui/gizmos.js b/ui/gizmos.js index 525ba6a96..ea55e96f4 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -37,11 +37,16 @@ let textOrigScaleZ = 1; // Color picking keyboard mode variables let colorPickingKeyboardMode = false; -// eslint-disable-next-line no-unused-vars let colorPickingCallback = null; let colorPickingCircle = null; let colorPickingCirclePosition = { x: 0, y: 0 }; +let _onPickMeshRef = null; +let cameraMode = "play"; + +// Track DO sections and their associated blocks for cleanup +const gizmoCreatedBlocks = new Map(); // blockId -> { parentId, createdDoSection, timestamp } + document.addEventListener("DOMContentLoaded", function () { const colorButton = document.getElementById("colorPickerButton"); @@ -133,8 +138,6 @@ document.addEventListener("DOMContentLoaded", function () { } }); -let _onPickMeshRef = null; - function pickMeshFromCanvas() { const canvas = flock.scene.getEngine().getRenderingCanvas(); @@ -188,8 +191,6 @@ function applyColorAtPosition(canvasX, canvasY) { } } -let cameraMode = "play"; - // Color Picking Keyboard Mode Functions function startColorPickingKeyboardMode(callback) { @@ -480,6 +481,22 @@ function focusCameraOnMesh() { } } +function getScaledSize(mesh) { + const { originalMin, originalMax } = mesh.metadata || {}; + const min = originalMin ?? mesh.getBoundingInfo().boundingBox.minimum; + const max = originalMax ?? mesh.getBoundingInfo().boundingBox.maximum; + + const baseX = max.x - min.x; + const baseY = max.y - min.y; + const baseZ = max.z - min.z; + + return { + x: baseX * Math.abs(mesh.scaling.x), + y: baseY * Math.abs(mesh.scaling.y), + z: baseZ * Math.abs(mesh.scaling.z), + }; +} + export function disableGizmos() { if (!gizmoManager) return; // Disable all gizmos @@ -490,697 +507,707 @@ export function disableGizmos() { endColorPickingMode(); } +// Toggle which Gizmo is being used export function toggleGizmo(gizmoType) { disableGizmos(); resetAttachedMeshIfMeshAttached(); document.body.style.cursor = "default"; - let blockKey, blockId, canvas, onPickMesh; - // Enable the selected gizmo switch (gizmoType) { - case "camera": { - if (cameraMode === "play") { - cameraMode = "fly"; - flock.printText({ - text: translate("fly_camera_instructions"), - duration: 15, - color: "white", - }); - } else { - cameraMode = "play"; - } - - const currentCamera = flock.scene.activeCamera; - console.log("Camera", flock.savedCamera); - flock.scene.activeCamera = flock.savedCamera; - flock.savedCamera = currentCamera; + case "camera": + handleCameraGizmo(); break; - } case "delete": - if (!gizmoManager.attachedMesh) { - flock.printText({ - text: translate("select_mesh_delete_prompt"), - duration: 30, - color: "black", - }); - return; - } - blockKey = findParentWithBlockId(gizmoManager.attachedMesh)?.metadata - ?.blockKey; - blockId = meshBlockIdMap[blockKey]; - deleteBlockWithUndo(blockId); + handleDeleteGizmo(); break; - case "duplicate": - if (!gizmoManager.attachedMesh) { - flock.printText({ - text: translate("select_mesh_duplicate_prompt"), - duration: 30, - color: "black", - }); - return; - } - blockKey = findParentWithBlockId(gizmoManager.attachedMesh)?.metadata - ?.blockKey; - blockId = meshBlockIdMap[blockKey]; + handleDuplicateGizmo(); + break; + case "select": + handleSelectGizmo(); + break; + case "position": + handlePositionGizmo(); + break; + case "rotation": + handleRotationGizmo(); + break; + case "scale": + handleScaleGizmo(); + break; + /* + case "boundingBox": + gizmoManager.boundingBoxGizmoEnabled = true; + break; + case "bounds": + handleBoundsGizmo(); + break; + */ + case "focus": + focusCameraOnMesh(); + break; + default: + break; + } +} - document.body.style.cursor = "crosshair"; // Change cursor to indicate picking mode +// Scale: Allow the user to scale the mesh by dragging it +function handleScaleGizmo() { + configureScaleGizmo(gizmoManager); + { + const sg = gizmoManager.gizmos.scaleGizmo; + if (!sg._textAxisObserversRegistered) { + sg.xGizmo.dragBehavior.onDragStartObservable.add( + () => (textScaleAxis = "x"), + ); + sg.yGizmo.dragBehavior.onDragStartObservable.add( + () => (textScaleAxis = "y"), + ); + sg.zGizmo.dragBehavior.onDragStartObservable.add( + () => (textScaleAxis = "z"), + ); + sg.uniformScaleGizmo.dragBehavior.onDragStartObservable.add( + () => (textScaleAxis = "uniform"), + ); + sg._textAxisObserversRegistered = true; + } + } + gizmoManager.onAttachedToMeshObservable.add((mesh) => { + if (!mesh) return; - canvas = flock.scene.getEngine().getRenderingCanvas(); // Get the flock.BABYLON.js canvas + const blockKey = mesh?.metadata?.blockKey; + const blockId = blockKey ? meshMap[blockKey] : null; + if (!blockId) return; - onPickMesh = function (event) { - const canvasRect = canvas.getBoundingClientRect(); + highlightBlockById(Blockly.getMainWorkspace(), blockId); + }); - if (eventIsOutOfCanvasBounds(event, canvasRect)) { - window.removeEventListener("click", onPickMesh); - document.body.style.cursor = "default"; - return; - } + // Track bottom for correct visual anchoring + let originalBottomY = 0; - const [canvasX, canvasY] = getCanvasXAndCanvasYValues( - event, - canvasRect, - ); + gizmoManager.gizmos.scaleGizmo.onDragObservable.add(() => { + const mesh = gizmoManager.attachedMesh; - const pickRay = flock.scene.createPickingRay( - canvasX, - canvasY, - flock.BABYLON.Matrix.Identity(), - flock.scene.activeCamera, - ); + mesh.computeWorldMatrix(true); + mesh.refreshBoundingInfo(); - const pickResult = flock.scene.pickWithRay( - pickRay, - (mesh) => mesh.isPickable, - ); + const newBottomY = mesh.getBoundingInfo().boundingBox.minimumWorld.y; + const deltaY = originalBottomY - newBottomY; + mesh.position.y += deltaY; - if (pickResult.hit) { - const pickedPosition = pickResult.pickedPoint; + const block = Blockly.getMainWorkspace().getBlockById( + mesh?.metadata?.blockKey, + ); + if (gizmoManager.scaleGizmoEnabled) { + switch (block?.type) { + case "create_capsule": + case "create_cylinder": + mesh.scaling.z = mesh.scaling.x; + break; + case "create_3d_text": + if (textScaleAxis === "z") { + // Z handle: depth only — lock X and Y + mesh.scaling.x = 1; + mesh.scaling.y = 1; + } else if (textScaleAxis === "x" || textScaleAxis === "uniform") { + // X or uniform: size only — keep Y = X, lock Z + mesh.scaling.y = mesh.scaling.x; + mesh.scaling.z = textOrigScaleZ; + } else if (textScaleAxis === "y") { + // Y handle: size only — keep X = Y, lock Z + mesh.scaling.x = mesh.scaling.y; + mesh.scaling.z = textOrigScaleZ; + } + break; + } + } + }); - const workspace = Blockly.getMainWorkspace(); - const originalBlock = workspace.getBlockById(blockId); - duplicateBlockAndInsert(originalBlock, workspace, pickedPosition); - } - }; + gizmoManager.gizmos.scaleGizmo.onDragStartObservable.add(() => { + const mesh = gizmoManager.attachedMesh; + flock.ensureUniqueGeometry(mesh); + mesh.computeWorldMatrix(true); + mesh.refreshBoundingInfo(); + originalBottomY = mesh.getBoundingInfo().boundingBox.minimumWorld.y; + textOrigScaleZ = mesh.scaling.z; + textScaleAxis = null; + + const motionType = mesh.physics?.getMotionType(); + mesh.savedMotionType = motionType; + + if ( + mesh.physics && + mesh.physics.getMotionType() !== flock.BABYLON.PhysicsMotionType.ANIMATED + ) { + mesh.physics.setMotionType(flock.BABYLON.PhysicsMotionType.ANIMATED); + mesh.physics.disablePreStep = false; + } - // Use setTimeout to defer listener setup - document.body.style.cursor = "crosshair"; - setTimeout(() => { - window.addEventListener("click", onPickMesh); - }, 50); + const block = meshMap[mesh?.metadata?.blockKey]; + highlightBlockById(Blockly.getMainWorkspace(), block); + }); - break; - case "select": { - gizmoManager.selectGizmoEnabled = true; - - // Store the pointer observable - const pointerObservable = flock.scene.onPointerObservable; - - // Add the observer - const pointerObserver = pointerObservable.add((event) => { - if (event.type === flock.BABYLON.PointerEventTypes.POINTERPICK) { - if (gizmoManager.attachedMesh) { - resetAttachedMesh(); - blockKey = findParentWithBlockId(gizmoManager.attachedMesh) - ?.metadata?.blockKey; - } - let pickedMesh = event.pickInfo.pickedMesh; + gizmoManager.gizmos.scaleGizmo.onDragEndObservable.add(() => { + const mesh = gizmoManager.attachedMesh; + const block = meshMap[mesh?.metadata?.blockKey]; + textScaleAxis = null; - if (pickedMesh && pickedMesh.name !== "ground") { - const position = pickedMesh.getAbsolutePosition(); + if (mesh.savedMotionType != null) { + mesh.physics.setMotionType(mesh.savedMotionType); + } - // Round the coordinates to 2 decimal places - const roundedPosition = roundVectorToFixed(position, 2); + flock.updatePhysics(mesh); - flock.printText({ - text: translate("position_readout").replace( - "{position}", - String(roundedPosition), - ), - duration: 30, - color: "black", - }); + try { + const ensureFreshBounds = (m) => { + m.computeWorldMatrix(true); + m.refreshBoundingInfo(); + return m.getBoundingInfo().boundingBox; + }; - if (flock.meshDebug) console.log(pickedMesh.parent); + const bbox = ensureFreshBounds(mesh); - if (pickedMesh.parent) { - pickedMesh = getRootMesh(pickedMesh.parent); - if (flock.meshDebug) console.log(pickedMesh.visibility); - pickedMesh.visibility = 0.001; - if (flock.meshDebug) console.log(pickedMesh.visibility); - } + const newBottomY = bbox.minimumWorld.y; + mesh.position.y += originalBottomY - newBottomY; - const block = meshMap[blockKey]; - highlightBlockById(Blockly.getMainWorkspace(), block); + const sizeLocal = bbox.extendSize.scale(2); + const w = sizeLocal.x * mesh.scaling.x; + const h = sizeLocal.y * mesh.scaling.y; + const d = sizeLocal.z * mesh.scaling.z; - // Attach the gizmo to the selected mesh - gizmoManager.attachToMesh(pickedMesh); + switch (block.type) { + case "create_plane": + setNumberInputs(block, { WIDTH: w, HEIGHT: h }); + break; - // Show bounding box for the selected mesh - pickedMesh.showBoundingBox = true; - } else { - if (pickedMesh && pickedMesh.name === "ground") { - const position = event.pickInfo.pickedPoint; - - const roundedPosition = roundVectorToFixed(position, 2); - - flock.printText({ - text: translate("position_readout").replace( - "{position}", - String(roundedPosition), - ), - duration: 30, - color: "black", - }); - } + case "create_box": + setNumberInputs(block, { WIDTH: w, HEIGHT: h, DEPTH: d }); + break; - // Deselect if no mesh is picked - if (gizmoManager.attachedMesh) { - resetChildMeshesOfAttachedMesh(); - gizmoManager.attachToMesh(null); // Detach the gizmo - } - } + case "create_capsule": + setNumberInputs(block, { HEIGHT: h, DIAMETER: w }); + break; - pointerObservable.remove(pointerObserver); - } - }); + case "create_cylinder": { + const newScaledDiameter = w; - break; - } - case "bounds": - gizmoManager.boundingBoxGizmoEnabled = true; - gizmoManager.boundingBoxDragBehavior.onDragStartObservable.add( - function () { - const mesh = gizmoManager.attachedMesh; + const currentTop = getNumberInput(block, "DIAMETER_TOP"); + const currentBottom = getNumberInput(block, "DIAMETER_BOTTOM"); - if (!mesh?.physics) return; - - const motionType = mesh.physics.getMotionType?.(); - mesh.savedMotionType = motionType; + let newTop; + let newBottom; if ( - mesh.physics && - motionType != null && - motionType !== flock.BABYLON.PhysicsMotionType.STATIC + Number.isFinite(currentTop) && + Number.isFinite(currentBottom) && + currentTop > 0 && + currentBottom > 0 ) { - mesh.physics.setMotionType(flock.BABYLON.PhysicsMotionType.STATIC); - mesh.physics.disablePreStep = false; + if (currentTop >= currentBottom) { + newTop = newScaledDiameter; + newBottom = newTop * (currentBottom / currentTop); + } else { + newBottom = newScaledDiameter; + newTop = newBottom * (currentTop / currentBottom); + } + } else { + newTop = newScaledDiameter; + newBottom = newScaledDiameter; } - const block = meshMap[mesh?.metadata?.blockKey]; - highlightBlockById(Blockly.getMainWorkspace(), block); - }, - ); - - gizmoManager.boundingBoxDragBehavior.onDragEndObservable.add(function () { - const mesh = gizmoManager.attachedMesh; + setNumberInputs(block, { + HEIGHT: h, + DIAMETER_TOP: newTop, + DIAMETER_BOTTOM: newBottom, + }); + break; + } - if (mesh.savedMotionType != null && mesh.physics) { - mesh.physics.setMotionType(mesh.savedMotionType); + case "create_sphere": + setNumberInputs(block, { + DIAMETER_X: w, + DIAMETER_Y: h, + DIAMETER_Z: d, + }); + break; + + case "create_3d_text": { + const currentSize = getNumberInput(block, "SIZE"); + const currentDepth = getNumberInput(block, "DEPTH"); + setNumberInputs(block, { + SIZE: currentSize * mesh.scaling.y, + DEPTH: currentDepth * mesh.scaling.z, + }); + break; } - mesh.computeWorldMatrix(true); + case "load_model": + case "load_multi_object": + case "load_object": + case "load_character": { + const groupId = Blockly.utils.idGenerator.genUid(); + Blockly.Events.setGroup(groupId); + + let addedDoSection = false; + if (!block.getInput("DO")) { + block.appendStatementInput("DO").setCheck(null).appendField(""); + addedDoSection = true; + } - const block = meshMap[mesh?.metadata?.blockKey]; + let resizeBlock = null; + const modelVariable = block.getFieldValue("ID_VAR"); - if (block) { - const blockPosition = flock.getBlockPositionFromMesh(mesh); - setBlockXYZ(block, blockPosition.x, blockPosition.y, blockPosition.z); - } - }); + const stmt = block.getInput("DO")?.connection?.targetBlock?.(); + for (let cur = stmt; cur; cur = cur.getNextBlock?.()) { + if ( + cur.type === "resize" && + cur.getFieldValue?.("BLOCK_NAME") === modelVariable + ) { + resizeBlock = cur; + break; + } + } - break; + if (!resizeBlock) { + resizeBlock = Blockly.getMainWorkspace().newBlock("resize"); + resizeBlock.setFieldValue(modelVariable, "BLOCK_NAME"); + resizeBlock.initSvg(); + resizeBlock.render(); + + ["X", "Y", "Z"].forEach((axis) => { + const input = resizeBlock.getInput(axis); + const shadow = Blockly.getMainWorkspace().newBlock("math_number"); + shadow.setFieldValue("1", "NUM"); + shadow.setShadow(true); + shadow.initSvg(); + shadow.render(); + input.connection.connect(shadow.outputConnection); + }); - case "position": - configurePositionGizmo(gizmoManager); - gizmoManager.onAttachedToMeshObservable.add((mesh) => { - if (!mesh) return; + resizeBlock.render(); + block + .getInput("DO") + .connection.connect(resizeBlock.previousConnection); - const blockKey = mesh?.metadata?.blockKey; - const blockId = blockKey ? meshMap[blockKey] : null; - if (!blockId) return; + gizmoCreatedBlocks.set(resizeBlock.id, { + parentId: block.id, + createdDoSection: addedDoSection, + timestamp: Date.now(), + }); + } + mesh.computeWorldMatrix(true); + mesh.refreshBoundingInfo(); + const sizeLocalScaled = getScaledSize(mesh); + + setNumberInputs(resizeBlock, { + X: sizeLocalScaled.x, + Y: sizeLocalScaled.y, + Z: sizeLocalScaled.z, + }); - highlightBlockById(Blockly.getMainWorkspace(), blockId); - }); + Blockly.Events.setGroup(null); + break; + } + } + } catch (e) { + console.error("Error updating block values:", e); + } + }); +} - gizmoManager.gizmos.positionGizmo.onDragStartObservable.add(() => { - const mesh = gizmoManager.attachedMesh; - if (!mesh) return; +// Rotation: Allow the user to rotate the mesh by dragging it +function handleRotationGizmo() { + configureRotationGizmo(gizmoManager); - const motionType = mesh.physics?.getMotionType?.(); - mesh.savedMotionType = motionType; + gizmoManager.onAttachedToMeshObservable.add((mesh) => { + if (!mesh) return; - if ( - mesh.physics && - motionType && - motionType !== flock.BABYLON.PhysicsMotionType.ANIMATED - ) { - mesh.physics.setMotionType(flock.BABYLON.PhysicsMotionType.ANIMATED); - mesh.physics.disablePreStep = false; - } - }); + const blockKey = mesh?.metadata?.blockKey; + const blockId = blockKey ? meshMap[blockKey] : null; + if (!blockId) return; - gizmoManager.gizmos.positionGizmo.onDragEndObservable.add(function () { - const mesh = gizmoManager.attachedMesh; + highlightBlockById(Blockly.getMainWorkspace(), blockId); + }); - if (mesh.savedMotionType != null && mesh.physics) { - mesh.physics.setMotionType(mesh.savedMotionType); - } - mesh.computeWorldMatrix(true); + gizmoManager.gizmos.rotationGizmo.onDragStartObservable.add(() => { + let mesh = gizmoManager.attachedMesh; + if (!mesh) return; - const block = meshMap[mesh?.metadata?.blockKey]; + if (!mesh.physics) return; - if (block) { - const blockPosition = flock.getBlockPositionFromMesh(mesh); - setBlockXYZ(block, blockPosition.x, blockPosition.y, blockPosition.z); - } - }); + const motionType = + mesh.physics?.getMotionType?.() ?? flock.BABYLON.PhysicsMotionType.STATIC; + mesh.savedMotionType = motionType; - break; - case "rotation": - configureRotationGizmo(gizmoManager); + if ( + mesh.physics && + mesh.physics.getMotionType?.() !== + flock.BABYLON.PhysicsMotionType.ANIMATED + ) { + mesh.physics.setMotionType(flock.BABYLON.PhysicsMotionType.ANIMATED); + mesh.physics.disablePreStep = false; + } + }); - gizmoManager.onAttachedToMeshObservable.add((mesh) => { - if (!mesh) return; + gizmoManager.gizmos.rotationGizmo.onDragEndObservable.add(function () { + let mesh = gizmoManager.attachedMesh; + while (mesh?.parent && !mesh.parent.physics) { + mesh = mesh.parent; + } - const blockKey = mesh?.metadata?.blockKey; - const blockId = blockKey ? meshMap[blockKey] : null; - if (!blockId) return; + if (!mesh?.physics) return; - highlightBlockById(Blockly.getMainWorkspace(), blockId); - }); + if (mesh.savedMotionType != null) { + mesh.physics.setMotionType(mesh.savedMotionType); + } - gizmoManager.gizmos.rotationGizmo.onDragStartObservable.add(() => { - let mesh = gizmoManager.attachedMesh; - if (!mesh) return; + const block = meshMap[mesh?.metadata?.blockKey]; - if (!mesh.physics) return; + if (!block) return; - const motionType = - mesh.physics?.getMotionType?.() ?? - flock.BABYLON.PhysicsMotionType.STATIC; - mesh.savedMotionType = motionType; + const groupId = Blockly.utils.idGenerator.genUid(); + Blockly.Events.setGroup(groupId); - if ( - mesh.physics && - mesh.physics.getMotionType?.() !== - flock.BABYLON.PhysicsMotionType.ANIMATED - ) { - mesh.physics.setMotionType(flock.BABYLON.PhysicsMotionType.ANIMATED); - mesh.physics.disablePreStep = false; + let addedDoSection = false; + if (!block.getInput("DO")) { + block.appendStatementInput("DO").setCheck(null).appendField(""); + addedDoSection = true; + } + + // Check if the 'rotate_to' block already exists in the 'DO' section + let rotateBlock = null; + let modelVariable = block.getFieldValue("ID_VAR"); + const statementConnection = block.getInput("DO").connection; + if (statementConnection && statementConnection.targetBlock()) { + // Iterate through the blocks in the 'do' section to find 'rotate_to' + let currentBlock = statementConnection.targetBlock(); + while (currentBlock) { + if (currentBlock.type === "rotate_to") { + const modelField = currentBlock.getFieldValue("MODEL"); + if (modelField === modelVariable) { + rotateBlock = currentBlock; + break; + } } + currentBlock = currentBlock.getNextBlock(); + } + } + + // Create a new 'rotate_to' block if it doesn't exist + if (!rotateBlock) { + rotateBlock = Blockly.getMainWorkspace().newBlock("rotate_to"); + rotateBlock.setFieldValue(modelVariable, "MODEL"); + rotateBlock.initSvg(); + rotateBlock.render(); + + // Add shadow blocks for X, Y, Z inputs + ["X", "Y", "Z"].forEach((axis) => { + const input = rotateBlock.getInput(axis); + const shadowBlock = Blockly.getMainWorkspace().newBlock("math_number"); + shadowBlock.setFieldValue("1", "NUM"); + shadowBlock.setShadow(true); + shadowBlock.initSvg(); + shadowBlock.render(); + input.connection.connect(shadowBlock.outputConnection); }); - gizmoManager.gizmos.rotationGizmo.onDragEndObservable.add(function () { - let mesh = gizmoManager.attachedMesh; - while (mesh?.parent && !mesh.parent.physics) { - mesh = mesh.parent; - } + rotateBlock.render(); // Render the new block + // Connect the new 'rotate_to' block to the 'do' section + block.getInput("DO").connection.connect(rotateBlock.previousConnection); - if (!mesh?.physics) return; + // Track this block for DO section cleanup + const timestamp = Date.now(); + gizmoCreatedBlocks.set(rotateBlock.id, { + parentId: block.id, + createdDoSection: addedDoSection, + timestamp: timestamp, + }); + } - if (mesh.savedMotionType != null) { - mesh.physics.setMotionType(mesh.savedMotionType); - } + const currentRotation = getMeshRotationInDegrees(mesh); - const block = meshMap[mesh?.metadata?.blockKey]; + setBlockXYZ( + rotateBlock, + currentRotation.x, + currentRotation.y, + currentRotation.z, + ); - if (!block) return; + // End undo group + Blockly.Events.setGroup(null); + }); +} - const groupId = Blockly.utils.idGenerator.genUid(); - Blockly.Events.setGroup(groupId); +// Position: Allow the user to move the mesh by dragging it +function handlePositionGizmo() { + configurePositionGizmo(gizmoManager); + gizmoManager.onAttachedToMeshObservable.add((mesh) => { + if (!mesh) return; - let addedDoSection = false; - if (!block.getInput("DO")) { - block.appendStatementInput("DO").setCheck(null).appendField(""); - addedDoSection = true; - } + const blockKey = mesh?.metadata?.blockKey; + const blockId = blockKey ? meshMap[blockKey] : null; + if (!blockId) return; - // Check if the 'rotate_to' block already exists in the 'DO' section - let rotateBlock = null; - let modelVariable = block.getFieldValue("ID_VAR"); - const statementConnection = block.getInput("DO").connection; - if (statementConnection && statementConnection.targetBlock()) { - // Iterate through the blocks in the 'do' section to find 'rotate_to' - let currentBlock = statementConnection.targetBlock(); - while (currentBlock) { - if (currentBlock.type === "rotate_to") { - const modelField = currentBlock.getFieldValue("MODEL"); - if (modelField === modelVariable) { - rotateBlock = currentBlock; - break; - } - } - currentBlock = currentBlock.getNextBlock(); - } - } + highlightBlockById(Blockly.getMainWorkspace(), blockId); + }); - // Create a new 'rotate_to' block if it doesn't exist - if (!rotateBlock) { - rotateBlock = Blockly.getMainWorkspace().newBlock("rotate_to"); - rotateBlock.setFieldValue(modelVariable, "MODEL"); - rotateBlock.initSvg(); - rotateBlock.render(); - - // Add shadow blocks for X, Y, Z inputs - ["X", "Y", "Z"].forEach((axis) => { - const input = rotateBlock.getInput(axis); - const shadowBlock = - Blockly.getMainWorkspace().newBlock("math_number"); - shadowBlock.setFieldValue("1", "NUM"); - shadowBlock.setShadow(true); - shadowBlock.initSvg(); - shadowBlock.render(); - input.connection.connect(shadowBlock.outputConnection); - }); + gizmoManager.gizmos.positionGizmo.onDragStartObservable.add(() => { + const mesh = gizmoManager.attachedMesh; + if (!mesh) return; - rotateBlock.render(); // Render the new block - // Connect the new 'rotate_to' block to the 'do' section - block - .getInput("DO") - .connection.connect(rotateBlock.previousConnection); - - // Track this block for DO section cleanup - const timestamp = Date.now(); - gizmoCreatedBlocks.set(rotateBlock.id, { - parentId: block.id, - createdDoSection: addedDoSection, - timestamp: timestamp, - }); - } + const motionType = mesh.physics?.getMotionType?.(); + mesh.savedMotionType = motionType; - const currentRotation = getMeshRotationInDegrees(mesh); + if ( + mesh.physics && + motionType && + motionType !== flock.BABYLON.PhysicsMotionType.ANIMATED + ) { + mesh.physics.setMotionType(flock.BABYLON.PhysicsMotionType.ANIMATED); + mesh.physics.disablePreStep = false; + } + }); - setBlockXYZ( - rotateBlock, - currentRotation.x, - currentRotation.y, - currentRotation.z, - ); + gizmoManager.gizmos.positionGizmo.onDragEndObservable.add(function () { + const mesh = gizmoManager.attachedMesh; - // End undo group - Blockly.Events.setGroup(null); - }); + if (mesh.savedMotionType != null && mesh.physics) { + mesh.physics.setMotionType(mesh.savedMotionType); + } + mesh.computeWorldMatrix(true); - break; + const block = meshMap[mesh?.metadata?.blockKey]; - case "scale": { - configureScaleGizmo(gizmoManager); - { - const sg = gizmoManager.gizmos.scaleGizmo; - if (!sg._textAxisObserversRegistered) { - sg.xGizmo.dragBehavior.onDragStartObservable.add( - () => (textScaleAxis = "x"), - ); - sg.yGizmo.dragBehavior.onDragStartObservable.add( - () => (textScaleAxis = "y"), - ); - sg.zGizmo.dragBehavior.onDragStartObservable.add( - () => (textScaleAxis = "z"), - ); - sg.uniformScaleGizmo.dragBehavior.onDragStartObservable.add( - () => (textScaleAxis = "uniform"), - ); - sg._textAxisObserversRegistered = true; - } - } - gizmoManager.onAttachedToMeshObservable.add((mesh) => { - if (!mesh) return; + if (block) { + const blockPosition = flock.getBlockPositionFromMesh(mesh); + setBlockXYZ(block, blockPosition.x, blockPosition.y, blockPosition.z); + } + }); +} - const blockKey = mesh?.metadata?.blockKey; - const blockId = blockKey ? meshMap[blockKey] : null; - if (!blockId) return; +// Bounds: Allow the user to move the mesh +// Legacy? +function handleBoundsGizmo() { + gizmoManager.boundingBoxGizmoEnabled = true; + gizmoManager.boundingBoxDragBehavior.onDragStartObservable.add(function () { + const mesh = gizmoManager.attachedMesh; - highlightBlockById(Blockly.getMainWorkspace(), blockId); - }); + if (!mesh?.physics) return; - // Track bottom for correct visual anchoring - let originalBottomY = 0; + const motionType = mesh.physics.getMotionType?.(); + mesh.savedMotionType = motionType; - gizmoManager.gizmos.scaleGizmo.onDragObservable.add(() => { - const mesh = gizmoManager.attachedMesh; + if ( + mesh.physics && + motionType != null && + motionType !== flock.BABYLON.PhysicsMotionType.STATIC + ) { + mesh.physics.setMotionType(flock.BABYLON.PhysicsMotionType.STATIC); + mesh.physics.disablePreStep = false; + } - mesh.computeWorldMatrix(true); - mesh.refreshBoundingInfo(); + const block = meshMap[mesh?.metadata?.blockKey]; + highlightBlockById(Blockly.getMainWorkspace(), block); + }); - const newBottomY = mesh.getBoundingInfo().boundingBox.minimumWorld.y; - const deltaY = originalBottomY - newBottomY; - mesh.position.y += deltaY; + gizmoManager.boundingBoxDragBehavior.onDragEndObservable.add(function () { + const mesh = gizmoManager.attachedMesh; - const block = Blockly.getMainWorkspace().getBlockById( - mesh?.metadata?.blockKey, - ); - if (gizmoManager.scaleGizmoEnabled) { - switch (block?.type) { - case "create_capsule": - case "create_cylinder": - mesh.scaling.z = mesh.scaling.x; - break; - case "create_3d_text": - if (textScaleAxis === "z") { - // Z handle: depth only — lock X and Y - mesh.scaling.x = 1; - mesh.scaling.y = 1; - } else if (textScaleAxis === "x" || textScaleAxis === "uniform") { - // X or uniform: size only — keep Y = X, lock Z - mesh.scaling.y = mesh.scaling.x; - mesh.scaling.z = textOrigScaleZ; - } else if (textScaleAxis === "y") { - // Y handle: size only — keep X = Y, lock Z - mesh.scaling.x = mesh.scaling.y; - mesh.scaling.z = textOrigScaleZ; - } - break; - } - } - }); + if (mesh.savedMotionType != null && mesh.physics) { + mesh.physics.setMotionType(mesh.savedMotionType); + } - gizmoManager.gizmos.scaleGizmo.onDragStartObservable.add(() => { - const mesh = gizmoManager.attachedMesh; - flock.ensureUniqueGeometry(mesh); - mesh.computeWorldMatrix(true); - mesh.refreshBoundingInfo(); - originalBottomY = mesh.getBoundingInfo().boundingBox.minimumWorld.y; - textOrigScaleZ = mesh.scaling.z; - textScaleAxis = null; + mesh.computeWorldMatrix(true); - const motionType = mesh.physics?.getMotionType(); - mesh.savedMotionType = motionType; + const block = meshMap[mesh?.metadata?.blockKey]; - if ( - mesh.physics && - mesh.physics.getMotionType() !== - flock.BABYLON.PhysicsMotionType.ANIMATED - ) { - mesh.physics.setMotionType(flock.BABYLON.PhysicsMotionType.ANIMATED); - mesh.physics.disablePreStep = false; - } + if (block) { + const blockPosition = flock.getBlockPositionFromMesh(mesh); + setBlockXYZ(block, blockPosition.x, blockPosition.y, blockPosition.z); + } + }); +} - const block = meshMap[mesh?.metadata?.blockKey]; - highlightBlockById(Blockly.getMainWorkspace(), block); - }); +// Select: Allow the user to select a mesh by clicking on it +function handleSelectGizmo() { + let blockKey; + gizmoManager.selectGizmoEnabled = true; + + // Store the pointer observable + const pointerObservable = flock.scene.onPointerObservable; + + // Add the observer + const pointerObserver = pointerObservable.add((event) => { + if (event.type === flock.BABYLON.PointerEventTypes.POINTERPICK) { + if (gizmoManager.attachedMesh) { + resetAttachedMesh(); + blockKey = findParentWithBlockId(gizmoManager.attachedMesh)?.metadata + ?.blockKey; + } + let pickedMesh = event.pickInfo.pickedMesh; - gizmoManager.gizmos.scaleGizmo.onDragEndObservable.add(() => { - const mesh = gizmoManager.attachedMesh; - const block = meshMap[mesh?.metadata?.blockKey]; - textScaleAxis = null; + if (pickedMesh && pickedMesh.name !== "ground") { + const position = pickedMesh.getAbsolutePosition(); - if (mesh.savedMotionType != null) { - mesh.physics.setMotionType(mesh.savedMotionType); - } + // Round the coordinates to 2 decimal places + const roundedPosition = roundVectorToFixed(position, 2); - flock.updatePhysics(mesh); + flock.printText({ + text: translate("position_readout").replace( + "{position}", + String(roundedPosition), + ), + duration: 30, + color: "black", + }); - try { - const ensureFreshBounds = (m) => { - m.computeWorldMatrix(true); - m.refreshBoundingInfo(); - return m.getBoundingInfo().boundingBox; - }; + if (flock.meshDebug) console.log(pickedMesh.parent); - const bbox = ensureFreshBounds(mesh); + if (pickedMesh.parent) { + pickedMesh = getRootMesh(pickedMesh.parent); + if (flock.meshDebug) console.log(pickedMesh.visibility); + pickedMesh.visibility = 0.001; + if (flock.meshDebug) console.log(pickedMesh.visibility); + } - const newBottomY = bbox.minimumWorld.y; - mesh.position.y += originalBottomY - newBottomY; + const block = meshMap[blockKey]; + highlightBlockById(Blockly.getMainWorkspace(), block); - const sizeLocal = bbox.extendSize.scale(2); - const w = sizeLocal.x * mesh.scaling.x; - const h = sizeLocal.y * mesh.scaling.y; - const d = sizeLocal.z * mesh.scaling.z; + // Attach the gizmo to the selected mesh + gizmoManager.attachToMesh(pickedMesh); - switch (block.type) { - case "create_plane": - setNumberInputs(block, { WIDTH: w, HEIGHT: h }); - break; + // Show bounding box for the selected mesh + pickedMesh.showBoundingBox = true; + } else { + if (pickedMesh && pickedMesh.name === "ground") { + const position = event.pickInfo.pickedPoint; + + const roundedPosition = roundVectorToFixed(position, 2); + + flock.printText({ + text: translate("position_readout").replace( + "{position}", + String(roundedPosition), + ), + duration: 30, + color: "black", + }); + } - case "create_box": - setNumberInputs(block, { WIDTH: w, HEIGHT: h, DEPTH: d }); - break; + // Deselect if no mesh is picked + if (gizmoManager.attachedMesh) { + resetChildMeshesOfAttachedMesh(); + gizmoManager.attachToMesh(null); // Detach the gizmo + } + } - case "create_capsule": - setNumberInputs(block, { HEIGHT: h, DIAMETER: w }); - break; + pointerObservable.remove(pointerObserver); + } + }); +} - case "create_cylinder": { - const newScaledDiameter = w; - - const currentTop = getNumberInput(block, "DIAMETER_TOP"); - const currentBottom = getNumberInput(block, "DIAMETER_BOTTOM"); - - let newTop; - let newBottom; - - if ( - Number.isFinite(currentTop) && - Number.isFinite(currentBottom) && - currentTop > 0 && - currentBottom > 0 - ) { - if (currentTop >= currentBottom) { - newTop = newScaledDiameter; - newBottom = newTop * (currentBottom / currentTop); - } else { - newBottom = newScaledDiameter; - newTop = newBottom * (currentTop / currentBottom); - } - } else { - newTop = newScaledDiameter; - newBottom = newScaledDiameter; - } +// Duplicate: Create a copy of the selected mesh and its corresponding block, +// and allow the user to place it by clicking on the canvas +function handleDuplicateGizmo() { + let blockKey, blockId, canvas, onPickMesh; + if (!gizmoManager.attachedMesh) { + flock.printText({ + text: translate("select_mesh_duplicate_prompt"), + duration: 30, + color: "black", + }); + return; + } + blockKey = findParentWithBlockId(gizmoManager.attachedMesh)?.metadata + ?.blockKey; + blockId = meshBlockIdMap[blockKey]; - setNumberInputs(block, { - HEIGHT: h, - DIAMETER_TOP: newTop, - DIAMETER_BOTTOM: newBottom, - }); - break; - } + document.body.style.cursor = "crosshair"; // Change cursor to indicate picking mode - case "create_sphere": - setNumberInputs(block, { - DIAMETER_X: w, - DIAMETER_Y: h, - DIAMETER_Z: d, - }); - break; + canvas = flock.scene.getEngine().getRenderingCanvas(); // Get the flock.BABYLON.js canvas - case "create_3d_text": { - const currentSize = getNumberInput(block, "SIZE"); - const currentDepth = getNumberInput(block, "DEPTH"); - setNumberInputs(block, { - SIZE: currentSize * mesh.scaling.y, - DEPTH: currentDepth * mesh.scaling.z, - }); - break; - } + onPickMesh = function (event) { + const canvasRect = canvas.getBoundingClientRect(); - case "load_model": - case "load_multi_object": - case "load_object": - case "load_character": { - const groupId = Blockly.utils.idGenerator.genUid(); - Blockly.Events.setGroup(groupId); - - let addedDoSection = false; - if (!block.getInput("DO")) { - block.appendStatementInput("DO").setCheck(null).appendField(""); - addedDoSection = true; - } + if (eventIsOutOfCanvasBounds(event, canvasRect)) { + window.removeEventListener("click", onPickMesh); + document.body.style.cursor = "default"; + return; + } - let resizeBlock = null; - const modelVariable = block.getFieldValue("ID_VAR"); + const [canvasX, canvasY] = getCanvasXAndCanvasYValues(event, canvasRect); - const stmt = block.getInput("DO")?.connection?.targetBlock?.(); - for (let cur = stmt; cur; cur = cur.getNextBlock?.()) { - if ( - cur.type === "resize" && - cur.getFieldValue?.("BLOCK_NAME") === modelVariable - ) { - resizeBlock = cur; - break; - } - } + const pickRay = flock.scene.createPickingRay( + canvasX, + canvasY, + flock.BABYLON.Matrix.Identity(), + flock.scene.activeCamera, + ); - if (!resizeBlock) { - resizeBlock = Blockly.getMainWorkspace().newBlock("resize"); - resizeBlock.setFieldValue(modelVariable, "BLOCK_NAME"); - resizeBlock.initSvg(); - resizeBlock.render(); - - ["X", "Y", "Z"].forEach((axis) => { - const input = resizeBlock.getInput(axis); - const shadow = - Blockly.getMainWorkspace().newBlock("math_number"); - shadow.setFieldValue("1", "NUM"); - shadow.setShadow(true); - shadow.initSvg(); - shadow.render(); - input.connection.connect(shadow.outputConnection); - }); - - resizeBlock.render(); - block - .getInput("DO") - .connection.connect(resizeBlock.previousConnection); - - gizmoCreatedBlocks.set(resizeBlock.id, { - parentId: block.id, - createdDoSection: addedDoSection, - timestamp: Date.now(), - }); - } + const pickResult = flock.scene.pickWithRay( + pickRay, + (mesh) => mesh.isPickable, + ); - function getScaledSize(mesh) { - const { originalMin, originalMax } = mesh.metadata || {}; - const min = - originalMin ?? mesh.getBoundingInfo().boundingBox.minimum; - const max = - originalMax ?? mesh.getBoundingInfo().boundingBox.maximum; - - const baseX = max.x - min.x; - const baseY = max.y - min.y; - const baseZ = max.z - min.z; - - return { - x: baseX * Math.abs(mesh.scaling.x), - y: baseY * Math.abs(mesh.scaling.y), - z: baseZ * Math.abs(mesh.scaling.z), - }; - } + if (pickResult.hit) { + const pickedPosition = pickResult.pickedPoint; - mesh.computeWorldMatrix(true); - mesh.refreshBoundingInfo(); - const sizeLocalScaled = getScaledSize(mesh); + const workspace = Blockly.getMainWorkspace(); + const originalBlock = workspace.getBlockById(blockId); + duplicateBlockAndInsert(originalBlock, workspace, pickedPosition); + } + }; - setNumberInputs(resizeBlock, { - X: sizeLocalScaled.x, - Y: sizeLocalScaled.y, - Z: sizeLocalScaled.z, - }); + // Use setTimeout to defer listener setup + document.body.style.cursor = "crosshair"; + setTimeout(() => { + window.addEventListener("click", onPickMesh); + }, 50); +} - Blockly.Events.setGroup(null); - break; - } - } - } catch (e) { - console.error("Error updating block values:", e); - } - }); +// Delete: Remove the selected mesh and its corresponding block +function handleDeleteGizmo() { + let blockKey, blockId; + if (!gizmoManager.attachedMesh) { + flock.printText({ + text: translate("select_mesh_delete_prompt"), + duration: 30, + color: "black", + }); + return; + } + blockKey = findParentWithBlockId(gizmoManager.attachedMesh)?.metadata + ?.blockKey; + blockId = meshBlockIdMap[blockKey]; + deleteBlockWithUndo(blockId); +} - break; - } - case "boundingBox": - gizmoManager.boundingBoxGizmoEnabled = true; +// Camera: Toggle between play and fly camera modes +function handleCameraGizmo() { + const cameraButton = document.getElementById("cameraButton"); - break; - case "focus": - focusCameraOnMesh(); - break; - default: - break; + if (cameraMode === "play") { + cameraMode = "fly"; + flock.printText({ + text: translate("fly_camera_instructions"), + duration: 15, + color: "white", + }); + cameraButton.classList.add("active"); + } else { + cameraMode = "play"; + cameraButton.classList.remove("active"); } + + const currentCamera = flock.scene.activeCamera; + console.log("Camera", flock.savedCamera); + flock.scene.activeCamera = flock.savedCamera; + flock.savedCamera = currentCamera; + // Focus the canvas so you can use the camera controls + const canvas = flock.scene.getEngine().getRenderingCanvas(); + canvas.focus(); } function turnOffAllGizmos() { @@ -1191,9 +1218,6 @@ function turnOffAllGizmos() { disableGizmos(); } -// Track DO sections and their associated blocks for cleanup -const gizmoCreatedBlocks = new Map(); // blockId -> { parentId, createdDoSection, timestamp } - // Add undo handler to clean up DO sections when undoing block creation function addUndoHandler() { const workspace = Blockly.getMainWorkspace();