From 84ae47f997469290b325d345d07f84ec575a1e67 Mon Sep 17 00:00:00 2001
From: Laura Sach <5183697+lawsie@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:04:07 +0100
Subject: [PATCH 1/2] Add show-hide button for gizmo hud
---
accessibility/keyboardui.js | 5 ++
locale/de.js | 3 +
locale/en.js | 3 +
locale/es.js | 3 +
locale/fr.js | 3 +
locale/it.js | 3 +
locale/pl.js | 3 +
locale/pt.js | 3 +
locale/sv.js | 3 +
tests/gizmo-mobile-hud.test.js | 160 +++++++++++++++++++++++++++++----
ui/gizmo-mobile-hud.js | 118 ++++++++++++++++++++++++
ui/gizmos.js | 15 ++++
12 files changed, 306 insertions(+), 16 deletions(-)
diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js
index ada5170c..4b99c7d3 100644
--- a/accessibility/keyboardui.js
+++ b/accessibility/keyboardui.js
@@ -626,6 +626,11 @@ function getShortcuts() {
keys: `F`,
category: translate('shortcut_category_gizmos'),
},
+ {
+ label: translate('shortcut_toggle_hud'),
+ keys: `O`,
+ category: translate('shortcut_category_gizmos'),
+ },
{
label: translate('shortcut_quick_colour'),
keys: `C`,
diff --git a/locale/de.js b/locale/de.js
index 76bf3adc..7987ebb5 100644
--- a/locale/de.js
+++ b/locale/de.js
@@ -1274,6 +1274,9 @@ export default {
shortcut_lock_transform: 'Transformation auf Achse einschränken',
shortcut_transform_3d: 'In 3D transformieren',
shortcut_focus_camera: 'Kamera auf Objekt ausrichten',
+ shortcut_toggle_hud: 'Bildschirmsteuerung ein-/ausblenden', // machine
+ hud_hidden: 'Bildschirmsteuerung ausgeblendet', // machine
+ hud_shown: 'Bildschirmsteuerung eingeblendet', // machine
shortcut_quick_colour: 'Schnellfarbauswahl im Farbwähler',
shortcut_delete_object: 'Objekt löschen',
diff --git a/locale/en.js b/locale/en.js
index 44731f0b..32d11924 100644
--- a/locale/en.js
+++ b/locale/en.js
@@ -1397,6 +1397,9 @@ export default {
shortcut_lock_transform: 'Lock transform to axis',
shortcut_transform_3d: 'Transform in 3D',
shortcut_focus_camera: 'Focus camera on object',
+ shortcut_toggle_hud: 'Show/hide on-screen controls',
+ hud_hidden: 'On-screen controls hidden',
+ hud_shown: 'On-screen controls shown',
shortcut_quick_colour: 'Quick use colour in colour picker',
shortcut_delete_object: 'Delete object',
diff --git a/locale/es.js b/locale/es.js
index e0ea4fe0..a123152c 100644
--- a/locale/es.js
+++ b/locale/es.js
@@ -1342,6 +1342,9 @@ export default {
shortcut_lock_transform: 'Bloquear transformación en eje',
shortcut_transform_3d: 'Transformar en 3D',
shortcut_focus_camera: 'Enfocar cámara en objeto',
+ shortcut_toggle_hud: 'Mostrar/ocultar controles en pantalla', // machine
+ hud_hidden: 'Controles en pantalla ocultos', // machine
+ hud_shown: 'Controles en pantalla visibles', // machine
shortcut_quick_colour: 'Usar color rápido en el selector de color',
shortcut_delete_object: 'Eliminar objeto',
diff --git a/locale/fr.js b/locale/fr.js
index b691905e..0da02cda 100644
--- a/locale/fr.js
+++ b/locale/fr.js
@@ -1281,6 +1281,9 @@ export default {
shortcut_lock_transform: 'Verrouiller la transformation sur un axe',
shortcut_transform_3d: 'Transformer en 3D',
shortcut_focus_camera: "Centrer la caméra sur l'objet",
+ shortcut_toggle_hud: "Afficher/masquer les commandes à l'écran", // machine
+ hud_hidden: "Commandes à l'écran masquées", // machine
+ hud_shown: "Commandes à l'écran affichées", // machine
shortcut_quick_colour: 'Utiliser rapidement une couleur dans le sélecteur',
shortcut_delete_object: "Supprimer l'objet",
diff --git a/locale/it.js b/locale/it.js
index 662a301d..077afae8 100644
--- a/locale/it.js
+++ b/locale/it.js
@@ -1283,6 +1283,9 @@ export default {
shortcut_lock_transform: "Blocca trasformazione sull'asse",
shortcut_transform_3d: 'Trasforma in 3D',
shortcut_focus_camera: "Centra la telecamera sull'oggetto",
+ shortcut_toggle_hud: 'Mostra/nascondi i comandi a schermo', // machine
+ hud_hidden: 'Comandi a schermo nascosti', // machine
+ hud_shown: 'Comandi a schermo visibili', // machine
shortcut_quick_colour: 'Uso rapido del colore nel selettore colori',
shortcut_delete_object: 'Elimina oggetto',
diff --git a/locale/pl.js b/locale/pl.js
index f8c388ed..cb4b4d42 100644
--- a/locale/pl.js
+++ b/locale/pl.js
@@ -1274,6 +1274,9 @@ export default {
shortcut_lock_transform: 'Zablokuj transformację na osi',
shortcut_transform_3d: 'Transformuj w 3D',
shortcut_focus_camera: 'Skieruj kamerę na obiekt',
+ shortcut_toggle_hud: 'Pokaż/ukryj sterowanie ekranowe', // machine
+ hud_hidden: 'Sterowanie ekranowe ukryte', // machine
+ hud_shown: 'Sterowanie ekranowe widoczne', // machine
shortcut_quick_colour: 'Szybkie użycie koloru w selektorze kolorów',
shortcut_delete_object: 'Usuń obiekt',
diff --git a/locale/pt.js b/locale/pt.js
index a551fdf1..98b45336 100644
--- a/locale/pt.js
+++ b/locale/pt.js
@@ -1285,6 +1285,9 @@ export default {
shortcut_lock_transform: 'Bloquear transformação no eixo',
shortcut_transform_3d: 'Transformar em 3D',
shortcut_focus_camera: 'Focar câmara no objeto',
+ shortcut_toggle_hud: 'Mostrar/ocultar controlos no ecrã', // machine
+ hud_hidden: 'Controlos no ecrã ocultos', // machine
+ hud_shown: 'Controlos no ecrã visíveis', // machine
shortcut_quick_colour: 'Uso rápido de cor no seletor de cores',
shortcut_delete_object: 'Eliminar objeto',
diff --git a/locale/sv.js b/locale/sv.js
index 756459be..a2420911 100644
--- a/locale/sv.js
+++ b/locale/sv.js
@@ -1260,6 +1260,9 @@ export default {
shortcut_lock_transform: 'Lås transformering till axel',
shortcut_transform_3d: 'Transformera i 3D',
shortcut_focus_camera: 'Rikta kameran mot objekt',
+ shortcut_toggle_hud: 'Visa/dölj skärmkontroller', // machine
+ hud_hidden: 'Skärmkontroller dolda', // machine
+ hud_shown: 'Skärmkontroller visas', // machine
shortcut_quick_colour: 'Snabb färganvändning i färgväljaren',
shortcut_delete_object: 'Ta bort objekt',
diff --git a/tests/gizmo-mobile-hud.test.js b/tests/gizmo-mobile-hud.test.js
index 560d322c..e6bfb0fe 100644
--- a/tests/gizmo-mobile-hud.test.js
+++ b/tests/gizmo-mobile-hud.test.js
@@ -10,12 +10,34 @@ function findControl(flock, name) {
return hud?.getDescendants(false, (c) => c.name === name)[0] ?? null;
}
+const COLLAPSED_KEY = 'flock-gizmo-hud-collapsed';
+
+// A dispatched PointerEvent has no active pointer behind it, so the slider's
+// canvas.setPointerCapture() throws NotFoundError here even though it is fine
+// in a real browser. Stub it for the dispatch — it is incidental to what these
+// tests assert.
+function withoutPointerCapture(canvas, fn) {
+ const real = canvas.setPointerCapture;
+ canvas.setPointerCapture = () => {};
+ try {
+ fn();
+ } finally {
+ canvas.setPointerCapture = real;
+ }
+}
+
export function runGizmoMobileHudTests(flock) {
describe('ui/gizmo-mobile-hud @gizmomobilehud', function () {
let stop;
let moves;
let axisChanges;
+ // The collapsed preference persists in localStorage, so without this a
+ // single collapse test would silently invalidate every later assertion.
+ beforeEach(function () {
+ localStorage.removeItem(COLLAPSED_KEY);
+ });
+
function make(overrides = {}) {
moves = [];
axisChanges = [];
@@ -34,6 +56,7 @@ export function runGizmoMobileHudTests(flock) {
stop = null;
flock.controlsTexture = undefined;
flock._joystickSource = undefined;
+ localStorage.removeItem(COLLAPSED_KEY);
});
describe('guard clause', function () {
@@ -207,14 +230,16 @@ export function runGizmoMobileHudTests(flock) {
make({ initialAxis: 'x', getValues: () => ({ x: 0, y: 0, z: 0 }) });
const canvas = flock.canvas;
const rect = canvas.getBoundingClientRect();
- canvas.dispatchEvent(
- new PointerEvent('pointerdown', {
- pointerId: 2,
- clientX: rect.left + rect.width / 4 + 30,
- clientY: rect.bottom - 10,
- })
- );
- canvas.dispatchEvent(new PointerEvent('pointerup', { pointerId: 2 }));
+ withoutPointerCapture(canvas, () => {
+ canvas.dispatchEvent(
+ new PointerEvent('pointerdown', {
+ pointerId: 2,
+ clientX: rect.left + rect.width / 4 + 30,
+ clientY: rect.bottom - 10,
+ })
+ );
+ canvas.dispatchEvent(new PointerEvent('pointerup', { pointerId: 2 }));
+ });
expect(moves.length).to.equal(1);
expect(moves[0][0]).to.be.above(0);
});
@@ -223,14 +248,16 @@ export function runGizmoMobileHudTests(flock) {
make({ initialAxis: 'x', getValues: () => ({ x: 0, y: 0, z: 0 }) });
const canvas = flock.canvas;
const rect = canvas.getBoundingClientRect();
- canvas.dispatchEvent(
- new PointerEvent('pointerdown', {
- pointerId: 3,
- clientX: rect.left + rect.width / 4 - 30,
- clientY: rect.bottom - 10,
- })
- );
- canvas.dispatchEvent(new PointerEvent('pointerup', { pointerId: 3 }));
+ withoutPointerCapture(canvas, () => {
+ canvas.dispatchEvent(
+ new PointerEvent('pointerdown', {
+ pointerId: 3,
+ clientX: rect.left + rect.width / 4 - 30,
+ clientY: rect.bottom - 10,
+ })
+ );
+ canvas.dispatchEvent(new PointerEvent('pointerup', { pointerId: 3 }));
+ });
expect(moves.length).to.equal(1);
expect(moves[0][0]).to.be.below(0);
});
@@ -249,5 +276,106 @@ export function runGizmoMobileHudTests(flock) {
expect(moves).to.deep.equal([]);
});
});
+
+ describe('collapse handle', function () {
+ it('creates the handle in slider mode', function () {
+ make();
+ expect(findControl(flock, 'gizmo-hud-toggle')).to.exist;
+ });
+
+ it('creates the handle in arrows mode', function () {
+ make({ mode: 'arrows' });
+ expect(findControl(flock, 'gizmo-hud-toggle')).to.exist;
+ });
+
+ it('starts expanded by default', function () {
+ make();
+ expect(stop.isCollapsed()).to.equal(false);
+ expect(findControl(flock, 'gizmoHudContainer').isVisible).to.equal(true);
+ });
+
+ it('tapping the handle hides the strip but keeps the handle visible', function () {
+ make();
+ const handle = findControl(flock, 'gizmo-hud-toggle');
+ handle.onPointerUpObservable.notifyObservers();
+ expect(findControl(flock, 'gizmoHudContainer').isVisible).to.equal(false);
+ expect(handle.isVisible).to.equal(true);
+ });
+
+ it('tapping the handle again restores the strip', function () {
+ make();
+ const handle = findControl(flock, 'gizmo-hud-toggle');
+ handle.onPointerUpObservable.notifyObservers();
+ handle.onPointerUpObservable.notifyObservers();
+ expect(findControl(flock, 'gizmoHudContainer').isVisible).to.equal(true);
+ });
+
+ it('stop.toggleCollapsed() reports and applies the new state', function () {
+ make();
+ expect(stop.toggleCollapsed()).to.equal(true);
+ expect(stop.isCollapsed()).to.equal(true);
+ expect(stop.toggleCollapsed()).to.equal(false);
+ });
+
+ it('persists the collapsed state to localStorage', function () {
+ make();
+ stop.toggleCollapsed();
+ expect(localStorage.getItem(COLLAPSED_KEY)).to.equal('1');
+ });
+
+ it('a HUD built while the preference is set starts collapsed', function () {
+ localStorage.setItem(COLLAPSED_KEY, '1');
+ make();
+ expect(stop.isCollapsed()).to.equal(true);
+ expect(findControl(flock, 'gizmoHudContainer').isVisible).to.equal(false);
+ });
+
+ it('does not claim slider touches while collapsed', function () {
+ // The slider hit-tests canvas geometry rather than using GUI
+ // hit-testing, so hiding the strip alone would still swallow the
+ // camera drag in that corner.
+ make({ initialAxis: 'x', getValues: () => ({ x: 0, y: 0, z: 0 }) });
+ stop.toggleCollapsed();
+ const canvas = flock.canvas;
+ const rect = canvas.getBoundingClientRect();
+ withoutPointerCapture(canvas, () => {
+ canvas.dispatchEvent(
+ new PointerEvent('pointerdown', {
+ pointerId: 5,
+ clientX: rect.left + rect.width / 4 + 30,
+ clientY: rect.bottom - 10,
+ })
+ );
+ canvas.dispatchEvent(new PointerEvent('pointerup', { pointerId: 5 }));
+ });
+ expect(moves).to.deep.equal([]);
+ });
+
+ it('claims slider touches again after expanding', function () {
+ make({ initialAxis: 'x', getValues: () => ({ x: 0, y: 0, z: 0 }) });
+ stop.toggleCollapsed();
+ stop.toggleCollapsed();
+ const canvas = flock.canvas;
+ const rect = canvas.getBoundingClientRect();
+ withoutPointerCapture(canvas, () => {
+ canvas.dispatchEvent(
+ new PointerEvent('pointerdown', {
+ pointerId: 6,
+ clientX: rect.left + rect.width / 4 + 30,
+ clientY: rect.bottom - 10,
+ })
+ );
+ canvas.dispatchEvent(new PointerEvent('pointerup', { pointerId: 6 }));
+ });
+ expect(moves.length).to.equal(1);
+ });
+
+ it('toggleCollapsed after stop() does not throw', function () {
+ make();
+ stop();
+ expect(() => stop.toggleCollapsed()).to.not.throw();
+ stop = null;
+ });
+ });
});
}
diff --git a/ui/gizmo-mobile-hud.js b/ui/gizmo-mobile-hud.js
index 3fcfbebd..4d207487 100644
--- a/ui/gizmo-mobile-hud.js
+++ b/ui/gizmo-mobile-hud.js
@@ -2,6 +2,42 @@ import { flock } from '../flock.js';
const fontFamily = 'Atkinson Hyperlegible Next';
+// Collapsed/expanded survives tool switches, canvas rebuilds and reloads —
+// re-hiding on every tool switch would make the toggle pointless.
+// Font Awesome Free 6.7.2 "gears" (solid) by @fontawesome — https://fontawesome.com
+// License: https://fontawesome.com/license/free — Copyright 2025 Fonticons, Inc.
+// Inlined rather than imported: @fortawesome is only a transitive dependency here,
+// and index.html embeds its icons the same way.
+const GEARS_PATH =
+ 'M308.5 135.3c7.1-6.3 9.9-16.2 6.2-25c-2.3-5.3-4.8-10.5-7.6-15.5L304 89.4c-3-5-6.3-9.9-9.8-14.6c-5.7-7.6-15.7-10.1-24.7-7.1l-28.2 9.3c-10.7-8.8-23-16-36.2-20.9L199 27.1c-1.9-9.3-9.1-16.7-18.5-17.8C173.9 8.4 167.2 8 160.4 8l-.7 0c-6.8 0-13.5 .4-20.1 1.2c-9.4 1.1-16.6 8.6-18.5 17.8L115 56.1c-13.3 5-25.5 12.1-36.2 20.9L50.5 67.8c-9-3-19-.5-24.7 7.1c-3.5 4.7-6.8 9.6-9.9 14.6l-3 5.3c-2.8 5-5.3 10.2-7.6 15.6c-3.7 8.7-.9 18.6 6.2 25l22.2 19.8C32.6 161.9 32 168.9 32 176s.6 14.1 1.7 20.9L11.5 216.7c-7.1 6.3-9.9 16.2-6.2 25c2.3 5.3 4.8 10.5 7.6 15.6l3 5.2c3 5.1 6.3 9.9 9.9 14.6c5.7 7.6 15.7 10.1 24.7 7.1l28.2-9.3c10.7 8.8 23 16 36.2 20.9l6.1 29.1c1.9 9.3 9.1 16.7 18.5 17.8c6.7 .8 13.5 1.2 20.4 1.2s13.7-.4 20.4-1.2c9.4-1.1 16.6-8.6 18.5-17.8l6.1-29.1c13.3-5 25.5-12.1 36.2-20.9l28.2 9.3c9 3 19 .5 24.7-7.1c3.5-4.7 6.8-9.5 9.8-14.6l3.1-5.4c2.8-5 5.3-10.2 7.6-15.5c3.7-8.7 .9-18.6-6.2-25l-22.2-19.8c1.1-6.8 1.7-13.8 1.7-20.9s-.6-14.1-1.7-20.9l22.2-19.8zM112 176a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM504.7 500.5c6.3 7.1 16.2 9.9 25 6.2c5.3-2.3 10.5-4.8 15.5-7.6l5.4-3.1c5-3 9.9-6.3 14.6-9.8c7.6-5.7 10.1-15.7 7.1-24.7l-9.3-28.2c8.8-10.7 16-23 20.9-36.2l29.1-6.1c9.3-1.9 16.7-9.1 17.8-18.5c.8-6.7 1.2-13.5 1.2-20.4s-.4-13.7-1.2-20.4c-1.1-9.4-8.6-16.6-17.8-18.5L583.9 307c-5-13.3-12.1-25.5-20.9-36.2l9.3-28.2c3-9 .5-19-7.1-24.7c-4.7-3.5-9.6-6.8-14.6-9.9l-5.3-3c-5-2.8-10.2-5.3-15.6-7.6c-8.7-3.7-18.6-.9-25 6.2l-19.8 22.2c-6.8-1.1-13.8-1.7-20.9-1.7s-14.1 .6-20.9 1.7l-19.8-22.2c-6.3-7.1-16.2-9.9-25-6.2c-5.3 2.3-10.5 4.8-15.6 7.6l-5.2 3c-5.1 3-9.9 6.3-14.6 9.9c-7.6 5.7-10.1 15.7-7.1 24.7l9.3 28.2c-8.8 10.7-16 23-20.9 36.2L315.1 313c-9.3 1.9-16.7 9.1-17.8 18.5c-.8 6.7-1.2 13.5-1.2 20.4s.4 13.7 1.2 20.4c1.1 9.4 8.6 16.6 17.8 18.5l29.1 6.1c5 13.3 12.1 25.5 20.9 36.2l-9.3 28.2c-3 9-.5 19 7.1 24.7c4.7 3.5 9.5 6.8 14.6 9.8l5.4 3.1c5 2.8 10.2 5.3 15.5 7.6c8.7 3.7 18.6 .9 25-6.2l19.8-22.2c6.8 1.1 13.8 1.7 20.9 1.7s14.1-.6 20.9-1.7l19.8 22.2zM464 304a48 48 0 1 1 0 96 48 48 0 1 1 0-96z';
+const GEARS_ASPECT = 512 / 640;
+// Babylon GUI images can't be tinted, so bake one data URI per fill.
+const gearsIcon = (fill) =>
+ 'data:image/svg+xml;charset=utf-8,' +
+ encodeURIComponent(
+ // width/height are required as well as viewBox: without an intrinsic size
+ // the browser hands Babylon a zero-sized image and the icon renders cropped.
+ ``
+ );
+const GEARS_DARK = gearsIcon('#1a1a1a');
+const GEARS_LIGHT = gearsIcon('#ffffff');
+
+const COLLAPSED_KEY = 'flock-gizmo-hud-collapsed';
+const readCollapsed = () => {
+ try {
+ return localStorage.getItem(COLLAPSED_KEY) === '1';
+ } catch {
+ return false;
+ }
+};
+const writeCollapsed = (value) => {
+ try {
+ localStorage.setItem(COLLAPSED_KEY, value ? '1' : '0');
+ } catch {
+ /* private mode / storage disabled — the session-local flag still works */
+ }
+};
+
export function createGizmoMobileHud({
onMove,
stepNormal,
@@ -47,6 +83,10 @@ export function createGizmoMobileHud({
// Cap at the same size as the existing on-screen controls (70 * s),
// but shrink if there isn't room for all axis buttons in the right half.
const BTN_SIZE = Math.min(70 * s, (HALF - (numAxes + 1) * GAP) / numAxes);
+ // Sized against the buttons so it reads as part of the same set, with a floor
+ // that keeps it a usable touch target on the narrowest canvases. It lives in
+ // the opposite corner from the strip, so it costs the row no width.
+ const TOGGLE = Math.max(44 * s, Math.min(56 * s, BTN_SIZE));
const TOTAL_H = BTN_SIZE + 2 * GAP;
// ── Transparent container ─────────────────────────────────────────────────
@@ -61,6 +101,62 @@ export function createGizmoMobileHud({
container.isPointerBlocker = false;
hudTexture.addControl(container);
+ // ── Collapse handle ───────────────────────────────────────────────────────
+ // A sibling of the strip rather than a child, so collapsing is just
+ // container.isVisible = false — Babylon GUI skips hit-testing hidden
+ // controls — while the handle itself stays visible and tappable.
+ // Same on/off language as the gizmo toolbar right above the canvas: the icon
+ // never changes, the button does. Amber is --color-focus, which is what
+ // .gizmo-button.active uses.
+ const TOGGLE_ON = '#ffcc33';
+ const TOGGLE_OFF = 'rgba(0,0,0,0.4)';
+
+ const handle = new flock.GUI.Rectangle('gizmo-hud-toggle');
+ handle.width = `${TOGGLE}px`;
+ handle.height = `${TOGGLE}px`;
+ handle.cornerRadius = 8 * s;
+ handle.color = 'white';
+ handle.thickness = 3 * s;
+ handle.isPointerBlocker = true;
+ handle.horizontalAlignment = flock.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
+ handle.verticalAlignment = flock.GUI.Control.VERTICAL_ALIGNMENT_TOP;
+ // Top-left, clear of the strip entirely, so it holds the same spot whether
+ // the controls are showing or not.
+ handle.left = `${GAP}px`;
+ handle.top = `${GAP}px`;
+ hudTexture.addControl(handle);
+
+ const gears = new flock.GUI.Image('gizmo-hud-toggle-icon', GEARS_DARK);
+ gears.width = `${TOGGLE * 0.62}px`;
+ gears.height = `${TOGGLE * 0.62 * GEARS_ASPECT}px`;
+ gears.stretch = flock.GUI.Image.STRETCH_UNIFORM;
+ gears.isPointerBlocker = false;
+ handle.addControl(gears);
+
+ let collapsed = readCollapsed();
+ // Cancel anything mid-gesture that would otherwise keep firing against
+ // controls the user can no longer see.
+ const collapseHooks = [];
+
+ function applyCollapsed() {
+ container.isVisible = !collapsed;
+ // Lit means the controls are showing.
+ handle.background = collapsed ? TOGGLE_OFF : TOGGLE_ON;
+ gears.source = collapsed ? GEARS_LIGHT : GEARS_DARK;
+ if (collapsed) collapseHooks.forEach((fn) => fn());
+ // The mouse gizmo can rotate the mesh while the strip is hidden, so the
+ // thumb has to be re-read from the mesh on the way back.
+ else refreshThumb?.();
+ }
+
+ function setCollapsed(next) {
+ collapsed = next;
+ writeCollapsed(collapsed);
+ applyCollapsed();
+ }
+
+ handle.onPointerUpObservable.add(() => setCollapsed(!collapsed));
+
// ── Axis buttons (right half, single horizontal row) ──────────────────────
const axisButtons = {};
AXIS_DEFS.forEach(({ key, label }, i) => {
@@ -200,6 +296,7 @@ export function createGizmoMobileHud({
btn.onPointerOutObservable.add(stopRepeat);
cleanups.push(stopRepeat);
+ collapseHooks.push(stopRepeat);
return btn;
}
@@ -336,6 +433,10 @@ export function createGizmoMobileHud({
}
function onPointerDown(e) {
+ // The slider hit-tests geometry on the canvas rather than using GUI
+ // hit-testing, so hiding the strip isn't enough to stop it claiming
+ // touches in that corner — it has to be switched off explicitly.
+ if (collapsed) return;
if (activePointer !== null) return;
const b = sliderBounds();
if (e.clientY < b.top || e.clientY > b.bottom) return;
@@ -389,8 +490,17 @@ export function createGizmoMobileHud({
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
+ // Collapsing mid-drag has to release the pointer, or activePointer stays
+ // set and the slider ignores the first touch after expanding again.
+ collapseHooks.push(() => {
+ activePointer = null;
+ lastSnappedGUI = null;
+ thumb.background = 'rgba(255,255,255,0.85)';
+ });
+
// Block camera/scene for slider touches — including the initial pointerdown
const prePointerObserver = flock.scene.onPrePointerObservable.add((info) => {
+ if (collapsed) return;
const e = info.event;
if (activePointer !== null && e.pointerId === activePointer) {
info.skipOnPointerObservable = true;
@@ -418,6 +528,8 @@ export function createGizmoMobileHud({
});
}
+ applyCollapsed();
+
// ── Stop / cleanup ────────────────────────────────────────────────────────
let stopped = false;
function stop() {
@@ -428,6 +540,12 @@ export function createGizmoMobileHud({
if (savedControls) savedControls.rootContainer.isVisible = true;
if (flock._joystickSource) flock._joystickSource.resume();
}
+ stop.toggleCollapsed = () => {
+ if (stopped) return collapsed;
+ setCollapsed(!collapsed);
+ return collapsed;
+ };
+ stop.isCollapsed = () => collapsed;
stop.setAxis = (newAxis) => {
if (stopped) return;
const def = AXIS_DEFS.find((d) => d.key === newAxis);
diff --git a/ui/gizmos.js b/ui/gizmos.js
index 1197eca4..ea38c130 100644
--- a/ui/gizmos.js
+++ b/ui/gizmos.js
@@ -189,6 +189,7 @@ function createAdaptiveInput({
onAxisChange?.(axis);
};
stop.getAxis = () => keyboard?.getAxis?.() ?? null;
+ stop.toggleHud = () => hud?.toggleCollapsed?.();
return stop;
}
@@ -220,6 +221,20 @@ function registerBindings() {
toggleGizmo('eye');
})
);
+ // Show/hide the on-screen transform controls with O
+ KeyboardDispatcher.on(
+ 'GIZMO',
+ 'KeyO',
+ noMod((e) => {
+ if (!stopAxisKeyboard?.toggleHud) return;
+ e.preventDefault();
+ const collapsed = stopAxisKeyboard.toggleHud();
+ showStatus(translate(collapsed ? 'hud_hidden' : 'hud_shown'), {
+ duration: 3,
+ owner: 'hud',
+ });
+ })
+ );
// Delete selected mesh with Del key
KeyboardDispatcher.on('GIZMO', 'Delete', (e) => {
if (!gizmoManager?.attachedMesh) return;
From 1ceb15114e50f614358e17d7f4a4548224305ee7 Mon Sep 17 00:00:00 2001
From: Laura Sach <5183697+lawsie@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:41:56 +0100
Subject: [PATCH 2/2] Remove Claude comment mess
---
ui/gizmo-mobile-hud.js | 28 +++-------------------------
1 file changed, 3 insertions(+), 25 deletions(-)
diff --git a/ui/gizmo-mobile-hud.js b/ui/gizmo-mobile-hud.js
index 4d207487..2612122b 100644
--- a/ui/gizmo-mobile-hud.js
+++ b/ui/gizmo-mobile-hud.js
@@ -2,21 +2,14 @@ import { flock } from '../flock.js';
const fontFamily = 'Atkinson Hyperlegible Next';
-// Collapsed/expanded survives tool switches, canvas rebuilds and reloads —
-// re-hiding on every tool switch would make the toggle pointless.
// Font Awesome Free 6.7.2 "gears" (solid) by @fontawesome — https://fontawesome.com
// License: https://fontawesome.com/license/free — Copyright 2025 Fonticons, Inc.
-// Inlined rather than imported: @fortawesome is only a transitive dependency here,
-// and index.html embeds its icons the same way.
const GEARS_PATH =
'M308.5 135.3c7.1-6.3 9.9-16.2 6.2-25c-2.3-5.3-4.8-10.5-7.6-15.5L304 89.4c-3-5-6.3-9.9-9.8-14.6c-5.7-7.6-15.7-10.1-24.7-7.1l-28.2 9.3c-10.7-8.8-23-16-36.2-20.9L199 27.1c-1.9-9.3-9.1-16.7-18.5-17.8C173.9 8.4 167.2 8 160.4 8l-.7 0c-6.8 0-13.5 .4-20.1 1.2c-9.4 1.1-16.6 8.6-18.5 17.8L115 56.1c-13.3 5-25.5 12.1-36.2 20.9L50.5 67.8c-9-3-19-.5-24.7 7.1c-3.5 4.7-6.8 9.6-9.9 14.6l-3 5.3c-2.8 5-5.3 10.2-7.6 15.6c-3.7 8.7-.9 18.6 6.2 25l22.2 19.8C32.6 161.9 32 168.9 32 176s.6 14.1 1.7 20.9L11.5 216.7c-7.1 6.3-9.9 16.2-6.2 25c2.3 5.3 4.8 10.5 7.6 15.6l3 5.2c3 5.1 6.3 9.9 9.9 14.6c5.7 7.6 15.7 10.1 24.7 7.1l28.2-9.3c10.7 8.8 23 16 36.2 20.9l6.1 29.1c1.9 9.3 9.1 16.7 18.5 17.8c6.7 .8 13.5 1.2 20.4 1.2s13.7-.4 20.4-1.2c9.4-1.1 16.6-8.6 18.5-17.8l6.1-29.1c13.3-5 25.5-12.1 36.2-20.9l28.2 9.3c9 3 19 .5 24.7-7.1c3.5-4.7 6.8-9.5 9.8-14.6l3.1-5.4c2.8-5 5.3-10.2 7.6-15.5c3.7-8.7 .9-18.6-6.2-25l-22.2-19.8c1.1-6.8 1.7-13.8 1.7-20.9s-.6-14.1-1.7-20.9l22.2-19.8zM112 176a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM504.7 500.5c6.3 7.1 16.2 9.9 25 6.2c5.3-2.3 10.5-4.8 15.5-7.6l5.4-3.1c5-3 9.9-6.3 14.6-9.8c7.6-5.7 10.1-15.7 7.1-24.7l-9.3-28.2c8.8-10.7 16-23 20.9-36.2l29.1-6.1c9.3-1.9 16.7-9.1 17.8-18.5c.8-6.7 1.2-13.5 1.2-20.4s-.4-13.7-1.2-20.4c-1.1-9.4-8.6-16.6-17.8-18.5L583.9 307c-5-13.3-12.1-25.5-20.9-36.2l9.3-28.2c3-9 .5-19-7.1-24.7c-4.7-3.5-9.6-6.8-14.6-9.9l-5.3-3c-5-2.8-10.2-5.3-15.6-7.6c-8.7-3.7-18.6-.9-25 6.2l-19.8 22.2c-6.8-1.1-13.8-1.7-20.9-1.7s-14.1 .6-20.9 1.7l-19.8-22.2c-6.3-7.1-16.2-9.9-25-6.2c-5.3 2.3-10.5 4.8-15.6 7.6l-5.2 3c-5.1 3-9.9 6.3-14.6 9.9c-7.6 5.7-10.1 15.7-7.1 24.7l9.3 28.2c-8.8 10.7-16 23-20.9 36.2L315.1 313c-9.3 1.9-16.7 9.1-17.8 18.5c-.8 6.7-1.2 13.5-1.2 20.4s.4 13.7 1.2 20.4c1.1 9.4 8.6 16.6 17.8 18.5l29.1 6.1c5 13.3 12.1 25.5 20.9 36.2l-9.3 28.2c-3 9-.5 19 7.1 24.7c4.7 3.5 9.5 6.8 14.6 9.8l5.4 3.1c5 2.8 10.2 5.3 15.5 7.6c8.7 3.7 18.6 .9 25-6.2l19.8-22.2c6.8 1.1 13.8 1.7 20.9 1.7s14.1-.6 20.9-1.7l19.8 22.2zM464 304a48 48 0 1 1 0 96 48 48 0 1 1 0-96z';
const GEARS_ASPECT = 512 / 640;
-// Babylon GUI images can't be tinted, so bake one data URI per fill.
const gearsIcon = (fill) =>
'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(
- // width/height are required as well as viewBox: without an intrinsic size
- // the browser hands Babylon a zero-sized image and the icon renders cropped.
``
);
const GEARS_DARK = gearsIcon('#1a1a1a');
@@ -81,11 +74,7 @@ export function createGizmoMobileHud({
const HALF = canvas.width / 2;
const BOTTOM_PADDING = 24 * s;
// Cap at the same size as the existing on-screen controls (70 * s),
- // but shrink if there isn't room for all axis buttons in the right half.
const BTN_SIZE = Math.min(70 * s, (HALF - (numAxes + 1) * GAP) / numAxes);
- // Sized against the buttons so it reads as part of the same set, with a floor
- // that keeps it a usable touch target on the narrowest canvases. It lives in
- // the opposite corner from the strip, so it costs the row no width.
const TOGGLE = Math.max(44 * s, Math.min(56 * s, BTN_SIZE));
const TOTAL_H = BTN_SIZE + 2 * GAP;
@@ -101,13 +90,7 @@ export function createGizmoMobileHud({
container.isPointerBlocker = false;
hudTexture.addControl(container);
- // ── Collapse handle ───────────────────────────────────────────────────────
- // A sibling of the strip rather than a child, so collapsing is just
- // container.isVisible = false — Babylon GUI skips hit-testing hidden
- // controls — while the handle itself stays visible and tappable.
- // Same on/off language as the gizmo toolbar right above the canvas: the icon
- // never changes, the button does. Amber is --color-focus, which is what
- // .gizmo-button.active uses.
+ // Same as gizmo highlight color
const TOGGLE_ON = '#ffcc33';
const TOGGLE_OFF = 'rgba(0,0,0,0.4)';
@@ -120,9 +103,7 @@ export function createGizmoMobileHud({
handle.isPointerBlocker = true;
handle.horizontalAlignment = flock.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT;
handle.verticalAlignment = flock.GUI.Control.VERTICAL_ALIGNMENT_TOP;
- // Top-left, clear of the strip entirely, so it holds the same spot whether
- // the controls are showing or not.
- handle.left = `${GAP}px`;
+ handle.left = `${GAP}px`; // Top-left to save space
handle.top = `${GAP}px`;
hudTexture.addControl(handle);
@@ -140,7 +121,6 @@ export function createGizmoMobileHud({
function applyCollapsed() {
container.isVisible = !collapsed;
- // Lit means the controls are showing.
handle.background = collapsed ? TOGGLE_OFF : TOGGLE_ON;
gears.source = collapsed ? GEARS_LIGHT : GEARS_DARK;
if (collapsed) collapseHooks.forEach((fn) => fn());
@@ -433,9 +413,7 @@ export function createGizmoMobileHud({
}
function onPointerDown(e) {
- // The slider hit-tests geometry on the canvas rather than using GUI
- // hit-testing, so hiding the strip isn't enough to stop it claiming
- // touches in that corner — it has to be switched off explicitly.
+ // Turn off slider explicitly when collapsed
if (collapsed) return;
if (activePointer !== null) return;
const b = sliderBounds();