From 11c4a4b6c03bdca1b008371aae2021f3c7471c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:06:39 +0000 Subject: [PATCH 1/5] Fix Steam Controller float layout: separate triggers from bumpers, lift paddles (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #61's tighter float spread packed the Steam Controller's popped parts together: the triggers landed right on top of the bumpers, and the back paddles dropped so far under the body they were half hidden behind it. These parts use per-part floatTuning (floatFactor/lateralBias don't apply to tuned parts), so adjust the tuning: - Triggers pushed further off the back surface (back 0.22→0.34, up 0.24→0.36) to reopen the gap above the bumpers. - Paddles raised (up -0.14→-0.06) and pulled further toward the camera (back -0.45→-0.58) so they clear the body's bottom edge instead of hiding. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017NiS2a4jZ877XgftkH1Dd1 --- packages/visualizer/src/controller-profiles.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/visualizer/src/controller-profiles.js b/packages/visualizer/src/controller-profiles.js index de7f963..12a1377 100644 --- a/packages/visualizer/src/controller-profiles.js +++ b/packages/visualizer/src/controller-profiles.js @@ -367,18 +367,24 @@ export const PROFILES = { floatTuning: { left_shoulder: { back: 0.08, up: 0.12, tiltUp: 65 }, right_shoulder: { back: 0.08, up: 0.12, tiltUp: 65 }, - left_trigger: { back: 0.22, up: 0.24, tiltUp: 65 }, - right_trigger: { back: 0.22, up: 0.24, tiltUp: 65 }, + // Triggers sit ABOVE/behind the bumpers when popped. #61's tighter spread + // packed them right on top of the bumpers (issue #75 — they overlapped), + // so push the triggers further off the back surface to reopen the gap. + left_trigger: { back: 0.34, up: 0.36, tiltUp: 65 }, + right_trigger: { back: 0.34, up: 0.36, tiltUp: 65 }, // Paddles pop toward the CENTER (X) and along −Z, with a little drop, so // from the top view they sit between the handle ends toward the bottom of // the view and stay visible. Knobs (model-radius units): // up<0 = down (−Y) // back>0 = −Z, back<0 = +Z (toward camera). `offset.z = −radius·back`. // side<0 = inward toward center (X) - paddle1: { up: -0.14, back: -0.45, side: -0.13 }, - paddle2: { up: -0.14, back: -0.45, side: -0.13 }, - paddle3: { up: -0.14, back: -0.45, side: -0.13 }, - paddle4: { up: -0.14, back: -0.45, side: -0.13 }, + // #61 dropped them too far under the body (issue #75 — half hidden below); + // raise them and pull them further toward the camera so they clear the + // body's bottom edge instead of hiding behind it. + paddle1: { up: -0.06, back: -0.58, side: -0.13 }, + paddle2: { up: -0.06, back: -0.58, side: -0.13 }, + paddle3: { up: -0.06, back: -0.58, side: -0.13 }, + paddle4: { up: -0.06, back: -0.58, side: -0.13 }, }, // Capacitive grip sensors (digital): glow these meshes while the grip is // held (driver parsed.grips). Highlighted via overlay.setGripState. From 7c2190bad556ad789fbc297403f89567952c0c76 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:51:27 +0000 Subject: [PATCH 2/5] Layout editor: keep orientation on select + precise numeric position/rotation (#75 PR feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the editing-UX feedback on PR #79: 1. Selecting/grabbing a part no longer resets its orientation. The override was seeded with euler {0,0,0}, discarding the part's intended tilt (triggers/bumpers' tiltUp) or camera-facing pose (paddles). It's now seeded from the part's current popped orientation (mode-aware), so the first edit is visually a no-op. Bare selection is non-destructive — getSelectedLayout() derives values read-only without committing an override. 2. A numeric editor in the Edit Layout panel, shown when a part is selected: precise X/Y/Z position and pitch/yaw/roll degrees, in addition to drag and the Q/E + arrow keys. Fields sync live while dragging/nudging, and edits apply per-axis (empty fields are left untouched) via the new getSelectedLayout()/setSelectedLayout() overlay API. A tip notes how to set mirrored values for a symmetrical layout. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017NiS2a4jZ877XgftkH1Dd1 --- apps/overlay/src/index.html | 27 +++++- apps/overlay/src/js/app.js | 40 +++++++++ packages/visualizer/src/controller-overlay.js | 82 ++++++++++++++++++- 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/apps/overlay/src/index.html b/apps/overlay/src/index.html index 7fa704c..06ecc52 100644 --- a/apps/overlay/src/index.html +++ b/apps/overlay/src/index.html @@ -144,6 +144,10 @@ cursor: pointer; } + .layout-num-row { align-items: center; } + .layout-num-fields { display: flex; align-items: center; gap: 4px; } + .layout-num-fields input[type="number"] { width: 46px; } + .window-size-fields { display: flex; align-items: center; gap: 4px; } .window-size-x { color: #888; font-size: 11px; } .setting-row input[type="number"] { @@ -1067,7 +1071,28 @@

Edit Layout

none +
diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 63e2f53..77a7a2b 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -555,6 +555,8 @@ async function init() { }); overlay.setLayoutChangeHandler((layout) => { localStorage.setItem('overlay:partLayout:' + currentControllerType, JSON.stringify(layout)); + // Keep the numeric editor in sync while the user drags or nudges with keys. + refreshLayoutNumeric(); }); overlay.setSelectHandler(onEditSelectionChange); overlay.setLayoutMode(localStorage.getItem('overlay:layoutMode') || 'relative'); @@ -1962,9 +1964,47 @@ const editLayoutStatusRow = document.getElementById('edit-layout-status-row'); const editLayoutHelp = document.getElementById('edit-layout-help'); const editLayoutSelected = document.getElementById('edit-layout-selected'); +// Precise numeric editor for the selected part (in addition to drag + Q/E/arrows). +const editLayoutNumeric = document.getElementById('edit-layout-numeric'); +const layoutNumInputs = { + px: document.getElementById('layout-pos-x'), py: document.getElementById('layout-pos-y'), pz: document.getElementById('layout-pos-z'), + rx: document.getElementById('layout-rot-x'), ry: document.getElementById('layout-rot-y'), rz: document.getElementById('layout-rot-z'), +}; + +// Push the selected part's live values into the numeric fields. Skips a field +// the user is mid-edit in (so typing isn't clobbered by drag/key updates), and +// rounds for readability. Pass the overlay's getSelectedLayout() result. +function refreshLayoutNumeric() { + const data = overlay?.getSelectedLayout?.(); + if (!data) return; + const round = (v, d) => Number.isFinite(v) ? Number(v.toFixed(d)) : 0; + const set = (el, v) => { if (el && document.activeElement !== el) el.value = v; }; + set(layoutNumInputs.px, round(data.offset.x, 3)); + set(layoutNumInputs.py, round(data.offset.y, 3)); + set(layoutNumInputs.pz, round(data.offset.z, 3)); + set(layoutNumInputs.rx, round(data.euler.x, 1)); + set(layoutNumInputs.ry, round(data.euler.y, 1)); + set(layoutNumInputs.rz, round(data.euler.z, 1)); +} + +// Read all six fields and apply them to the selected part. +function applyLayoutNumeric() { + if (!overlay?.setSelectedLayout) return; + const num = (el) => { const v = parseFloat(el?.value); return Number.isFinite(v) ? v : undefined; }; + overlay.setSelectedLayout({ + offset: { x: num(layoutNumInputs.px), y: num(layoutNumInputs.py), z: num(layoutNumInputs.pz) }, + euler: { x: num(layoutNumInputs.rx), y: num(layoutNumInputs.ry), z: num(layoutNumInputs.rz) }, + }); +} +for (const el of Object.values(layoutNumInputs)) { + el?.addEventListener('input', applyLayoutNumeric); +} + // Reflect the overlay's current selection in the settings UI. function onEditSelectionChange(partName) { if (editLayoutSelected) editLayoutSelected.textContent = partName || 'none'; + if (editLayoutNumeric) editLayoutNumeric.style.display = partName ? '' : 'none'; + if (partName) refreshLayoutNumeric(); } if (editLayoutToggle) { diff --git a/packages/visualizer/src/controller-overlay.js b/packages/visualizer/src/controller-overlay.js index 5fd4f29..f6158b1 100644 --- a/packages/visualizer/src/controller-overlay.js +++ b/packages/visualizer/src/controller-overlay.js @@ -1037,6 +1037,84 @@ export class ControllerOverlay { _emitLayout() { if (this._onLayoutChange) this._onLayoutChange(this.getLayout()); } + // Lazily create a part's layout override, SEEDED FROM ITS CURRENT POPPED + // ORIENTATION rather than zero rotation. Selecting/grabbing a part used to + // reset it to euler {0,0,0}, throwing away its intended tilt (triggers/ + // bumpers' tiltUp) or camera-facing pose (paddles) — the "orientation resets + // on first click" complaint. Reading the live wrapper quaternion reproduces + // exactly what's on screen, so the first edit is a no-op visually. + _ensureLayout(w) { + if (!w || w.layout) return; + const q = (this._layoutMode === 'absolute') + ? w.group.getWorldQuaternion(new THREE.Quaternion()) + : w.group.quaternion.clone(); + const e = new THREE.Euler().setFromQuaternion(q, 'XYZ'); + w.layout = { + offset: w.offset.clone(), + euler: { + x: THREE.MathUtils.radToDeg(e.x), + y: THREE.MathUtils.radToDeg(e.y), + z: THREE.MathUtils.radToDeg(e.z), + }, + }; + } + + /** + * Current position/rotation of the selected part, for a numeric editor. + * Read-only — does NOT create an override (so merely selecting a part to + * inspect its values doesn't freeze a camera-facing paddle). Values match + * what `setSelectedLayout` accepts: offset in parent-local model units, + * euler in degrees. Returns null when nothing is selected. + */ + getSelectedLayout() { + const w = this._selectedWrapper; + if (!w) return null; + let offset, euler; + if (w.layout) { + offset = w.layout.offset; + euler = w.layout.euler; + } else { + offset = w.offset; + const q = (this._layoutMode === 'absolute') + ? w.group.getWorldQuaternion(new THREE.Quaternion()) + : w.group.quaternion; + const e = new THREE.Euler().setFromQuaternion(q, 'XYZ'); + euler = { + x: THREE.MathUtils.radToDeg(e.x), + y: THREE.MathUtils.radToDeg(e.y), + z: THREE.MathUtils.radToDeg(e.z), + }; + } + return { + partName: w.partName, + offset: { x: offset.x, y: offset.y, z: offset.z }, + euler: { x: euler.x, y: euler.y, z: euler.z }, + }; + } + + /** + * Set precise position/rotation on the selected part from a numeric editor. + * Accepts a partial `{ offset:{x,y,z}, euler:{x,y,z} }`; only finite fields + * are applied, so individual axes can be edited independently. Commits an + * override (seeded from the current pose for any axes left unspecified). + */ + setSelectedLayout(vals) { + const w = this._selectedWrapper; + if (!w || !vals) return; + this._ensureLayout(w); + if (vals.offset) { + for (const k of ['x', 'y', 'z']) { + if (Number.isFinite(vals.offset[k])) w.layout.offset[k] = vals.offset[k]; + } + } + if (vals.euler) { + for (const k of ['x', 'y', 'z']) { + if (Number.isFinite(vals.euler[k])) w.layout.euler[k] = vals.euler[k]; + } + } + this._emitLayout(); + } + // Raycast canvas coords → the floatable wrapper under the cursor (or null). _pickWrapper(clientX, clientY) { const el = this.renderer?.domElement; @@ -1107,7 +1185,7 @@ export class ControllerOverlay { if (!hit) { if (this.controls) this.controls.enabled = true; return; } const parent = w.group.parent; const startLocal = parent ? parent.worldToLocal(hit.clone()) : hit.clone(); - if (!w.layout) w.layout = { offset: w.offset.clone(), euler: { x: 0, y: 0, z: 0 } }; + this._ensureLayout(w); this._dragState = { wrapper: w, plane, startLocal, startOffset: w.layout.offset.clone() }; } @@ -1133,7 +1211,7 @@ export class ControllerOverlay { if (e.key === 'Escape') { this._selectWrapper(null); e.preventDefault(); return; } const w = this._selectedWrapper; if (!w) return; - if (!w.layout) w.layout = { offset: w.offset.clone(), euler: { x: 0, y: 0, z: 0 } }; + this._ensureLayout(w); const step = e.shiftKey ? 2 : 15; // degrees per press (Shift = fine) let handled = true; switch (e.key) { From 67150a0d449e8802e64ba8986523cf5da354817a Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sat, 20 Jun 2026 10:13:30 -0400 Subject: [PATCH 3/5] Set hand-tuned defaults: Steam float layout, Top camera, full reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Steam Controller: bake the Edit-Layout trigger/bumper positions into a profile `defaultLayout` (applied before any user override) so the popout layout matches the hand-tuned spots out of the box. - Camera: default every controller to the Top view instead of Player (still persisted, so a user's pick is remembered). - Reset all defaults: also reset the window size, which lives in the main process (window-state.json), not localStorage — new reset-window-size IPC drops the persisted size and snaps the window back to the mode default. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/overlay/electron/main.js | 30 ++++++++++++++++--- apps/overlay/electron/preload.js | 3 ++ apps/overlay/src/js/app.js | 13 ++++---- packages/visualizer/src/controller-overlay.js | 8 +++-- .../visualizer/src/controller-profiles.js | 14 +++++++++ 5 files changed, 56 insertions(+), 12 deletions(-) diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index b65d876..b6f512c 100644 --- a/apps/overlay/electron/main.js +++ b/apps/overlay/electron/main.js @@ -136,13 +136,20 @@ function openInventoryWindow() { inventoryWindow.on('closed', () => { inventoryWindow = null; }); } -function createWindow() { +// Which size profile this launch uses. Single source of truth so the window +// creation and the "reset to default" handler agree on key + default size. +function getSizeMode() { const useMulti = process.env.OVERLAY_MULTI === '1' || process.argv.includes('--multi'); + return { + stateKey: useMulti ? 'multi' : 'main', + defaultSize: useMulti ? MULTI_WINDOW_SIZE : DEFAULT_WINDOW_SIZE, + }; +} +function createWindow() { // Restore the last-used size for this mode (issue #69), falling back to the // mode's default (single = 720×540). - const stateKey = useMulti ? 'multi' : 'main'; - const defaultSize = useMulti ? MULTI_WINDOW_SIZE : DEFAULT_WINDOW_SIZE; + const { stateKey, defaultSize } = getSizeMode(); const { width, height } = sanitizeSize(readWindowState()[stateKey], defaultSize); mainWindow = new BrowserWindow({ @@ -166,7 +173,7 @@ function createWindow() { }, }); - const entry = useMulti ? 'multi.html' : 'index.html'; + const entry = stateKey === 'multi' ? 'multi.html' : 'index.html'; mainWindow.loadFile(path.join(__dirname, '..', 'src', entry)); mainWindow.once('ready-to-show', () => { @@ -293,6 +300,21 @@ app.whenReady().then(() => { mainWindow.setSize(clampedW, clampedH); }); + // "Reset all defaults" (settings panel) clears localStorage in the renderer, + // but the window size lives in window-state.json (per mode), so it can't be + // reached from there. Drop the persisted size and snap the live window back + // to the mode's default so a reset truly restores everything. + ipcMain.handle('reset-window-size', () => { + const { stateKey, defaultSize } = getSizeMode(); + const state = readWindowState(); + delete state[stateKey]; + writeWindowState(state); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setSize(defaultSize.width, defaultSize.height); + } + return defaultSize; + }); + // ── Frameless window drag (renderer grabs a non-interactive area) ── // Renderer signals start/move/end; we compute position from the live // cursor so the grab point stays pinned under the pointer regardless of diff --git a/apps/overlay/electron/preload.js b/apps/overlay/electron/preload.js index fe24fa3..b5b4d4e 100644 --- a/apps/overlay/electron/preload.js +++ b/apps/overlay/electron/preload.js @@ -14,6 +14,9 @@ contextBridge.exposeInMainWorld('electronAPI', { setWindowSize: (width, height) => ipcRenderer.send('set-window-size', { width, height }), onWindowSizeChanged: (callback) => ipcRenderer.on('window-size-changed', (_, size) => callback(size)), + // resetWindowSize() drops the persisted size and snaps the window back to the + // mode default; used by "Reset all defaults". Resolves to the default size. + resetWindowSize: () => ipcRenderer.invoke('reset-window-size'), // ── Frameless window drag ── // The renderer detects a grab on a non-interactive area and drives the diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 77a7a2b..a900045 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -1830,7 +1830,7 @@ document.getElementById('btn-close-settings').addEventListener('click', () => { // Reset every saved overlay setting (all `overlay:*` keys) and reload so the // app re-initializes from defaults. -document.getElementById('btn-reset-defaults').addEventListener('click', () => { +document.getElementById('btn-reset-defaults').addEventListener('click', async () => { if (!confirm('Reset ALL overlay settings to their defaults? This clears your saved customizations.')) return; // Clear every overlay key — both the `overlay:` settings and the // `overlay-display-prefs` blob (note: no colon, so a `overlay:` filter misses it). @@ -1840,6 +1840,9 @@ document.getElementById('btn-reset-defaults').addEventListener('click', () => { // Force the settings gear visible so a reset can never strand the user with a // hidden gear (the in-handle Ctrl+Right-Click is the other way back in). try { localStorage.removeItem('overlay-display-prefs'); } catch (e) { /* ignore */ } + // Window size lives in the main process (window-state.json), not localStorage, + // so it has to be reset over IPC. Await it so the resize lands before reload. + try { await window.electronAPI?.resetWindowSize?.(); } catch (e) { /* ignore */ } location.reload(); }); @@ -2141,14 +2144,14 @@ greenScreenToggle.addEventListener('change', applyGreenScreen); greenScreenColorInput.addEventListener('input', applyGreenScreen); // Camera presets — one selected at a time, used as calibration view. -// The last-selected preset is persisted (issue #70) so reopening the overlay -// restores the user's preferred view (e.g. Top) instead of always resetting -// to Player. +// Defaults to Top for every controller; the last-selected preset is persisted +// (issue #70) so reopening the overlay restores the user's preferred view +// instead of resetting to the default. const CAMERA_PRESETS = ['front', 'back', 'left', 'right', 'player', 'top']; const CAMERA_PRESET_KEY = 'overlay:cameraPreset'; function loadSavedCameraPreset() { const saved = localStorage.getItem(CAMERA_PRESET_KEY); - return CAMERA_PRESETS.includes(saved) ? saved : 'player'; + return CAMERA_PRESETS.includes(saved) ? saved : 'top'; } let selectedCameraPreset = loadSavedCameraPreset(); const cameraPresetBtns = document.querySelectorAll('.camera-presets button'); diff --git a/packages/visualizer/src/controller-overlay.js b/packages/visualizer/src/controller-overlay.js index f6158b1..8624907 100644 --- a/packages/visualizer/src/controller-overlay.js +++ b/packages/visualizer/src/controller-overlay.js @@ -818,9 +818,11 @@ export class ControllerOverlay { this._floatWrappers.push(wrapper); } - // Re-apply any saved user layout for this controller (the editor's provider - // is registered by the app); runs on every (re)load so switching controllers - // restores each one's saved positions. + // Apply the profile's hand-tuned default layout first (baseline), then let + // any saved user layout for this controller override it. Both run on every + // (re)load so switching controllers restores each one's positions; the + // editor's provider is registered by the app. + if (profile.defaultLayout) this.applyLayout(profile.defaultLayout); if (this._layoutProvider) { const saved = this._layoutProvider(this.controllerType); if (saved) this.applyLayout(saved); diff --git a/packages/visualizer/src/controller-profiles.js b/packages/visualizer/src/controller-profiles.js index 12a1377..2281ba9 100644 --- a/packages/visualizer/src/controller-profiles.js +++ b/packages/visualizer/src/controller-profiles.js @@ -386,6 +386,20 @@ export const PROFILES = { paddle3: { up: -0.06, back: -0.58, side: -0.13 }, paddle4: { up: -0.06, back: -0.58, side: -0.13 }, }, + // Hand-tuned pop-off placement for the triggers + bumpers (issue #75), + // captured from the Edit-Layout editor. This is the same shape the editor + // persists to localStorage and overrides floatTuning's computed offset for + // these parts: `offset` is the part's parent-local translation (model-radius + // units, already scale-divided) and `euler` is its rotation in DEGREES. The + // overlay applies this as the baseline; a user's own Edit-Layout tweaks + // (saved to localStorage) still take precedence. Parts not listed here keep + // their floatTuning offset. + defaultLayout: { + left_trigger: { offset: [0.002, 0.0295, -0.0287], euler: [65, 0, 0] }, + right_trigger: { offset: [-0.0007, 0.0295, -0.0294], euler: [65, 0, 0] }, + left_shoulder: { offset: [0.0007, 0.0098, -0.0024], euler: [65, 0, 0] }, + right_shoulder: { offset: [-0.0006, 0.0098, -0.003], euler: [65, 0, 0] }, + }, // Capacitive grip sensors (digital): glow these meshes while the grip is // held (driver parsed.grips). Highlighted via overlay.setGripState. gripMeshes: { left: 'left_gripsense', right: 'right_gripsense' }, From 21d595aa05a76b155b0432219602878b2b5a3307 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sat, 20 Jun 2026 12:16:24 -0400 Subject: [PATCH 4/5] Edit Layout: floating numeric editor popup for the selected part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-axis Position/Rotation fields lived inside the settings panel, which is closed while you drag parts in the 3D view — so the fine-tune controls were effectively invisible. Move them into a self-contained popup that appears on selection, independent of the settings panel. - Floating, draggable popup (#layout-editor-popup) shown when a part is selected; close (×) deselects, "Reset this part" clears just its override. - X/Y/Z axis labels on each Position/Rotation input; roomy, evenly-sized fields so full values stay readable as they change. - Shift + ↑/↓ in a field does a fine nudge (Position 0.001, Rotation 0.1°); plain ↑/↓ keep the coarse step. Documented on the popup. - New overlay clearLayoutSelection() backs the close button. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/overlay/src/index.html | 119 +++++++++++++----- apps/overlay/src/js/app.js | 73 +++++++++-- packages/visualizer/src/controller-overlay.js | 2 + 3 files changed, 157 insertions(+), 37 deletions(-) diff --git a/apps/overlay/src/index.html b/apps/overlay/src/index.html index 06ecc52..5f5e3a0 100644 --- a/apps/overlay/src/index.html +++ b/apps/overlay/src/index.html @@ -144,9 +144,68 @@ cursor: pointer; } - .layout-num-row { align-items: center; } - .layout-num-fields { display: flex; align-items: center; gap: 4px; } - .layout-num-fields input[type="number"] { width: 46px; } + /* Floating numeric editor for the selected Edit-Layout part. Lives OUTSIDE the + settings panel so it stays visible while the panel is closed and the user + drags parts in the 3D view (that's why the inline fields felt "missing"). + Shown on selection; draggable by its header so it never blocks the part. */ + #layout-editor-popup { + display: none; + position: fixed; + top: 38px; left: 8px; + width: 340px; + background: rgba(30,30,35,0.95); + border: 1px solid rgba(51,136,255,0.55); + border-radius: 10px; + padding: 10px 12px 12px; + box-shadow: 0 6px 24px rgba(0,0,0,0.5); + backdrop-filter: blur(10px); + z-index: 50; + font-size: 12px; + user-select: none; + } + #layout-editor-popup .lep-header { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; cursor: move; margin-bottom: 8px; + } + #layout-editor-popup .lep-title { + color: #8af; font-family: monospace; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + #layout-editor-popup .lep-close { + flex: none; width: 22px; height: 22px; line-height: 1; font-size: 14px; + border: none; border-radius: 5px; cursor: pointer; + background: rgba(255,255,255,0.08); color: #ccc; + } + #layout-editor-popup .lep-close:hover { background: rgba(255,255,255,0.2); color: #fff; } + #layout-editor-popup .lep-reset { + width: 100%; margin-top: 8px; padding: 6px; border: none; border-radius: 6px; + background: rgba(255,255,255,0.1); color: #ddd; font-size: 11px; cursor: pointer; + } + #layout-editor-popup .lep-reset:hover { background: rgba(255,255,255,0.18); } + #layout-editor-popup .lep-hint { + margin-top: 6px; font-size: 10px; color: rgba(255,255,255,0.4); line-height: 1.3; + } + /* Position/Rotation sections: a small title, then three axis-labelled inputs + that share the width evenly. Roomy enough that the full value (and its live + changes) stay readable. */ + #layout-editor-popup .lep-header { margin-bottom: 10px; } + #layout-editor-popup .lep-title { font-size: 13px; } + #layout-editor-popup .lep-section { margin-bottom: 10px; } + #layout-editor-popup .lep-section-title { color: #bbb; font-size: 13px; margin-bottom: 5px; } + #layout-editor-popup .lep-axes { display: flex; gap: 8px; } + #layout-editor-popup .lep-axis { display: flex; align-items: center; gap: 5px; flex: 1; min-width: 0; } + #layout-editor-popup .lep-ax { color: #8af; font-family: monospace; font-size: 13px; font-weight: 600; } + #layout-editor-popup input[type="number"] { + flex: 1; min-width: 0; + background: rgba(255,255,255,0.08); + color: #eee; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 5px; + padding: 7px 8px; + font-size: 14px; + text-align: right; + } + #layout-editor-popup input[type="number"]:focus { outline: none; border-color: #3388ff; } .window-size-fields { display: flex; align-items: center; gap: 4px; } .window-size-x { color: #888; font-size: 11px; } @@ -1066,33 +1125,8 @@

Edit Layout

- -
@@ -1124,6 +1158,33 @@

Diagnostics

dev build
+ +
+
+ none + +
+
+
Position
+
+ + + +
+
+
+
Rotation°
+
+ + + +
+
+ +
In a field, ↑/↓ nudges; hold Shift for fine steps (Position 0.001, Rotation 0.1°). Drag the header to move this editor. Tip: mirror left/right by negating X.
+
+
diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index a900045..2797b44 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -1963,12 +1963,13 @@ let layoutEditing = false; const editLayoutToggle = document.getElementById('edit-layout-toggle'); const layoutModeSelect = document.getElementById('layout-mode'); const resetLayoutBtn = document.getElementById('reset-layout'); -const editLayoutStatusRow = document.getElementById('edit-layout-status-row'); const editLayoutHelp = document.getElementById('edit-layout-help'); const editLayoutSelected = document.getElementById('edit-layout-selected'); // Precise numeric editor for the selected part (in addition to drag + Q/E/arrows). -const editLayoutNumeric = document.getElementById('edit-layout-numeric'); +// It's a floating popup (outside the settings panel) so it's visible while the +// panel is closed and the user is dragging parts in the 3D view. +const layoutPopup = document.getElementById('layout-editor-popup'); const layoutNumInputs = { px: document.getElementById('layout-pos-x'), py: document.getElementById('layout-pos-y'), pz: document.getElementById('layout-pos-z'), rx: document.getElementById('layout-rot-x'), ry: document.getElementById('layout-rot-y'), rz: document.getElementById('layout-rot-z'), @@ -1999,14 +2000,30 @@ function applyLayoutNumeric() { euler: { x: num(layoutNumInputs.rx), y: num(layoutNumInputs.ry), z: num(layoutNumInputs.rz) }, }); } -for (const el of Object.values(layoutNumInputs)) { - el?.addEventListener('input', applyLayoutNumeric); +for (const [key, el] of Object.entries(layoutNumInputs)) { + if (!el) continue; + el.addEventListener('input', applyLayoutNumeric); + // Shift + ↑/↓ does a fine nudge (Position 0.001, Rotation 0.1°). Native number + // inputs have no "fine step" modifier, so handle it ourselves; plain ↑/↓ keep + // the coarse `step` from the markup (0.01 / 1). + const isPos = key.startsWith('p'); + const fineStep = isPos ? 0.001 : 0.1; + const decimals = isPos ? 3 : 1; + el.addEventListener('keydown', (e) => { + if (!e.shiftKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return; + e.preventDefault(); + const cur = parseFloat(el.value) || 0; + const next = cur + (e.key === 'ArrowUp' ? fineStep : -fineStep); + el.value = next.toFixed(decimals); + applyLayoutNumeric(); + }); } -// Reflect the overlay's current selection in the settings UI. +// Reflect the overlay's current selection: pop up the numeric editor for the +// selected part, hide it when nothing is selected. function onEditSelectionChange(partName) { if (editLayoutSelected) editLayoutSelected.textContent = partName || 'none'; - if (editLayoutNumeric) editLayoutNumeric.style.display = partName ? '' : 'none'; + if (layoutPopup) layoutPopup.style.display = partName ? 'block' : 'none'; if (partName) refreshLayoutNumeric(); } @@ -2015,12 +2032,51 @@ if (editLayoutToggle) { const on = e.target.checked; layoutEditing = on; // suppress window-drag while editing if (overlay?.setEditMode) overlay.setEditMode(on); - if (editLayoutStatusRow) editLayoutStatusRow.style.display = on ? '' : 'none'; if (editLayoutHelp) editLayoutHelp.style.display = on ? '' : 'none'; - if (!on) onEditSelectionChange(null); + if (!on) onEditSelectionChange(null); // editing off → close the popup }); } +// Floating editor controls: close (×) deselects the part; "Reset this part" +// clears just its override. Both flow back through the overlay's select/layout +// handlers, which refresh the fields. +document.getElementById('lep-close')?.addEventListener('click', () => { + overlay?.clearLayoutSelection?.(); +}); +document.getElementById('lep-reset-part')?.addEventListener('click', () => { + overlay?.resetSelected?.(); + refreshLayoutNumeric(); +}); + +// Let the user drag the popup by its header so it never blocks the part being +// edited. Window-drag is already suppressed while editing (layoutEditing), so +// this can't fight the OS window move. +(function makeLayoutPopupDraggable() { + const handle = document.getElementById('lep-drag'); + if (!layoutPopup || !handle) return; + let dragging = false, offX = 0, offY = 0; + handle.addEventListener('pointerdown', (e) => { + if (e.target.closest('button')) return; // the close button isn't a drag grip + dragging = true; + const r = layoutPopup.getBoundingClientRect(); + offX = e.clientX - r.left; + offY = e.clientY - r.top; + try { handle.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ } + e.preventDefault(); + }); + handle.addEventListener('pointermove', (e) => { + if (!dragging) return; + layoutPopup.style.left = Math.max(0, e.clientX - offX) + 'px'; + layoutPopup.style.top = Math.max(0, e.clientY - offY) + 'px'; + }); + const stop = (e) => { + dragging = false; + try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ } + }; + handle.addEventListener('pointerup', stop); + handle.addEventListener('pointercancel', stop); +})(); + if (layoutModeSelect) { layoutModeSelect.value = localStorage.getItem('overlay:layoutMode') || 'relative'; layoutModeSelect.addEventListener('change', (e) => { @@ -2032,6 +2088,7 @@ if (layoutModeSelect) { if (resetLayoutBtn) { resetLayoutBtn.addEventListener('click', () => { if (overlay?.resetLayout) overlay.resetLayout(); + refreshLayoutNumeric(); // keep the popup in sync if a part is still selected }); } diff --git a/packages/visualizer/src/controller-overlay.js b/packages/visualizer/src/controller-overlay.js index 8624907..c6ee409 100644 --- a/packages/visualizer/src/controller-overlay.js +++ b/packages/visualizer/src/controller-overlay.js @@ -1036,6 +1036,8 @@ export class ControllerOverlay { resetSelected() { if (this._selectedWrapper) { this._selectedWrapper.layout = null; this._emitLayout(); } } + /** Deselect the current part (used by the floating editor's close button). */ + clearLayoutSelection() { this._selectWrapper(null); } _emitLayout() { if (this._onLayoutChange) this._onLayoutChange(this.getLayout()); } From a1d585c28f58af8f3cb18312f4edb6125ec3174c Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sat, 20 Jun 2026 12:27:47 -0400 Subject: [PATCH 5/5] Edit Layout: reset a part to its baked default, not floatTuning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Reset this part" / "RESET LAYOUT" cleared the override to null, which falls back to the computed floatTuning offset — a different spot than the hand-tuned defaultLayout shown when Edit Layout opens. Restore the position editing started from instead: the profile's defaultLayout if it lists the part (Steam triggers/bumpers), otherwise null → floatTuning (paddles, other controllers). So reset undoes the user's tweaks rather than jumping elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/visualizer/src/controller-overlay.js | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/visualizer/src/controller-overlay.js b/packages/visualizer/src/controller-overlay.js index c6ee409..525bf01 100644 --- a/packages/visualizer/src/controller-overlay.js +++ b/packages/visualizer/src/controller-overlay.js @@ -1029,12 +1029,29 @@ export class ControllerOverlay { /** Clear all per-part overrides back to the profile defaults. */ resetLayout() { - for (const w of this._floatWrappers) w.layout = null; + for (const w of this._floatWrappers) this._restoreWrapperDefault(w); this._emitLayout(); } /** Clear just the selected part's override. */ resetSelected() { - if (this._selectedWrapper) { this._selectedWrapper.layout = null; this._emitLayout(); } + if (this._selectedWrapper) { this._restoreWrapperDefault(this._selectedWrapper); this._emitLayout(); } + } + + // Reset a wrapper to the position it had BEFORE any user edit — i.e. the spot + // shown when Edit Layout opens. That's the profile's hand-tuned defaultLayout + // if it lists this part (Steam triggers/bumpers), otherwise null so it falls + // back to the computed floatTuning offset. So "reset" undoes the user's tweaks + // rather than jumping to a different procedural value. + _restoreWrapperDefault(w) { + const def = PROFILES[this.controllerType]?.defaultLayout?.[w.partName]; + if (def && Array.isArray(def.offset)) { + w.layout = { + offset: new THREE.Vector3(def.offset[0] || 0, def.offset[1] || 0, def.offset[2] || 0), + euler: { x: def.euler?.[0] || 0, y: def.euler?.[1] || 0, z: def.euler?.[2] || 0 }, + }; + } else { + w.layout = null; + } } /** Deselect the current part (used by the floating editor's close button). */ clearLayoutSelection() { this._selectWrapper(null); }