diff --git a/apps/overlay/src/index.html b/apps/overlay/src/index.html index 5f5e3a0..da5126e 100644 --- a/apps/overlay/src/index.html +++ b/apps/overlay/src/index.html @@ -124,6 +124,16 @@ .setting-row button:hover { background: rgba(255,255,255,0.15); } + /* The select's translucent dark bg + light text looks right on the panel, + but the OPEN option list is drawn by the OS — on Windows it came up + light-mode, leaving unselected options as light text on a white popup + (unreadable). `color-scheme: dark` renders the native popup dark; the + explicit option colors are a solid-background fallback across platforms. */ + .setting-row select { color-scheme: dark; } + .setting-row select option { + background: #26262c; + color: #eee; + } .setting-row input[type="color"] { width: 32px; @@ -1045,15 +1055,15 @@

Window Display

- +
- +
- +
diff --git a/packages/visualizer/src/controller-overlay.js b/packages/visualizer/src/controller-overlay.js index 525bf01..3cad87e 100644 --- a/packages/visualizer/src/controller-overlay.js +++ b/packages/visualizer/src/controller-overlay.js @@ -28,6 +28,14 @@ const PRESS_GLOW = 1.5; // between render frames still shows a noticeably-sized circle, then keeps // expanding to full while held. const TRACKPAD_FILL_MIN = 0.45; +// Grip-sense bars sit at this inert dark grey at rest and brighten to the grip +// color while that side is gripped (issue #74 — activation should read on the +// bar itself, not just the handle glow). +const GRIP_BAR_REST_COLOR = 0x44444c; +// Emissive intensity of a grip-sense BAR while its side is gripped. Fixed on +// purpose — the Grip Glow Brightness slider drives the GLOW only, so the bar and +// the glow stay independently defined. +const GRIP_BAR_LIT_EMISSIVE = 0.6; const FLOAT_ZERO = new THREE.Vector3(); // shared read-only lerp target (parts seated) export class ControllerOverlay { @@ -583,6 +591,10 @@ export class ControllerOverlay { this._repositionGripBars(profile); // Honor the current "show grip bars" setting on this freshly-loaded model. this._applyGripBarsVisible(); + // Set the bars to their inert rest grey + clone their materials (they're not + // in a theme group, so this is the only thing that colors them); setGripState + // then lights them to the grip color while a side is gripped. + this._applyGripBarColor(); // Diagnostic: per-mapping check at load time so we can tell whether // each gamepad-index → mesh path will animate at runtime. The press @@ -2011,40 +2023,57 @@ export class ControllerOverlay { /** * Highlight the capacitive grip sensors while held (digital on/off). Two - * visuals: (1) emissive glow on the grip meshes — only seen when the back of - * the controller faces the camera; and (2) a billboard glow marker per grip - * rendered ON TOP (depthTest off, at the top of the controller) so grip state - * reads at ANY angle. Toggle both via setGripVisible. Intensity lerps. + * INDEPENDENT indicators, each with its own toggle: + * • GLOW — an on-top billboard/cylinder marker per grip, rendered with + * depthTest off so it reads at ANY angle. Peak opacity = the Grip Glow + * Brightness setting. Gated by setGripVisible (_gripEnabled). + * • BARS — the left/right side strip meshes. They ease from a rest grey to + * the grip color (with a fixed emissive lift) while that side is gripped, + * so the bar itself shows activation (issue #74). Gated by + * setGripBarsVisible (_gripBarsVisible); brightness does NOT affect them. * @param {{left:boolean, right:boolean}} grips */ setGripState(grips) { const map = PROFILES[this.controllerType]?.gripMeshes; if (!map || !grips) return; + const gripCol = this._gripColorC || (this._gripColorC = new THREE.Color()); + gripCol.setHex(this._gripColor); + const restCol = this._gripBarRestC || (this._gripBarRestC = new THREE.Color(GRIP_BAR_REST_COLOR)); + const black = this._gripBlackC || (this._gripBlackC = new THREE.Color(0x000000)); for (const side of ['left', 'right']) { const gripped = !!grips[side]; - // The bars and the on-top glow markers are INDEPENDENT: - // - bars highlight on touch whenever the bars are shown (their own glow), - // - the on-top markers are the separate, toggleable "glow". - const barOn = this._gripBarsVisible && gripped; - const glowOn = this._gripEnabled && gripped; - // (1) bar mesh emissive highlight (on touch, if the bars are visible) - const obj = this.meshes[map[side]]; - const mesh = obj && (obj.isMesh ? obj : obj.children?.find((c) => c.isMesh)); - const mat = mesh?.material; - if (mat && 'emissive' in mat) { - mat.emissive.set(this._gripColor); // set each frame so a color change applies live - // Bar glow scales with the brightness slider too, so it can reach full - // brightness at max (and stays subtle at the lower default). - const barTarget = barOn ? this._gripBrightness * 1.5 : 0; - mat.emissiveIntensity = THREE.MathUtils.lerp(mat.emissiveIntensity, barTarget, LERP_SPEED); - } - // (2) always-on-top billboard glow marker (brightness = peak opacity) + // On-top billboard glow marker — the handle activation cue. Brightness + // sets its peak opacity; lerps in/out. Gated by the glow toggle. const marker = this._gripMarkers?.[side]; - if (marker) marker.material.opacity = THREE.MathUtils.lerp(marker.material.opacity, glowOn ? this._gripBrightness : 0, LERP_SPEED); + if (marker) { + marker.material.opacity = THREE.MathUtils.lerp( + marker.material.opacity, (this._gripEnabled && gripped) ? this._gripBrightness : 0, LERP_SPEED + ); + } + // Bar mesh — ease toward the grip color (lit) while that side is gripped, + // back to the inert rest grey when released, so the bar itself reads the + // activation (issue #74). Gated by the bars toggle, independent of glow. + const barActive = this._gripBarsVisible && gripped; + const bar = this._gripBarMesh(map[side]); + if (bar?.material?.color) { + bar.material.color.lerp(barActive ? gripCol : restCol, LERP_SPEED); + if ('emissive' in bar.material) { + bar.material.emissive.lerp(barActive ? gripCol : black, LERP_SPEED); + bar.material.emissiveIntensity = THREE.MathUtils.lerp( + bar.material.emissiveIntensity ?? 0, barActive ? GRIP_BAR_LIT_EMISSIVE : 0, LERP_SPEED + ); + } + } } } - /** Toggle the grip-sense glow (mesh emissive + on-top markers). */ + // The Mesh that carries a grip-bar's material (the GLB node may wrap it). + _gripBarMesh(name) { + const obj = this.meshes[name]; + return obj && (obj.isMesh ? obj : obj.children?.find((c) => c.isMesh)) || null; + } + + /** Toggle the grip-sense GLOW (the on-top marker only — not the bars). */ setGripVisible(enabled) { this._gripEnabled = !!enabled; } @@ -2081,12 +2110,39 @@ export class ControllerOverlay { this._gripBrightness = Math.max(0, Math.min(1, value)); } - /** Grip highlight color (markers + mesh glow); CSS hex e.g. '#3388ff'. */ + /** Grip highlight color (markers + the bar's lit state); CSS hex e.g. '#3388ff'. */ setGripColor(hexColor) { this._gripColor = new THREE.Color(hexColor).getHex(); if (this._gripMarkers) { for (const side of ['left', 'right']) this._gripMarkers[side]?.material.color.setHex(this._gripColor); } + // The bars' rest grey is independent of the grip color; setGripState picks + // up the new _gripColor for the lit state on the next report. + } + + // Reset the grip-sense BAR meshes to their inert rest appearance (grey, no + // emissive); setGripState lights them to the grip color while gripped. The + // bars are excluded from the body/accent theme groups (PR #76) — otherwise + // they'd take the body color and vanish into it — so they're driven here. + // Each bar's material is cloned once so left/right light up independently + // (GLB models often share one material across both meshes). + _applyGripBarColor() { + const map = PROFILES[this.controllerType]?.gripMeshes; + if (!map) return; + for (const side of ['left', 'right']) { + const mesh = this._gripBarMesh(map[side]); + if (!mesh?.material?.color) continue; + if (!mesh.material._gripCloned) { + mesh.material = mesh.material.clone(); + mesh.material._gripCloned = true; + } + mesh.material.color.setHex(GRIP_BAR_REST_COLOR); + if ('emissive' in mesh.material) { + mesh.material.emissive.setHex(0x000000); + mesh.material.emissiveIntensity = 0; + } + mesh.material.needsUpdate = true; + } } // Set up the on-top grip glow markers. Computes each marker's pinned handle @@ -2165,6 +2221,27 @@ export class ControllerOverlay { const w = this._gripGlowWidth; const len = this._gripGlowLength; const isCircle = w === 1 && len === 1; + // Yaw (about the vertical Y axis) that swings each glow's front (+Z, the + // bumper/trigger end) toward the controller's centerline — see it in the Top + // view. Per-profile knob in degrees; sign flips per side below. + const yaw = THREE.MathUtils.degToRad(PROFILES[this.controllerType]?.gripMarkerYaw ?? 0); + // Band texture for the cylinder glow: uniform AROUND the tube (so the color + // wraps the whole circumference, not just one facing strip like the radial + // texture did), and a bright plateau filling MOST of the length with just a + // soft fade at the very ends. The Length slider scales the cylinder height, + // so this keeps the glow filling ~the whole cylinder (max Length ≈ the full + // handle) instead of only a short middle band. Maps v (0..1) → height. + if (!this._barGlowTexture) { + const c = document.createElement('canvas'); c.width = 4; c.height = 64; + const ctx = c.getContext('2d'); + const g = ctx.createLinearGradient(0, 0, 0, 64); + g.addColorStop(0.00, 'rgba(255,255,255,0)'); + g.addColorStop(0.10, 'rgba(255,255,255,0.95)'); + g.addColorStop(0.90, 'rgba(255,255,255,0.95)'); + g.addColorStop(1.00, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; ctx.fillRect(0, 0, 4, 64); + this._barGlowTexture = new THREE.CanvasTexture(c); + } const markers = {}; for (const side of ['left', 'right']) { const spec = specs[side]; @@ -2184,20 +2261,26 @@ export class ControllerOverlay { const d = thin * 0.9; obj.scale.set(d, d, 1); } else { - // NORMAL blending (not additive): additive adds the glow to whatever's - // behind it, so it washed out over the bright white body but popped over - // the dark trackpads. Normal blending shows the grip color consistently - // regardless of what's behind/in front (fully solid at full brightness). + // Read as a soft GLOW that's still visible over the light handles. The + // band texture wraps the FULL circumference (color all the way around the + // tube, consistent from any angle) and fills most of the length, fading + // softly only at the very ends. NORMAL blending keeps + // it visible on any background. (Pure additive looked like a real glow + // but vanished over the bright body — additive adds to the destination, + // and there's no headroom over near-white, so it only showed over dark + // areas. A radial texture only lit one facing strip of the tube.) const mat = new THREE.MeshBasicMaterial({ + map: this._barGlowTexture, color: this._gripColor, transparent: true, opacity: 0, blending: THREE.NormalBlending, depthTest: false, depthWrite: false, }); // Cylinder: radius ≤ half the handle's thin cross-section; height ≤ the // handle's Z length. Built along Y, rotated so its axis runs - // front-to-back (model Z, the handle's long axis). + // front-to-back (model Z, the handle's long axis). Open-ended (no cap + // disks) so the band fades cleanly at the ends with no flat cap showing. const radius = (thin * 0.45) * (w / 5); const height = (spec.dimZ * 1.35) * (len / 5); // ~50% longer than before - const geo = new THREE.CylinderGeometry(radius, radius, height, 20); + const geo = new THREE.CylinderGeometry(radius, radius, height, 20, 1, true); // Lay the cylinder front-to-back (axis → Z), then tilt it so the back // (−Z) end rises ~1/3 of its length, angling it inline with the handle. // asin(2/3): with a centre pivot the end rises (height/2)·(2/3) = height/3. @@ -2209,6 +2292,10 @@ export class ControllerOverlay { obj.renderOrder = 10; // draw after the model so it sits on top obj.position.copy(spec.pos); obj.position.y += yLift; + // Angle the bumper/trigger end inward toward center. Sign mirrors per + // side: left handle (−X) yaws −, right handle (+X) yaws +. No-op on the + // circle sprite (it billboards). + obj.rotation.y = (spec.pos.x < 0 ? -1 : 1) * yaw; this.bodyGroup.add(obj); markers[side] = obj; } diff --git a/packages/visualizer/src/controller-profiles.js b/packages/visualizer/src/controller-profiles.js index 2281ba9..13361c5 100644 --- a/packages/visualizer/src/controller-profiles.js +++ b/packages/visualizer/src/controller-profiles.js @@ -412,14 +412,24 @@ export const PROFILES = { // Lift the glow up a little into the handle body (fraction toward the top). gripMarkerHeight: 0.1, // Nudge each glow outward into the handle (away from the body center), as a - // fraction of the model width. - gripMarkerSideOffset: 0.06, - // Two-tone (ceski/Larf SC2 look, issue #61): light body = shells + grips - // only. Everything else is dark accent — the trackpads, the system buttons + // fraction of the model width. Pushed farther out (was 0.06) so the glows + // sit over the controller handles where the hands grip, instead of bunched + // near the center (PR #76 feedback). + gripMarkerSideOffset: 0.14, + // Yaw each glow about the vertical axis (degrees) so its front (bumper/ + // trigger) end angles toward the controller's centerline — best seen in the + // Top view. Sign is applied per side in code (left/right mirror). + gripMarkerYaw: 18, + // Two-tone (ceski/Larf SC2 look, issue #61): light body = shells only. + // The grip-sense bars (left/right_gripsense) are deliberately NOT in a + // theme group — they're driven on their own (a rest grey that lights to the + // grip color while that side is gripped) so they read as the grip-sense + // indicator instead of disappearing into the body color (PR #76, #74). + // Everything else is dark accent — the trackpads, the system buttons // (view/menu/steam + the "…" quick-access), and the controls (sticks, face // buttons, dpad, shoulders, triggers, paddles). bodyColorMeshes: [ - 'top_shell', 'bottom_shell', 'left_gripsense', 'right_gripsense', + 'top_shell', 'bottom_shell', ], accentColorMeshes: [ 'misc1', 'misc2', 'touchpad', 'touch_point1', 'touch_point2',