From 8597b781484d57aceb64ea0a6ea9ac880f746336 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:44:27 +0100 Subject: [PATCH 1/5] Bug: update blockly block on move --- ui/gizmos.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/ui/gizmos.js b/ui/gizmos.js index 692dce21..c16721e4 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -958,6 +958,10 @@ function handleRotationGizmo() { function handlePositionGizmo() { configurePositionGizmo(gizmoManager); + // Highlight the move button + const positionButton = document.getElementById("positionButton"); + positionButton.classList.add("active"); + const mesh = gizmoManager.attachedMesh; if (mesh) { const original = mesh.position.clone(); @@ -967,18 +971,29 @@ function handlePositionGizmo() { mesh.position.x += dx; mesh.position.y += dy; mesh.position.z += dz; - }, - onConfirm: () => { + // Update the blockly block mesh.computeWorldMatrix(true); const block = meshMap[mesh?.metadata?.blockKey]; if (block) { const pos = flock.getBlockPositionFromMesh(mesh); setBlockXYZ(block, pos.x, pos.y, pos.z); } + }, + onConfirm: () => { + positionButton.classList.remove("active"); disableGizmos(); }, onCancel: () => { + positionButton.classList.remove("active"); + // Replace mesh mesh.position.copyFrom(original); + // Replace blockly block + mesh.computeWorldMatrix(true); + const block = meshMap[mesh?.metadata?.blockKey]; + if (block) { + const pos = flock.getBlockPositionFromMesh(mesh); + setBlockXYZ(block, pos.x, pos.y, pos.z); + } disableGizmos(); }, stepNormal: 0.1, From c41686557cffd229d6af8c7657d069fdd6864a68 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:56 +0100 Subject: [PATCH 2/5] Bug: moving multiple meshes --- ui/gizmos.js | 113 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 43 deletions(-) diff --git a/ui/gizmos.js b/ui/gizmos.js index c16721e4..143c6ae1 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -34,6 +34,9 @@ const blueColor = flock.BABYLON.Color3.FromHexString("#0072B2"); // Colour for X const greenColor = flock.BABYLON.Color3.FromHexString("#009E73"); // Colour for Y-axis const orangeColor = flock.BABYLON.Color3.FromHexString("#D55E00"); // Colour for Z-axis +const FAST_CURSOR = 1; // Step for moving KB cursor quickly +const DEFAULT_CURSOR = 0.1; // Step for moving KB cursor slowly (default) + window.selectedColor = "#ffffff"; // Default color let colorPicker = null; @@ -44,6 +47,7 @@ let textOrigScaleZ = 1; let cameraMode = "play"; let activeDuplicatePickHandler = null; // Are they in the middle of a duplication? let stopAxisKeyboard = null; // Are they transforming? +let positionGizmoObserver = null; // Are they in [move mesh] state? // Track DO sections and their associated blocks for cleanup const gizmoCreatedBlocks = new Map(); // blockId -> { parentId, createdDoSection, timestamp } @@ -495,6 +499,52 @@ function getScaledSize(mesh) { }; } +// Clean up gizmo state if aborted +function exitGizmoState() { + // Stop the axis keyboard + stopAxisKeyboard?.(); + stopAxisKeyboard = null; + // Remove position observer + if (positionGizmoObserver) { + gizmoManager.onAttachedToMeshObservable.remove(positionGizmoObserver); + positionGizmoObserver = null; + } + // Remove active class from all buttons + document + .querySelectorAll(".gizmo-button") + .forEach((btn) => btn.classList.remove("active")); + disableGizmos(); + document.body.style.cursor = "default"; +} + +// Start the keyboard handler for moving a mesh +function startMoveKeyboardHandler(mesh) { + stopAxisKeyboard?.(); + stopAxisKeyboard = null; + setTimeout(() => { + stopAxisKeyboard = createAxisKeyboardHandler({ + onMove: (dx, dy, dz) => { + mesh.position.x += dx; + mesh.position.y += dy; + mesh.position.z += dz; + mesh.computeWorldMatrix(true); + const block = meshMap[mesh?.metadata?.blockKey]; + if (block) { + const pos = flock.getBlockPositionFromMesh(mesh); + setBlockXYZ(block, pos.x, pos.y, pos.z); + } + }, + onConfirm: () => { + exitGizmoState(); + document.getElementById("positionButton")?.focus(); + }, + onCancel: () => exitGizmoState(), + stepNormal: DEFAULT_CURSOR, + stepFast: FAST_CURSOR, + }); + }, 0); +} + export function disableGizmos() { if (!gizmoManager) return; // Disable all gizmos @@ -964,53 +1014,30 @@ function handlePositionGizmo() { const mesh = gizmoManager.attachedMesh; if (mesh) { - const original = mesh.position.clone(); - setTimeout(() => { - stopAxisKeyboard = createAxisKeyboardHandler({ - onMove: (dx, dy, dz) => { - mesh.position.x += dx; - mesh.position.y += dy; - mesh.position.z += dz; - // Update the blockly block - mesh.computeWorldMatrix(true); - const block = meshMap[mesh?.metadata?.blockKey]; - if (block) { - const pos = flock.getBlockPositionFromMesh(mesh); - setBlockXYZ(block, pos.x, pos.y, pos.z); - } - }, - onConfirm: () => { - positionButton.classList.remove("active"); - disableGizmos(); - }, - onCancel: () => { - positionButton.classList.remove("active"); - // Replace mesh - mesh.position.copyFrom(original); - // Replace blockly block - mesh.computeWorldMatrix(true); - const block = meshMap[mesh?.metadata?.blockKey]; - if (block) { - const pos = flock.getBlockPositionFromMesh(mesh); - setBlockXYZ(block, pos.x, pos.y, pos.z); - } - disableGizmos(); - }, - stepNormal: 0.1, - stepFast: 1, - }); - }, 0); + startMoveKeyboardHandler(mesh); } - gizmoManager.onAttachedToMeshObservable.add((mesh) => { - if (!mesh) return; + // Don't attach to multiple meshes + if (positionGizmoObserver) { + gizmoManager.onAttachedToMeshObservable.remove(positionGizmoObserver); + } - const blockKey = mesh?.metadata?.blockKey; - const blockId = blockKey ? meshMap[blockKey] : null; - if (!blockId) return; + positionGizmoObserver = gizmoManager.onAttachedToMeshObservable.add( + (mesh) => { + if (!mesh) { + exitGizmoState(); + return; + } - highlightBlockById(Blockly.getMainWorkspace(), blockId); - }); + startMoveKeyboardHandler(mesh); // Reattach + + const blockKey = mesh?.metadata?.blockKey; + const blockId = blockKey ? meshMap[blockKey] : null; + if (!blockId) return; + + highlightBlockById(Blockly.getMainWorkspace(), blockId); + }, + ); gizmoManager.gizmos.positionGizmo.onDragStartObservable.add(() => { const mesh = gizmoManager.attachedMesh; From fdd362620862a6fc3053d41624fd8b5b9414bd8f Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:14:36 +0100 Subject: [PATCH 3/5] Move gizmo works --- ui/gizmos.js | 89 +++++++++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/ui/gizmos.js b/ui/gizmos.js index 143c6ae1..df2dd95e 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -519,6 +519,7 @@ function exitGizmoState() { // Start the keyboard handler for moving a mesh function startMoveKeyboardHandler(mesh) { + document.body.style.cursor = "default"; stopAxisKeyboard?.(); stopAxisKeyboard = null; setTimeout(() => { @@ -538,13 +539,45 @@ function startMoveKeyboardHandler(mesh) { exitGizmoState(); document.getElementById("positionButton")?.focus(); }, - onCancel: () => exitGizmoState(), + onCancel: () => { + exitGizmoState(); + // Deselect so you get [select mesh] for next tool + gizmoManager.attachToMesh(null); + document.getElementById("positionButton")?.focus(); + }, stepNormal: DEFAULT_CURSOR, stepFast: FAST_CURSOR, }); }, 0); } +// Pick a mesh (used by multiple gizmos) +function pickMeshFromScene(onPicked) { + resetAttachedMesh(); + setTimeout(() => { + startCanvasKeyboardMode( + (x, y) => { + const pick = flock.scene.pick(x, y); + onPicked(pick?.pickedMesh, pick?.pickedPoint); + stopCanvasKeyboardMode(); + }, + false, + (x, y) => + !!flock.scene.pick(x, y, (m) => m.isPickable && m.name !== "ground") + ?.hit, + ); + document.body.style.cursor = "crosshair"; + }, 0); + + const pointerObservable = flock.scene.onPointerObservable; + const pointerObserver = pointerObservable.add((event) => { + if (event.type === flock.BABYLON.PointerEventTypes.POINTERPICK) { + onPicked(event.pickInfo.pickedMesh, event.pickInfo.pickedPoint); + pointerObservable.remove(pointerObserver); + } + }); +} + export function disableGizmos() { if (!gizmoManager) return; // Disable all gizmos @@ -557,6 +590,13 @@ export function disableGizmos() { // Toggle which Gizmo is being used export function toggleGizmo(gizmoType) { + // Is this gizmo already active? If so, toggle it off + const button = document.getElementById(`${gizmoType}Button`); + if (button?.classList.contains("active")) { + exitGizmoState(); + return; + } + // No buttons should be highlighted document .querySelectorAll(".gizmo-button") @@ -1015,6 +1055,12 @@ function handlePositionGizmo() { const mesh = gizmoManager.attachedMesh; if (mesh) { startMoveKeyboardHandler(mesh); + } else { + pickMeshFromScene((pickedMesh) => { + if (!pickedMesh || pickedMesh.name === "ground") return; + if (pickedMesh.parent) pickedMesh = getRootMesh(pickedMesh.parent); + gizmoManager.attachToMesh(pickedMesh); + }); } // Don't attach to multiple meshes @@ -1118,7 +1164,6 @@ function handleBoundsGizmo() { // Select: Allow the user to select a mesh by clicking on it function handleSelectGizmo() { - let blockKey; gizmoManager.selectGizmoEnabled = true; function applySelection(pickedMesh, pickedPoint) { @@ -1163,44 +1208,8 @@ function handleSelectGizmo() { } } - // Wait until the click has propagated otherwise - // the keyboard mode gets cancelled immediately - setTimeout(() => { - startCanvasKeyboardMode( - (x, y) => { - if (gizmoManager.attachedMesh) { - resetAttachedMesh(); - blockKey = findParentWithBlockId(gizmoManager.attachedMesh)?.metadata - ?.blockKey; - } - const pick = flock.scene.pick(x, y); - applySelection(pick?.pickedMesh, pick?.pickedPoint); - stopCanvasKeyboardMode(); - }, - false, - (x, y) => - !!flock.scene.pick(x, y, (m) => m.isPickable && m.name !== "ground") - ?.hit, - ); - }, 0); - - // 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; - } - - applySelection(event.pickInfo.pickedMesh, event.pickInfo.pickedPoint); - - pointerObservable.remove(pointerObserver); - } - }); + // Use helper function to pick the mesh + pickMeshFromScene(applySelection); } // Duplicate: Create a copy of the selected mesh and its corresponding block, From d2af581dec676ff05021e0df165645352ed30321 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:51:29 +0100 Subject: [PATCH 4/5] Fix leak with observers --- ui/gizmos.js | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/ui/gizmos.js b/ui/gizmos.js index df2dd95e..7bd6674a 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -28,8 +28,12 @@ import { stopCanvasKeyboardMode, } from "./canvas-utils.js"; import { createAxisKeyboardHandler } from "./axis-keyboard.js"; +import { cleanup } from "manifold-3d/lib/animation.js"; export let gizmoManager; +// Enable debug messages +const DEBUG = true; + const blueColor = flock.BABYLON.Color3.FromHexString("#0072B2"); // Colour for X-axis const greenColor = flock.BABYLON.Color3.FromHexString("#009E73"); // Colour for Y-axis const orangeColor = flock.BABYLON.Color3.FromHexString("#D55E00"); // Colour for Z-axis @@ -44,10 +48,12 @@ let colorPicker = null; let textScaleAxis = null; let textOrigScaleZ = 1; +// Track state let cameraMode = "play"; -let activeDuplicatePickHandler = null; // Are they in the middle of a duplication? -let stopAxisKeyboard = null; // Are they transforming? -let positionGizmoObserver = null; // Are they in [move mesh] state? +let activePick = null; // [Select mesh?] +let activeDuplicatePickHandler = null; // [Clone mesh?] +let stopAxisKeyboard = null; // Axis keyboard active? +let positionGizmoObserver = null; // [Move mesh?] // Track DO sections and their associated blocks for cleanup const gizmoCreatedBlocks = new Map(); // blockId -> { parentId, createdDoSection, timestamp } @@ -501,6 +507,7 @@ function getScaledSize(mesh) { // Clean up gizmo state if aborted function exitGizmoState() { + cleanupScenePick(); // Stop picking // Stop the axis keyboard stopAxisKeyboard?.(); stopAxisKeyboard = null; @@ -553,13 +560,25 @@ function startMoveKeyboardHandler(mesh) { // Pick a mesh (used by multiple gizmos) function pickMeshFromScene(onPicked) { + cleanupScenePick(); // Stop picking resetAttachedMesh(); + + const pointerObservable = flock.scene.onPointerObservable; + const pointerObserver = pointerObservable.add((event) => { + if (event.type === flock.BABYLON.PointerEventTypes.POINTERPICK) { + cleanupScenePick(); + onPicked(event.pickInfo.pickedMesh, event.pickInfo.pickedPoint); + } + }); + + activePick = { pointerObservable, pointerObserver }; + setTimeout(() => { startCanvasKeyboardMode( (x, y) => { const pick = flock.scene.pick(x, y); + cleanupScenePick(); onPicked(pick?.pickedMesh, pick?.pickedPoint); - stopCanvasKeyboardMode(); }, false, (x, y) => @@ -568,14 +587,16 @@ function pickMeshFromScene(onPicked) { ); document.body.style.cursor = "crosshair"; }, 0); +} - const pointerObservable = flock.scene.onPointerObservable; - const pointerObserver = pointerObservable.add((event) => { - if (event.type === flock.BABYLON.PointerEventTypes.POINTERPICK) { - onPicked(event.pickInfo.pickedMesh, event.pickInfo.pickedPoint); - pointerObservable.remove(pointerObserver); - } - }); +// Clean up after picking +function cleanupScenePick() { + if (activePick) { + activePick.pointerObservable.remove(activePick.pointerObserver); + activePick = null; + } + stopCanvasKeyboardMode(); + document.body.style.cursor = "default"; } export function disableGizmos() { @@ -1707,3 +1728,6 @@ export function configureScaleGizmo( // Export functions for global access window.toggleGizmo = toggleGizmo; window.turnOffAllGizmos = turnOffAllGizmos; +if (DEBUG) { + window._debugPick = () => flock.scene.onPointerObservable._observers.length; +} From b83b6255cabb3d496a4b694a0e5d62367d611bcc Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:03:29 +0100 Subject: [PATCH 5/5] Bug - clean up duplicate --- ui/gizmos.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ui/gizmos.js b/ui/gizmos.js index 7bd6674a..ab184853 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -508,6 +508,13 @@ function getScaledSize(mesh) { // Clean up gizmo state if aborted function exitGizmoState() { cleanupScenePick(); // Stop picking + + // Properly clean up if duplicating + if (activeDuplicatePickHandler) { + window.removeEventListener("click", activeDuplicatePickHandler); + activeDuplicatePickHandler = null; + } + // Stop the axis keyboard stopAxisKeyboard?.(); stopAxisKeyboard = null; @@ -624,10 +631,9 @@ export function toggleGizmo(gizmoType) { .forEach((btn) => btn.classList.remove("active")); // If they abandoned a duplicate half way, remove listener - if (activeDuplicatePickHandler) { - window.removeEventListener("click", activeDuplicatePickHandler); - activeDuplicatePickHandler = null; - if (gizmoType === "duplicate") return; + if (gizmoType === "duplicate" && activeDuplicatePickHandler) { + exitGizmoState(); + return; } // If they were mid-transform, clean up