@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
195 lines • 8.44 kB
JavaScript
import { PerspectiveCamera, Vector3 } from "three";
import { GroundProjectedEnv } from "../engine-components/GroundProjection.js";
import { findObjectOfType } from "./engine_components.js";
import { Context } from "./engine_context.js";
import { Gizmos } from "./engine_gizmos.js";
import { getBoundingBox } from "./engine_three_utils.js";
import { NeedleXRSession } from "./xr/NeedleXRSession.js";
/**
* Fit the camera to the specified objects or the whole scene.
* Adjusts the camera position and optionally the FOV to ensure all objects are visible.
*
* @example Fit the main camera to the entire scene:
* ```ts
* import { fitCamera } from '@needle-tools/engine';
*
* // Fit the main camera to the entire scene
* fitCamera();
* ```
* @example Fit a specific camera to specific objects with custom options:
* ```ts
* import { fitCamera } from '@needle-tools/engine';
*
* // Fit a specific camera to specific objects with custom options
* const myCamera = ...; // your camera
* const objectsToFit = [...]; // array of objects to fit
* fitCamera({
* camera: myCamera,
* objects: objectsToFit,
* fitOffset: 1,
* fov: 20,
* });
* ```
*
* @param options Options for fitting the camera
* @returns
*/
export function fitCamera(options) {
if (NeedleXRSession.active) {
// camera fitting in XR is not supported
console.warn('[OrbitControls] Can not fit camera while XR session is active');
return null;
}
const context = Context.Current;
if (!context) {
console.warn('[OrbitControls] No context found');
return null;
}
const camera = options?.camera || context.mainCamera;
// const controls = this._controls as ThreeOrbitControls | null;
if (!camera) {
console.warn("No camera or controls found to fit camera to objects...");
return null;
}
if (!options)
options = {};
options.autoApply = options.autoApply !== false; // default to true
options.minZoom ||= 0;
options.maxZoom ||= Infinity;
const { centerCamera, cameraNearFar = "auto", fitOffset = 1.1, fov = camera instanceof PerspectiveCamera ? camera?.fov : -1 } = options;
const size = new Vector3();
const center = new Vector3();
const aspect = camera instanceof PerspectiveCamera ? camera.aspect : 1;
const objects = options.objects || context.scene;
// TODO would be much better to calculate the bounds in camera space instead of world space -
// we would get proper view-dependant fit.
// Right now it's independent from where the camera is actually looking from,
// and thus we're just getting some maximum that will work for sure.
const box = getBoundingBox(objects, undefined, camera?.layers);
const boxCopy = box.clone();
box.getCenter(center);
const box_size = new Vector3();
box.getSize(box_size);
// project this box into camera space
if (camera instanceof PerspectiveCamera)
camera.updateProjectionMatrix();
camera.updateMatrixWorld();
box.applyMatrix4(camera.matrixWorldInverse);
box.getSize(size);
box.setFromCenterAndSize(center, size);
if (Number.isNaN(size.x) || Number.isNaN(size.y) || Number.isNaN(size.z)) {
console.warn("Camera fit size resultet in NaN", camera, box);
return null;
}
if (size.length() <= 0.0000000001) {
console.warn("Camera fit size is zero", box);
return null;
}
const verticalFov = fov;
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov * Math.PI / 360 / 2) * aspect) / Math.PI * 360;
const fitHeightDistance = size.y / (2 * Math.atan(Math.PI * verticalFov / 360));
const fitWidthDistance = size.x / (2 * Math.atan(Math.PI * horizontalFov / 360));
const distance = fitOffset * Math.max(fitHeightDistance, fitWidthDistance) + size.z / 2;
options.maxZoom = distance * 10;
options.minZoom = distance * 0.01;
if (options.debug === true) {
console.log("Fit camera to objects", { fitHeightDistance, fitWidthDistance, distance, verticalFov, horizontalFov });
}
const verticalOffset = 0.05;
const lookAt = center.clone();
lookAt.y -= size.y * verticalOffset;
if (options.targetOffset) {
if (options.targetOffset.x !== undefined)
lookAt.x += options.targetOffset.x;
if (options.targetOffset.y !== undefined)
lookAt.y += options.targetOffset.y;
if (options.targetOffset.z !== undefined)
lookAt.z += options.targetOffset.z;
}
if (options.relativeTargetOffset) {
if (options.relativeTargetOffset.x !== undefined)
lookAt.x += options.relativeTargetOffset.x * size.x;
if (options.relativeTargetOffset.y !== undefined)
lookAt.y += options.relativeTargetOffset.y * size.y;
if (options.relativeTargetOffset.z !== undefined)
lookAt.z += options.relativeTargetOffset.z * size.z;
}
// this.setLookTargetPosition(lookAt, immediate);
// this.setFieldOfView(options.fov, immediate);
if (cameraNearFar == undefined || cameraNearFar == "auto") {
// Check if the scene has a GroundProjectedEnv and include the scale to the far plane so that it doesnt cut off
const groundprojection = findObjectOfType(GroundProjectedEnv);
const groundProjectionRadius = groundprojection ? groundprojection.radius : 0;
const boundsMax = Math.max(box_size.x, box_size.y, box_size.z, groundProjectionRadius);
// TODO: this doesnt take the Camera component nearClipPlane into account
if (camera instanceof PerspectiveCamera) {
camera.near = (distance / 100);
camera.far = boundsMax + distance * 10;
camera.updateProjectionMatrix();
}
// adjust maxZoom so that the ground projection radius is always inside
if (groundprojection) {
options.maxZoom = Math.max(Math.min(options.maxZoom, groundProjectionRadius * 0.5), distance);
}
}
// ensure we're not clipping out of the current zoom level just because we're fitting
if (options.currentZoom !== undefined) {
if (options.currentZoom < options.minZoom)
options.minZoom = options.currentZoom * 0.9;
if (options.currentZoom > options.maxZoom)
options.maxZoom = options.currentZoom * 1.1;
}
const direction = center.clone();
if (options.fitDirection) {
direction.sub(new Vector3().copy(options.fitDirection).multiplyScalar(1_000_000));
}
else {
direction.sub(camera.worldPosition);
}
if (centerCamera === "y")
direction.y = 0;
direction.normalize();
direction.multiplyScalar(distance);
if (centerCamera === "y")
direction.y += -verticalOffset * 4 * distance;
let cameraLocalPosition = center.clone().sub(direction);
if (options.cameraOffset) {
if (options.cameraOffset.x !== undefined)
cameraLocalPosition.x += options.cameraOffset.x;
if (options.cameraOffset.y !== undefined)
cameraLocalPosition.y += options.cameraOffset.y;
if (options.cameraOffset.z !== undefined)
cameraLocalPosition.z += options.cameraOffset.z;
}
if (options.relativeCameraOffset) {
if (options.relativeCameraOffset.x !== undefined)
cameraLocalPosition.x += options.relativeCameraOffset.x * size.x;
if (options.relativeCameraOffset.y !== undefined)
cameraLocalPosition.y += options.relativeCameraOffset.y * size.y;
if (options.relativeCameraOffset.z !== undefined)
cameraLocalPosition.z += options.relativeCameraOffset.z * size.z;
}
if (camera.parent) {
cameraLocalPosition = camera.parent.worldToLocal(cameraLocalPosition);
}
// this.setCameraTargetPosition(cameraLocalPosition, immediate);
if (options.debug) {
Gizmos.DrawWireBox3(box, 0xffff33, 10);
Gizmos.DrawWireBox3(boxCopy, 0x00ff00, 10);
}
if (options.autoApply) {
camera.position.copy(cameraLocalPosition);
camera.lookAt(lookAt);
if (fov > 0 && camera instanceof PerspectiveCamera) {
camera.fov = fov;
camera.updateProjectionMatrix();
}
}
return {
camera: camera,
position: cameraLocalPosition,
lookAt: lookAt,
fov: options.fov,
};
}
//# sourceMappingURL=engine_camera.fit.js.map