From 7ad646e0f840e2e8b6004a5bb0e18306cac62011 Mon Sep 17 00:00:00 2001 From: Daniel Massey Date: Sat, 13 Jun 2026 12:08:25 +0200 Subject: [PATCH 1/4] fix for ray origin compute error --- src/core/compute-ray.ts | 19 +++++++++++++++++ src/core/events.ts | 8 ++------ src/test/compute-ray.test.ts | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 src/core/compute-ray.ts create mode 100644 src/test/compute-ray.test.ts diff --git a/src/core/compute-ray.ts b/src/core/compute-ray.ts new file mode 100644 index 0000000..063e719 --- /dev/null +++ b/src/core/compute-ray.ts @@ -0,0 +1,19 @@ +import { Matrix4, Ray } from "three"; + +/** + * Builds a raycaster ray from the pointer position by unprojecting NDC points + * through the inverted projection*view matrix. + * + * Both the origin (near plane) and direction (towards the far plane) are derived + * from the pointer, so the resulting ray is the geometrically correct line + * through the camera eye for any cursor position — the same near→far scheme + * three.js uses in `Raycaster.setFromCamera`. + */ +export function computeRay(ray: Ray, pointerX: number, pointerY: number, projViewInv: Matrix4) { + ray.origin.set(pointerX, pointerY, -1).applyMatrix4(projViewInv); + ray.direction + .set(pointerX, pointerY, 1) + .applyMatrix4(projViewInv) + .sub(ray.origin) + .normalize(); +} diff --git a/src/core/events.ts b/src/core/events.ts index 3eae86e..b7a7aab 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -1,5 +1,6 @@ import { Canvas, events as fiberEvents } from "@react-three/fiber"; import { Matrix4 } from "three"; +import { computeRay } from "./compute-ray"; /** projection * view matrix inverted */ const projViewInv = new Matrix4() @@ -22,12 +23,7 @@ export const events: Events = (store) => { if (state.camera.userData.projByViewInv) projViewInv.fromArray(state.camera.userData.projByViewInv); state.raycaster.camera = state.camera; - state.raycaster.ray.origin.setScalar(0).applyMatrix4(projViewInv); - state.raycaster.ray.direction - .set(state.pointer.x, state.pointer.y, 1) - .applyMatrix4(projViewInv) - .sub(state.raycaster.ray.origin) - .normalize(); + computeRay(state.raycaster.ray, state.pointer.x, state.pointer.y, projViewInv); }, }; diff --git a/src/test/compute-ray.test.ts b/src/test/compute-ray.test.ts new file mode 100644 index 0000000..2bfc28d --- /dev/null +++ b/src/test/compute-ray.test.ts @@ -0,0 +1,40 @@ +import { Matrix4, PerspectiveCamera, Ray, Vector3 } from "three"; +import { expect, it } from "vitest"; +import { computeRay } from "../core/compute-ray"; + +/** + * For a perspective camera every pointer ray must pass through the camera eye. + * `computeRay` satisfies this for any cursor position by deriving the origin + * from the pointer; the previous fixed origin (`setScalar(0)`) only did so at + * the dead centre of the view, which is the hover/raycast offset bug. + */ +it("computeRay builds a ray that passes through the perspective camera eye", () => { + const camera = new PerspectiveCamera(60, 1.5, 0.1, 100); + camera.position.set(3, 4, 5); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + camera.updateProjectionMatrix(); + + // projection * view, inverted — the matrix events.ts unprojects through + const projViewInv = new Matrix4() + .multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse) + .invert(); + + const ray = new Ray(); + // an off-centre pointer, where the old fixed origin was wrong + computeRay(ray, 0.6, -0.3, projViewInv); + + // direction is unit length + expect(ray.direction.length()).toBeCloseTo(1, 6); + + // eye is collinear with the ray: (eye - origin) is parallel to direction + const eye = camera.position.clone(); + const toEye = eye.clone().sub(ray.origin).normalize(); + expect(Math.abs(toEye.dot(ray.direction))).toBeCloseTo(1, 5); + + // regression guard: the old origin (unprojected NDC centre) is NOT on the + // ray, so it produced a skewed ray that missed the eye. + const oldOrigin = new Vector3().setScalar(0).applyMatrix4(projViewInv); + const toEyeOld = eye.clone().sub(oldOrigin).normalize(); + expect(Math.abs(toEyeOld.dot(ray.direction))).toBeLessThan(0.999); +}); From 88c8f6b1e41938997dd7a127670488e99fe722b3 Mon Sep 17 00:00:00 2001 From: Daniel Massey Date: Sat, 13 Jun 2026 14:57:24 +0200 Subject: [PATCH 2/4] adding changeset --- .changeset/fix-pointer-ray-origin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-pointer-ray-origin.md diff --git a/.changeset/fix-pointer-ray-origin.md b/.changeset/fix-pointer-ray-origin.md new file mode 100644 index 0000000..826a9d5 --- /dev/null +++ b/.changeset/fix-pointer-ray-origin.md @@ -0,0 +1,5 @@ +--- +"react-three-map": patch +--- + +Fix hover/raycast inaccuracy by deriving the pointer-event ray origin from the cursor position. The ray origin was being unprojected from a fixed NDC point that ignored the cursor, producing a skewed ray whose error grew towards the edges of the map. From 8425f0cee71e9dcf223160b0d78cfe2ae3a2f321 Mon Sep 17 00:00:00 2001 From: Rodri Date: Fri, 26 Jun 2026 21:50:57 +0100 Subject: [PATCH 3/4] simplify --- .changeset/fix-pointer-ray-origin.md | 2 +- src/core/compute-ray.ts | 19 ------------- src/core/events.ts | 8 ++++-- src/test/compute-ray.test.ts | 40 ---------------------------- 4 files changed, 7 insertions(+), 62 deletions(-) delete mode 100644 src/core/compute-ray.ts delete mode 100644 src/test/compute-ray.test.ts diff --git a/.changeset/fix-pointer-ray-origin.md b/.changeset/fix-pointer-ray-origin.md index 826a9d5..4fda7cb 100644 --- a/.changeset/fix-pointer-ray-origin.md +++ b/.changeset/fix-pointer-ray-origin.md @@ -2,4 +2,4 @@ "react-three-map": patch --- -Fix hover/raycast inaccuracy by deriving the pointer-event ray origin from the cursor position. The ray origin was being unprojected from a fixed NDC point that ignored the cursor, producing a skewed ray whose error grew towards the edges of the map. +Fix hover/raycast inaccuracy by deriving the pointer-event ray origin from the cursor position. diff --git a/src/core/compute-ray.ts b/src/core/compute-ray.ts deleted file mode 100644 index 063e719..0000000 --- a/src/core/compute-ray.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Matrix4, Ray } from "three"; - -/** - * Builds a raycaster ray from the pointer position by unprojecting NDC points - * through the inverted projection*view matrix. - * - * Both the origin (near plane) and direction (towards the far plane) are derived - * from the pointer, so the resulting ray is the geometrically correct line - * through the camera eye for any cursor position — the same near→far scheme - * three.js uses in `Raycaster.setFromCamera`. - */ -export function computeRay(ray: Ray, pointerX: number, pointerY: number, projViewInv: Matrix4) { - ray.origin.set(pointerX, pointerY, -1).applyMatrix4(projViewInv); - ray.direction - .set(pointerX, pointerY, 1) - .applyMatrix4(projViewInv) - .sub(ray.origin) - .normalize(); -} diff --git a/src/core/events.ts b/src/core/events.ts index b7a7aab..c3582de 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -1,6 +1,5 @@ import { Canvas, events as fiberEvents } from "@react-three/fiber"; import { Matrix4 } from "three"; -import { computeRay } from "./compute-ray"; /** projection * view matrix inverted */ const projViewInv = new Matrix4() @@ -23,7 +22,12 @@ export const events: Events = (store) => { if (state.camera.userData.projByViewInv) projViewInv.fromArray(state.camera.userData.projByViewInv); state.raycaster.camera = state.camera; - computeRay(state.raycaster.ray, state.pointer.x, state.pointer.y, projViewInv); + state.raycaster.ray.origin.set(state.pointer.x, state.pointer.y, -1).applyMatrix4(projViewInv); + state.raycaster.ray.direction + .set(state.pointer.x, state.pointer.y, 1) + .applyMatrix4(projViewInv) + .sub(state.raycaster.ray.origin) + .normalize(); }, }; diff --git a/src/test/compute-ray.test.ts b/src/test/compute-ray.test.ts deleted file mode 100644 index 2bfc28d..0000000 --- a/src/test/compute-ray.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Matrix4, PerspectiveCamera, Ray, Vector3 } from "three"; -import { expect, it } from "vitest"; -import { computeRay } from "../core/compute-ray"; - -/** - * For a perspective camera every pointer ray must pass through the camera eye. - * `computeRay` satisfies this for any cursor position by deriving the origin - * from the pointer; the previous fixed origin (`setScalar(0)`) only did so at - * the dead centre of the view, which is the hover/raycast offset bug. - */ -it("computeRay builds a ray that passes through the perspective camera eye", () => { - const camera = new PerspectiveCamera(60, 1.5, 0.1, 100); - camera.position.set(3, 4, 5); - camera.lookAt(0, 0, 0); - camera.updateMatrixWorld(true); - camera.updateProjectionMatrix(); - - // projection * view, inverted — the matrix events.ts unprojects through - const projViewInv = new Matrix4() - .multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse) - .invert(); - - const ray = new Ray(); - // an off-centre pointer, where the old fixed origin was wrong - computeRay(ray, 0.6, -0.3, projViewInv); - - // direction is unit length - expect(ray.direction.length()).toBeCloseTo(1, 6); - - // eye is collinear with the ray: (eye - origin) is parallel to direction - const eye = camera.position.clone(); - const toEye = eye.clone().sub(ray.origin).normalize(); - expect(Math.abs(toEye.dot(ray.direction))).toBeCloseTo(1, 5); - - // regression guard: the old origin (unprojected NDC centre) is NOT on the - // ray, so it produced a skewed ray that missed the eye. - const oldOrigin = new Vector3().setScalar(0).applyMatrix4(projViewInv); - const toEyeOld = eye.clone().sub(oldOrigin).normalize(); - expect(Math.abs(toEyeOld.dot(ray.direction))).toBeLessThan(0.999); -}); From 2286de8a44daa0b7e0a017d60d8e9760fe218a1f Mon Sep 17 00:00:00 2001 From: Rodri Date: Fri, 26 Jun 2026 21:52:10 +0100 Subject: [PATCH 4/4] spaces --- src/core/events.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/events.ts b/src/core/events.ts index c3582de..209c8b2 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -22,11 +22,11 @@ export const events: Events = (store) => { if (state.camera.userData.projByViewInv) projViewInv.fromArray(state.camera.userData.projByViewInv); state.raycaster.camera = state.camera; - state.raycaster.ray.origin.set(state.pointer.x, state.pointer.y, -1).applyMatrix4(projViewInv); - state.raycaster.ray.direction - .set(state.pointer.x, state.pointer.y, 1) - .applyMatrix4(projViewInv) - .sub(state.raycaster.ray.origin) + state.raycaster.ray.origin.set(state.pointer.x, state.pointer.y, -1).applyMatrix4(projViewInv); + state.raycaster.ray.direction + .set(state.pointer.x, state.pointer.y, 1) + .applyMatrix4(projViewInv) + .sub(state.raycaster.ray.origin) .normalize(); },