This commit is contained in:
math-pixel
2026-06-02 16:31:36 +02:00
parent 2c194cdd2e
commit 193fc8b4b6
6 changed files with 1135 additions and 256 deletions
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import * as THREE from "three";
import type { Vector3Tuple } from "@/types/three/three";
import "@/types/ebike/ebikeWindow";
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
export interface EbikeSpeedmeterProps {
width?: number;
height?: number;
/** Local position offset within the parent group. Default: [0, 0, 0] */
position?: Vector3Tuple;
/**
* Needle rotation.z when speedFactor = 0.
* Default: Math.PI / 2 (pointing left — 9 o'clock)
*/
minAngle?: number;
/**
* Needle rotation.z when speedFactor = 1.
* Default: -Math.PI / 2 (pointing right — 3 o'clock)
*/
maxAngle?: number;
renderOrder?: number;
/**
* Inner radius of the gauge-fill arc, as a fraction of the canvas half-width.
* Tune this to align the fill with the cadran.png track. Default: 0.33
*/
gaugeInnerR?: number;
/**
* Outer radius of the gauge-fill arc, as a fraction of the canvas half-width.
* Tune this to align the fill with the cadran.png track. Default: 0.445
*/
gaugeOuterR?: number;
/**
* Width of the gauge-fill plane. Defaults to `width` when omitted.
* Lets you resize the fill independently of the cadran/needle.
*/
gaugeWidth?: number;
/**
* Height of the gauge-fill plane. Defaults to `height` when omitted.
* Lets you resize the fill independently of the cadran/needle.
*/
gaugeHeight?: number;
/**
* Horizontal offset of the arc pivot from the canvas centre.
* Expressed as a fraction of the canvas size: -0.1 = shift 10 % to the left,
* +0.1 = shift 10 % to the right. Default: 0
*/
gaugeOffsetX?: number;
/**
* Vertical offset of the arc pivot from its default position.
* Expressed as a fraction of the canvas size: -0.1 = shift upward (toward top
* of the plane), +0.1 = shift downward. Default: 0
*/
gaugeOffsetY?: number;
}
// The needle pivot is always at -height*0.38 in local space,
// which is always 12 % from the bottom of the plane (UV y = 0.12).
// With Three.js flipY texture convention, canvas y = (1 - 0.12) * size = 0.88 * size.
const NEEDLE_PIVOT_UV_Y = 0.12; // fraction from bottom
export function EbikeSpeedmeter({
width = 0.8,
height = 0.8,
position = [0, 0, 0],
minAngle = Math.PI / 2,
maxAngle = -Math.PI / 2,
renderOrder = 1000,
gaugeInnerR = 0.33,
gaugeOuterR = 0.445,
gaugeWidth,
gaugeHeight,
gaugeOffsetX = 0,
gaugeOffsetY = 0,
}: EbikeSpeedmeterProps): React.JSX.Element {
// Fall back to the main dimensions when gauge-specific ones aren't provided
const fillW = gaugeWidth ?? width;
const fillH = gaugeHeight ?? height;
const needleGroupRef = useRef<THREE.Group>(null);
const speedFactorRef = useRef(0);
// ── Dial & needle textures ──────────────────────────────────────────────────
const [dialTexture, needleTexture] = useTexture([
SPEEDOMETER_DIAL_TEXTURE,
SPEEDOMETER_NEEDLE_TEXTURE,
]) as [THREE.Texture, THREE.Texture];
const needleWidth = width * 0.68;
const needleHeight = needleWidth / 2;
useEffect(() => {
[dialTexture, needleTexture].forEach((tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
tex.needsUpdate = true;
});
}, [dialTexture, needleTexture]);
// ── Gauge-fill canvas ───────────────────────────────────────────────────────
const fillCanvas = useMemo(() => {
const c = document.createElement("canvas");
c.width = 256;
c.height = 256;
return c;
}, []);
const fillTexture = useMemo(() => {
const tex = new THREE.CanvasTexture(fillCanvas);
tex.format = THREE.RGBAFormat;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
return tex;
}, [fillCanvas]);
useEffect(
() => () => {
fillTexture.dispose();
},
[fillTexture],
);
// ── Frame loop ──────────────────────────────────────────────────────────────
useFrame((_, delta) => {
// 1. Smooth speed factor
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
speedFactorRef.current = THREE.MathUtils.lerp(
speedFactorRef.current,
target,
Math.min(1, delta * 10),
);
// 2. Needle rotation
if (needleGroupRef.current) {
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
minAngle,
maxAngle,
speedFactorRef.current,
);
}
// 3. Draw gauge fill -------------------------------------------------------
const ctx = fillCanvas.getContext("2d", { alpha: true });
if (!ctx) return;
const size = fillCanvas.width;
ctx.clearRect(0, 0, size, size);
// Default centre: horizontal middle + needle-pivot height.
// gaugeOffsetX/Y shift the pivot so the arc aligns with cadran.png.
const cx = size * (0.5 + gaugeOffsetX);
const cy = size * ((1 - NEEDLE_PIVOT_UV_Y) + gaugeOffsetY); // default ≈ 0.88 × size
const outerR = size * gaugeOuterR;
const innerR = size * gaugeInnerR;
// Arc sweeps clockwise from π (left) to current needle angle
const arcStart = Math.PI;
const arcEnd = Math.PI + speedFactorRef.current * Math.PI;
if (speedFactorRef.current > 0.005) {
// Radial gradient using #3F67DD — slightly transparent at inner edge,
// fully solid at outer edge for a depth effect.
const radial = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
radial.addColorStop(0.7, "rgba(118, 152, 255, 0.95)"); // outer edge
// Annular sector shape (outer arc + inner arc reversed)
ctx.beginPath();
ctx.arc(cx, cy, outerR, arcStart, arcEnd, false);
ctx.arc(cx, cy, innerR, arcEnd, arcStart, true);
ctx.closePath();
ctx.fillStyle = radial;
ctx.shadowBlur = 16;
ctx.shadowColor = "#3F67DD";
ctx.fill();
ctx.shadowBlur = 0;
}
fillTexture.needsUpdate = true;
});
return (
<group renderOrder={renderOrder} position={position}>
{/* Gauge fill — behind the cadran frame (size controlled by gaugeWidth/gaugeHeight) */}
<mesh renderOrder={renderOrder - 1} position={[0, 0, -0.001]}>
<planeGeometry args={[fillW, fillH]} />
<meshBasicMaterial
map={fillTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
{/* Dial frame (cadran.png) */}
<mesh renderOrder={renderOrder}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={dialTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
{/* Needle — pivot at bottom-centre of the arc */}
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]} rotation={[0, 0, 0]}>
<mesh
position={[0, needleHeight / 2, 0]}
renderOrder={renderOrder + 1}
>
<planeGeometry args={[needleWidth, needleHeight]} />
<meshBasicMaterial
map={needleTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
</group>
</group>
);
}