import * as THREE from 'three'; export class CircularFrustum { public readonly apex = new THREE.Vector3(); // Cone apex (camera position) public readonly axisNormalized = new THREE.Vector3(); // normalized (unit) axis direction (camera → screen point) public halfAngle: number = 0; // Half-angle of the cone in radians public cosHalfAngle: number = 0; // cos(halfAngle) — cached public sinHalfAngle: number = 0; // sin(halfAngle) — cached /** * Build a CircularFrustum from a screen-space point and pixel threshold. * * @param screenPointNormalized point in normalised device coordinates (NDC) [-1, 1] * @param camera PerspectiveCamera or OrthographicCamera * @param thresholdPx Screen-space radius in pixels * @param screenSize renderer viewport size in pixels (e.g. from viewport.getBoundingClientRect()) */ public setFromScreenPoint( screenPointNormalized: THREE.Vector2Like, // in [-1, 1] space screenSize: THREE.Vector2Like, camera: THREE.PerspectiveCamera | THREE.OrthographicCamera, thresholdPx: number, // in screen pixels ): void { // cone apex camera.getWorldPosition(this.apex); // cone central ray (axis) const centerNDC = new THREE.Vector3(screenPointNormalized.x, screenPointNormalized.y, 0.5); // center const centerWorld = centerNDC.clone().unproject(camera); this.axisNormalized.copy(centerWorld.sub(this.apex)).normalize(); // frustum near circle bottommost point const bottomNDC = centerNDC.clone().add({ x: 0, y: (thresholdPx / screenSize.y) * 2, z: 0 }); const bottomWorld = bottomNDC.clone().unproject(camera); const downAxis = bottomWorld.sub(this.apex).normalize(); // cone half-angle const halfAngle = Math.acos(THREE.MathUtils.clamp(this.axisNormalized.dot(downAxis), -1, 1)); this.set(this.apex, this.axisNormalized, halfAngle); // console.log({ // screenPointNormalized, // screenSize, // thresholdPx, // centerNDC: centerNDC.toArray(), // centerWorld: centerWorld.toArray(), // bottomNDC: bottomNDC.toArray(), // bottomWorld: bottomWorld.toArray(), // downAxis: downAxis.toArray(), // apex: this.apex.toArray(), // axisNormalized: this.axisNormalized, // halfAngle, // }); // console.log(this.apex.toArray()); } public getCircleAtDepth(depth: number): { center: THREE.Vector3Like, radius: number } { const center = this.apex.clone().addScaledVector(this.axisNormalized, depth); const radius = Math.tan(this.halfAngle) * depth; return { center, radius, } } private set(apex: THREE.Vector3Like, axisNormalized: THREE.Vector3Like, halfAngle: number) { this.apex.copy(apex); this.axisNormalized.copy(axisNormalized); this.halfAngle = halfAngle; this.cosHalfAngle = Math.cos(this.halfAngle); this.sinHalfAngle = Math.sin(this.halfAngle); } public transform(matrix: THREE.Matrix4): CircularFrustum { const transformed = new CircularFrustum(); const localApex = this.apex.clone().applyMatrix4(matrix); const localAxis = this.axisNormalized.clone().transformDirection(matrix).normalize(); transformed.set(localApex, localAxis, this.halfAngle); return transformed; } }