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/index.html b/apps/overlay/src/index.html
index 7fa704c..5f5e3a0 100644
--- a/apps/overlay/src/index.html
+++ b/apps/overlay/src/index.html
@@ -144,6 +144,69 @@
cursor: pointer;
}
+ /* 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; }
.setting-row input[type="number"] {
@@ -1062,12 +1125,8 @@
- Click a part to select. Drag to move. Rotate with arrows (pitch/yaw), Q/E (roll); Shift = fine, R = reset rotation, Esc = deselect.
+ Click a part to select — a numeric editor pops up. Drag to move, or type exact values. Rotate with arrows (pitch/yaw), Q/E (roll); Shift = fine, R = reset rotation, Esc = deselect.
diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js
index 63e2f53..2797b44 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');
@@ -1828,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).
@@ -1838,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();
});
@@ -1958,13 +1963,68 @@ 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');
-// Reflect the overlay's current selection in the settings UI.
+// Precise numeric editor for the selected part (in addition to drag + Q/E/arrows).
+// 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'),
+};
+
+// 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 [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: 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 (layoutPopup) layoutPopup.style.display = partName ? 'block' : 'none';
+ if (partName) refreshLayoutNumeric();
}
if (editLayoutToggle) {
@@ -1972,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) => {
@@ -1989,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
});
}
@@ -2101,14 +2201,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 5fd4f29..525bf01 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);
@@ -1027,16 +1029,113 @@ 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); }
+
_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 +1206,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 +1232,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) {
diff --git a/packages/visualizer/src/controller-profiles.js b/packages/visualizer/src/controller-profiles.js
index de7f963..2281ba9 100644
--- a/packages/visualizer/src/controller-profiles.js
+++ b/packages/visualizer/src/controller-profiles.js
@@ -367,18 +367,38 @@ 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 },
+ },
+ // 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.