@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
144 lines (118 loc) • 3.93 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import {
MathUtils,
Mesh,
MeshStandardMaterial,
SphereGeometry,
Vector2,
Vector3,
type Camera,
type Intersection,
type Material,
type OrthographicCamera,
type PerspectiveCamera,
type Raycaster,
type Scene,
type WebGLRenderer,
} from 'three';
const DEFAULT_SCALE = new Vector3(1, 1, 1);
const tmpOrigin = new Vector3();
const tmpPosition = new Vector3();
const tmpScale = new Vector3();
const tmpSize = new Vector2();
const DEFAULT_MATERIAL = new MeshStandardMaterial({ color: 'red' });
function isPerspectiveCamera(cam: unknown): cam is PerspectiveCamera {
return (cam as PerspectiveCamera).isPerspectiveCamera;
}
function isOrthographicCamera(cam: unknown): cam is OrthographicCamera {
return (cam as OrthographicCamera).isOrthographicCamera;
}
const SHARED_GEOMETRY = new SphereGeometry(1);
const DEFAULT_RADIUS = 10;
/**
* A 3D sphere that maintains the same apparent radius in screen space pixels.
*/
export default class ConstantSizeSphere extends Mesh {
/**
* The radius, in pixels.
*/
public radius: number;
public enableRaycast = true;
public readonly isConstantSizeSphere = true as const;
public override readonly type = 'ConstantSizeSphere' as const;
public constructor(options?: {
/**
* The sphere apparent radius, in pixels.
* @defaultValue 10
*/
radius?: number;
/**
* The sphere material.
* @defaultValue a {@link MeshStandardMaterial} with a red color.
*/
material?: Material;
}) {
super(SHARED_GEOMETRY, options?.material ?? DEFAULT_MATERIAL);
this.radius = options?.radius ?? DEFAULT_RADIUS;
}
/** @internal */
public override raycast(raycaster: Raycaster, intersects: Intersection[]): void {
if (this.enableRaycast) {
super.raycast(raycaster, intersects);
}
}
/** @internal */
public override onBeforeRender(renderer: WebGLRenderer, _scene: Scene, camera: Camera): void {
this.updateWorldMatrix(true, false);
const scale = getWorldSpaceRadius(
renderer,
camera,
this.getWorldPosition(tmpPosition),
this.radius,
);
const parentScale = this.parent?.getWorldScale(tmpScale) ?? DEFAULT_SCALE;
// We want the sphere to ignore the world scale,
// as it should have a constant size on screen.
this.scale.set(
(1 / parentScale.x) * scale,
(1 / parentScale.y) * scale,
(1 / parentScale.z) * scale,
);
this.updateMatrixWorld();
}
}
/**
* Returns the radius in world units so that a sphere appears to have a given radius in pixels.
* @internal
*/
export function getWorldSpaceRadius(
renderer: WebGLRenderer,
camera: Camera,
worldPosition: Vector3,
screenSpaceRadius: number,
): number {
const origin = camera.getWorldPosition(tmpOrigin);
const dist = origin.distanceTo(worldPosition);
let fieldOfViewHeight: number;
if (isPerspectiveCamera(camera)) {
const fovRads = MathUtils.degToRad(camera.getEffectiveFOV()) / 2;
fieldOfViewHeight = 2 * Math.tan(fovRads) * dist;
} else if (isOrthographicCamera(camera)) {
fieldOfViewHeight = Math.abs(camera.top - camera.bottom) / camera.zoom;
} else {
throw new Error('unsupported camera type');
}
const size = renderer.getSize(tmpSize);
const pixelRatio = screenSpaceRadius / size.height;
return fieldOfViewHeight * pixelRatio;
}
export function isConstantSizeSphere(obj: unknown): obj is ConstantSizeSphere {
if (obj == null) {
return false;
}
return (obj as ConstantSizeSphere).isConstantSizeSphere;
}