app
Adom 3D Viewer
Public Made by Adomby adom
The Babylon 9.5 engine behind every component page's 3D tab on wiki.adom.inc. Versioned ESM bundle with GLB loading, view cube, layers toolbar, ground shadows, and Z-up CAD framing.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
import { Scene } from '@babylonjs/core/scene';
import { KeyboardEventTypes, PointerEventTypes, type IKeyboardEvent } from '@babylonjs/core/Events';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { HighlightLayer } from '@babylonjs/core/Layers/highlightLayer';
import { Color3 } from '@babylonjs/core/Maths/math.color'
import { Animation } from '@babylonjs/core/Animations/animation';
import { QuadraticEase, EasingFunction } from '@babylonjs/core/Animations/easing';
import { AdomStatics } from '../AdomStatics';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { writable, get } from 'svelte/store';
/** When true, disables camera keyboard input (WASD). Use when another control has focus (e.g. input fields). */
export const disableCameraKeyboardStore = writable<boolean>(false);
export class AdomCamera extends ArcRotateCamera{
// State for keyboard and mouse handling
private isPointerDownWithShiftAlt: boolean = false;
private lastCamTarget: Vector3 | null = null;
private lastCamTargetMove: any;
/** Pivot point picked by shift+alt / click-to-set; snapped to on early pointer-up. */
private pendingPivotTarget: Vector3 | null = null;
private sphere: any;
private sphereHighlightLayer: HighlightLayer | undefined;
private isRotating: boolean = false;
private sphereVisibilityThreshold: number = 500; // ms to keep sphere visible after rotation stops
// Controls whether normal left-clicks behave like shift+alt+click
private useClickToSetRotationPoint: boolean = false;
private clickStartTime: number = 0; // To track when the click started
private clickDelayTimeout: NodeJS.Timeout | null = null; // For delaying click-to-set behavior
private clickDelayDuration: number = 500; // 0.5 second delay for click-to-set (reduced from 1 second)
// Orbiting with spacebar properties
private isSpacebarPressed: boolean = false;
private orbitAnimationFrameId: number | null = null;
private orbitSpeed: number = 0.005; // radians per frame
// First-person movement properties
private moveSpeed: number = 2;
private keysPressed: { [key: string]: boolean } = {
'w': false,
's': false,
'a': false,
'd': false,
'q': false,
'e': false
};
// Precision mode (toggle with Caps Lock)
private precisionMode: boolean = false;
// Base speeds (normal mode)
private baseOrbitSpeed: number = 0.005;
private baseMoveSpeed: number = 2;
// Camera bounds properties
private targetBounds: {
minX: number | null;
maxX: number | null;
minY: number | null;
maxY: number | null;
minZ: number | null;
maxZ: number | null;
} = {
minX: null,
maxX: null,
minY: null,
maxY: null,
minZ: null,
maxZ: null
};
private enforceBounds: boolean = false;
// Camera rotation detection for middle mouse
private lastAlpha: number = 0;
private lastBeta: number = 0;
private lastRadius: number = 0;
private isMiddleMouseRotating: boolean = false;
private rotationDetectionTimeout: NodeJS.Timeout | null = null;
private isMiddleMouseDown: boolean = false;
// Pinch zoom sensitivity (multiplier for trackpad pinch gestures)
private pinchZoomMultiplier: number = 1.0;
private canvas: HTMLCanvasElement | null = null;
private wheelHandler: ((e: WheelEvent) => void) | null = null;
// Right-click drag panning
private isRightMouseDown: boolean = false;
private isCtrlLeftPanning: boolean = false;
private lastPointerX: number = 0;
private lastPointerY: number = 0;
private panSpeed: number = 1.0;
// Event listeners for external control (e.g. automated tests or external UI)
private externalEventListeners: Map<string, EventListener> = new Map();
/***
* Create a new AdomCamera
* @param scene: The scene that the camera is in
* @param canvas: The canvas that the camera is in
* @param zUp: If true (default), Z-axis is up. If false, Y-axis is up (Babylon default).
*/
constructor(scene: Scene, canvas: HTMLCanvasElement, zUp: boolean = true) {
// console.log("adom camera start");
// Create the camera
super("adomCamera", 0, 0, 2500, new Vector3(0, 0, 140), scene);
this.upVector = zUp ? new Vector3(0, 0, 1) : new Vector3(0, 1, 0);
this.canvas = canvas;
this.attachControl(canvas, true);
this.parent = null;
// Additional AdomCamera initialization
this.setupCameraDefaults();
this.setupKeyboardEvents();
this.setupSphereAndHighlight();
this.setupPointerEvents();
this.setupPinchZoom();
// Initialize rotation detection values AFTER camera setup
this.initializeRotationDetection();
this.setupExternalEventListeners();
// console.log("adom camera end");
}
/***
* Getter to expose the ArcRotateCamera
*/
public getCamera(): ArcRotateCamera {
return this;
}
/***
* Get the current coordinates of the camera
*/
public getCameraCoords() {
// console.log("Camera position:", this.position);
// console.log("Camera target:", this.target);
// console.log("Camera radius:", this.radius);
// console.log("Camera alpha:", this.alpha);
// console.log("Camera beta:", this.beta);
let str =
`Camera.moveCamTo(
0, 0, 0, // Position values are ignored - will be calculated from other parameters
${this.target.x}, ${this.target.y}, ${this.target.z}, // target - what the camera looks at
${this.radius}, ${this.alpha}, ${this.beta}, // radius, alpha, beta - determine camera position
60, // Optional: fps (defaults to 60)
60); // Optional: totalframes (defaults to 60)`;
navigator.clipboard?.writeText(str);
// console.log("Camera coordinates copied to clipboard");
return str;
}
/***
* Set up the camera to work in our environment
*/
private setupCameraDefaults() {
this.allowUpsideDown = true;
this.panningSensibility = 0; // Disable built-in panning (0 = disabled in Babylon)
this.panningInertia = 0;
this.invertRotation = false;
this.panningAxis = new Vector3(1, 1, 0);
this.inertia = 0.1;
this.angularSensibilityX = 500;
this.angularSensibilityY = 500;
this.lowerAlphaLimit = -100;
this.lowerBetaLimit = -100;
this.lowerRadiusLimit = -100;
this.upperRadiusLimit = 2700;
this.wheelDeltaPercentage = 0.1;
// minZ is set here, maxZ is left at Babylon default for perspective mode
// Orthographic mode dynamically adjusts both in updateClippingPlanes()
// radius/50 (not /1000): the near plane is the depth-precision lever.
// PCB exports stack layers 10-40 microns apart; with near at
// radius/1000 the depth quantum at working distance is ~20 microns
// and coplanar-ish layers z-fight (moire on the mask around vias).
// The quantum grows with distance squared, so max zoom-out needs the
// extra headroom of /50 over /100.
this.minZ = this.radius / 50;
// Note: NOT setting maxZ here - Babylon's default works better for perspective
// upVector already set in constructor from zUp option
this.alpha = Math.PI / 2;
this.beta = Math.PI / 4;
this.zoomToMouseLocation = true;
// Load camera mode from localStorage, default to perspective.
// Guarded: a sandboxed iframe without allow-same-origin throws
// SecurityError on ANY storage access (even a read), and an
// exception here silently kills the whole scene rig (lights, env,
// ViewCube). Sandboxed embeds just get the default mode.
let savedMode: string | null = null;
try {
savedMode = typeof window !== 'undefined' ? window.localStorage.getItem('adom_camera_mode') : null;
} catch (e) {
savedMode = null;
}
if (savedMode === 'orthographic') {
this.mode = ArcRotateCamera.ORTHOGRAPHIC_CAMERA;
this.wasOrthoMode = true; // Initialize tracking flag
// console.log('Camera initialized in Orthographic mode (from localStorage)');
} else {
this.mode = ArcRotateCamera.PERSPECTIVE_CAMERA;
// console.log('Camera initialized in Perspective mode');
}
// Initial orthographic bounds - these will be updated based on radius
this.updateOrthographicBounds();
// Register an observer to check bounds on every frame
this._scene.onBeforeRenderObservable.add(() => {
if (this.enforceBounds) {
this.checkAndEnforceBounds();
}
// Handle keyboard movement
this.processKeyboardInput();
// Detect middle mouse rotation and show orbit sphere
this.detectAndShowRotationSphere();
// Update orthographic bounds and clipping planes based on radius for proper zooming
this.updateClippingPlanes();
});
}
/**
* Update orthographic bounds based on camera radius for proper zooming
*/
public updateOrthographicBounds(): void {
// Scale orthographic bounds based on radius
// Smaller radius = more zoomed in (smaller bounds)
const scale = this.radius / 2;
const aspect = this._scene.getEngine().getRenderWidth() / this._scene.getEngine().getRenderHeight();
this.orthoLeft = -scale * aspect;
this.orthoRight = scale * aspect;
this.orthoTop = scale;
this.orthoBottom = -scale;
}
// Track whether we've set ortho clipping to know when to restore perspective values
private wasOrthoMode: boolean = false;
/**
* Update clipping planes based on camera mode
* Orthographic mode requires different clipping plane calculations to prevent
* excessive near-plane clipping while maintaining good z-buffer precision
*/
private updateClippingPlanes(): void {
if (this.mode === ArcRotateCamera.ORTHOGRAPHIC_CAMERA) {
this.updateOrthographicBounds();
// In orthographic mode, use negative minZ to prevent near-plane clipping
// when viewing at angles. Objects "in front" of the camera plane need to be visible.
this.minZ = -this.radius * 20;
this.maxZ = this.radius * 20;
this.wasOrthoMode = true;
} else {
if (this.wasOrthoMode) {
this.wasOrthoMode = false;
}
// Scale minZ with radius to maintain good depth buffer precision at all zoom levels
// (radius/50, not /1000 — see setupCameraDefaults; 20x depth precision)
this.minZ = this.radius / 50;
}
}
/***
* Set up key inputs for our environment
*/
private setupKeyboardEvents() {
this._scene.onKeyboardObservable.add((kbInfo) => {
switch (kbInfo.type) {
case KeyboardEventTypes.KEYDOWN:
this.handleKeyDown(kbInfo.event);
break;
case KeyboardEventTypes.KEYUP:
this.handleKeyUp(kbInfo.event);
break;
}
});
}
/***
* Helper for keyboard events
*/
private handleKeyDown(event: IKeyboardEvent) {
const key = event.key.toLowerCase();
const code = event.code;
// Toggle precision mode on Caps Lock press
if (code === 'CapsLock') {
this.precisionMode = !this.precisionMode;
this.updateSpeeds();
return;
}
if (get(disableCameraKeyboardStore)) {
return;
}
// Handle spacebar for orbit
if (key === ' ' || code === 'Space') {
if (!this.isSpacebarPressed) {
this.isSpacebarPressed = true;
this.startOrbiting();
}
return;
}
// Track WASD key presses
if (key in this.keysPressed) {
this.keysPressed[key] = true;
}
}
private handleKeyUp(event: IKeyboardEvent) {
const key = event.key.toLowerCase();
const code = event.code;
if (key === ' ' || code === 'Space') {
this.isSpacebarPressed = false;
this.stopOrbiting();
return;
}
// Track WASD key releases
if (key in this.keysPressed) {
this.keysPressed[key] = false;
}
}
/**
* Updates movement and orbit speeds based on precision mode
*/
private updateSpeeds(): void {
if (this.precisionMode) {
// Precision mode (1/4 speed)
this.orbitSpeed = this.baseOrbitSpeed / 4;
this.moveSpeed = this.baseMoveSpeed / 4;
// console.log('Precision mode activated: speed reduced to 25%');
} else {
// Normal speed
this.orbitSpeed = this.baseOrbitSpeed;
this.moveSpeed = this.baseMoveSpeed;
// console.log('Normal speed restored');
}
}
/**
* Start orbiting around the target point
*/
private startOrbiting() {
// Show the rotation sphere at the target position
this.showRotationSphere();
// Start the animation if it's not already running
if (this.orbitAnimationFrameId === null) {
// Use requestAnimationFrame for smooth animation
const animate = () => {
if (this.isSpacebarPressed) {
// Increment alpha to orbit around the target
this.alpha += this.orbitSpeed;
// Keep sphere visible and properly sized
this.updateRotationSphere();
// Continue animation
this.orbitAnimationFrameId = requestAnimationFrame(animate);
}
};
this.orbitAnimationFrameId = requestAnimationFrame(animate);
}
}
/**
* Stop orbiting animation
*/
private stopOrbiting() {
// Cancel the animation frame
if (this.orbitAnimationFrameId !== null) {
cancelAnimationFrame(this.orbitAnimationFrameId);
this.orbitAnimationFrameId = null;
}
// Hide the rotation sphere
if (this.sphere) {
this.sphere.isVisible = false;
}
}
/**
* Set the orbit speed
* @param speed The speed in radians per frame
*/
public setOrbitSpeed(speed: number): void {
this.baseOrbitSpeed = speed;
// Apply precision mode slowdown if active
this.orbitSpeed = this.precisionMode ? speed / 4 : speed;
}
/**
* Get the current orbit speed
*/
public getOrbitSpeed(): number {
return this.orbitSpeed;
}
/**
* Initialize rotation detection values to current camera state
*/
private initializeRotationDetection(): void {
this.lastAlpha = this.alpha;
this.lastBeta = this.beta;
this.lastRadius = this.radius;
this.isMiddleMouseRotating = false;
this.isMiddleMouseDown = false;
// Make sure sphere is hidden initially
if (this.sphere) {
this.sphere.isVisible = false;
}
// console.log("Rotation detection initialized");
}
/**
* Set up event listeners for external control (e.g. automated tests or external UI).
* Trigger via window.dispatchEvent(new CustomEvent('camera:change_view', { detail: { orientation: 'top' } })).
*/
private setupExternalEventListeners(): void {
// Create a single event listener that handles all orientations
const changeViewListener = (event: Event) => {
const customEvent = event as CustomEvent;
const orientation = customEvent.detail?.orientation;
if (!orientation) {
// console.error('camera:change_view event requires orientation in detail');
return;
}
switch (orientation) {
case 'top':
this.rotateToCubeTop();
break;
case 'top_front_right':
this.rotateToCubeTopFrontRight();
break;
case 'top_front_left':
this.rotateToCubeTopFrontLeft();
break;
case 'top_back_right':
this.rotateToCubeTopBackRight();
break;
case 'top_back_left':
this.rotateToCubeTopBackLeft();
break;
default:
// console.error(`Unknown camera orientation: ${orientation}`);
}
};
// Store listener for cleanup
this.externalEventListeners.set('camera:change_view', changeViewListener as EventListener);
// Add event listener to window
if (typeof window !== 'undefined') {
window.addEventListener('camera:change_view', changeViewListener as EventListener);
// console.log('Camera external event listener registered for camera:change_view');
}
}
/**
* Reset middle mouse detection state to ensure clean detection
*/
private resetMiddleMouseState(): void {
// console.log("Resetting middle mouse state for clean detection");
this.isMiddleMouseRotating = false;
// Don't reset this.isMiddleMouseDown as it tracks actual button state
// Clear finished animations using robust checking
if (this.lastCamTargetMove) {
const animatable = this.lastCamTargetMove;
const shouldClear = animatable.animationEnded ||
!animatable.animationStarted ||
!animatable.runtimeAnimations ||
animatable.runtimeAnimations.length === 0 ||
animatable.runtimeAnimations.every((anim: any) => !anim.animation || anim.animation._stopped);
if (shouldClear) {
// console.log("Clearing finished animation during reset");
this.lastCamTargetMove = null;
}
}
// Update last known camera values to current state
this.lastAlpha = this.alpha;
this.lastBeta = this.beta;
this.lastRadius = this.radius;
}
/**
* Detect camera rotation (middle mouse) and show/hide rotation sphere accordingly
*/
private detectAndShowRotationSphere(): void {
const currentAlpha = this.alpha;
const currentBeta = this.beta;
const currentRadius = this.radius;
// Check if camera rotation values have changed significantly
const alphaChanged = Math.abs(currentAlpha - this.lastAlpha) > 0.001;
const betaChanged = Math.abs(currentBeta - this.lastBeta) > 0.001;
const radiusChanged = Math.abs(currentRadius - this.lastRadius) > 0.1;
const isRotatingNow = alphaChanged || betaChanged || radiusChanged;
// Check if animation is actually still running
let isAnimationRunning = false;
if (this.lastCamTargetMove) {
// Check multiple properties to determine if animation is truly finished
const animatable = this.lastCamTargetMove;
isAnimationRunning = animatable.animationStarted &&
!animatable.animationEnded &&
animatable.runtimeAnimations &&
animatable.runtimeAnimations.length > 0 &&
animatable.runtimeAnimations.some((anim: any) => anim.animation && !anim.animation._stopped);
}
const canDetectMiddleMouse = !this.isSpacebarPressed &&
!this.isPointerDownWithShiftAlt &&
!this.isRotating &&
!isAnimationRunning;
if (this.isMiddleMouseDown && !this.isMiddleMouseRotating && canDetectMiddleMouse) {
// Check if camera is rotating significantly
if (isRotatingNow) {
this.isMiddleMouseRotating = true;
this.showRotationSphere();
// console.log("Middle mouse rotation detected - showing orbit sphere");
}
} else if (this.isMiddleMouseDown && !this.isMiddleMouseRotating && !canDetectMiddleMouse) {
// Debug: log what's blocking detection
// console.log("Middle mouse blocked - spacebar:", this.isSpacebarPressed,
// "shiftAlt:", this.isPointerDownWithShiftAlt,
// "leftRotating:", this.isRotating,
// "animation:", !!this.lastCamTargetMove,
// "animRunning:", isAnimationRunning);
}
// Clean up finished animations - use more robust checking
if (this.lastCamTargetMove) {
const animatable = this.lastCamTargetMove;
const shouldClear = animatable.animationEnded ||
!animatable.animationStarted ||
!animatable.runtimeAnimations ||
animatable.runtimeAnimations.length === 0 ||
animatable.runtimeAnimations.every((anim: any) => !anim.animation || anim.animation._stopped);
if (shouldClear) {
// console.log("Clearing finished camera animation");
this.lastCamTargetMove = null;
}
}
// Handle middle mouse rotation sphere display
if (this.isMiddleMouseRotating) {
if (this.isMiddleMouseDown) {
// Keep updating sphere position during rotation
this.updateRotationSphere();
// Clear any pending timeout since we're still rotating
if (this.rotationDetectionTimeout) {
clearTimeout(this.rotationDetectionTimeout);
this.rotationDetectionTimeout = null;
}
} else {
// Middle mouse was released - hide sphere immediately
this.isMiddleMouseRotating = false;
if (this.sphere) {
this.sphere.isVisible = false;
}
// console.log("Middle mouse released - hiding orbit sphere");
}
}
// Update last known values
this.lastAlpha = currentAlpha;
this.lastBeta = currentBeta;
this.lastRadius = currentRadius;
}
/***
* Glowing sphere for showing where the rotation axis is
*/
private setupSphereAndHighlight() {
// Create a sphere to show the point where the camera is rotating around
this.sphere = MeshBuilder.CreateSphere('arcRotateIndicatorSphere', { segments: 10, diameter: 1 }, this._scene);
this.sphere.isVisible = false;
const material = new StandardMaterial("rotationSphereMaterial", this._scene);
material.emissiveColor = new Color3(0, 0.38, 0.94);
this.sphere.material = material;
}
/***
* Set up pointer event inputs for our environment
*/
private setupPointerEvents() {
let rotationTimeout: NodeJS.Timeout | null = null;
this._scene.onPointerObservable.add((pointerInfo) => {
switch (pointerInfo.type) {
case PointerEventTypes.POINTERDOWN:
// Track right mouse button for custom panning
if (pointerInfo.event.button === 2) {
this.isRightMouseDown = true;
this.lastPointerX = pointerInfo.event.clientX;
this.lastPointerY = pointerInfo.event.clientY;
}
// Track middle mouse button
if (pointerInfo.event.button === 1) { // Middle mouse button
this.isMiddleMouseDown = true;
}
if (pointerInfo.event.shiftKey && pointerInfo.event.altKey) {
// Traditional shift+alt+click behavior (immediate)
this.isPointerDownWithShiftAlt = true;
const pickResult = pointerInfo.pickInfo;
if (pickResult?.hit) {
const clickedPoint = pickResult.pickedPoint;
if (clickedPoint) {
this.handleShiftAltClick(clickedPoint);
}
}
} else if (pointerInfo.event.button === 0 && pointerInfo.event.ctrlKey) {
// Ctrl+Left-click: custom panning (matches right-click speed)
this.isCtrlLeftPanning = true;
this.lastPointerX = pointerInfo.event.clientX;
this.lastPointerY = pointerInfo.event.clientY;
} else if (pointerInfo.event.button === 0) { // Left mouse button
if (this.useClickToSetRotationPoint) {
// Store the time when the click started
this.clickStartTime = performance.now();
// Clear any existing timeout
if (this.clickDelayTimeout) {
clearTimeout(this.clickDelayTimeout);
this.clickDelayTimeout = null;
}
// Set up a timeout for the delayed click-to-set behavior
this.clickDelayTimeout = setTimeout(() => {
// Only proceed if we're still in the same click (mouse button still down)
if (this.isRotating) {
const pickResult = pointerInfo.pickInfo;
if (pickResult?.hit) {
const clickedPoint = pickResult.pickedPoint;
if (clickedPoint) {
// Handle the delayed click to set rotation point
this.handleClick(clickedPoint);
}
}
}
this.clickDelayTimeout = null;
}, this.clickDelayDuration);
// Mark that we're rotating - this will be used for regular rotation until timeout
this.isRotating = true;
this.showRotationSphere();
} else {
// Left-click: orbit only (do not set rotation point); show sphere like middle-mouse
this.isRotating = true;
this.showRotationSphere();
}
}
break;
case PointerEventTypes.POINTERMOVE:
// Custom drag panning (right-click or ctrl+left-click)
if (this.isRightMouseDown || this.isCtrlLeftPanning) {
const dx = pointerInfo.event.clientX - this.lastPointerX;
const dy = pointerInfo.event.clientY - this.lastPointerY;
this.lastPointerX = pointerInfo.event.clientX;
this.lastPointerY = pointerInfo.event.clientY;
// Get camera right and up vectors in world space
const rightDir = this.getDirection(Vector3.Right());
const upDir = this.getDirection(Vector3.Up());
// Scale pan amount by radius so panning feels consistent at any zoom
const scale = (this.radius / 1000) * this.panSpeed;
const offset = rightDir.scale(-dx * scale).add(upDir.scale(dy * scale));
this.target.addInPlace(offset);
}
if (this.isRotating && !this.isPointerDownWithShiftAlt) {
// Keep sphere visible and update its position during rotation
// Only update sphere position in normal mode, not in click-to-set mode
if (!this.useClickToSetRotationPoint || this.clickDelayTimeout === null) {
this.updateRotationSphere();
}
// Clear any existing timeout
if (rotationTimeout) {
clearTimeout(rotationTimeout);
rotationTimeout = null;
}
}
break;
case PointerEventTypes.POINTERUP:
// Track right mouse button release
if (pointerInfo.event.button === 2) {
this.isRightMouseDown = false;
}
// Track ctrl+left-click panning release
if (pointerInfo.event.button === 0 && this.isCtrlLeftPanning) {
this.isCtrlLeftPanning = false;
}
// Track middle mouse button release
if (pointerInfo.event.button === 1) { // Middle mouse button
this.isMiddleMouseDown = false;
}
if (this.isPointerDownWithShiftAlt) {
this.isPointerDownWithShiftAlt = false;
this._resetCameraTarget();
// Reset middle mouse state for clean detection
// console.log("Shift+Alt interaction ended - resetting middle mouse state");
this.resetMiddleMouseState();
}
if (this.isRotating && pointerInfo.event.button === 0) { // Only reset for left mouse button
this.isRotating = false;
// Cancel the delayed click-to-set behavior on pointer up
if (this.clickDelayTimeout) {
clearTimeout(this.clickDelayTimeout);
this.clickDelayTimeout = null;
}
// Hide the sphere on mouse up only if it was shown by left-click rotation
// Don't hide if we're currently orbiting with spacebar or middle mouse rotating
if (this.sphere && !this.isSpacebarPressed && !this.isMiddleMouseRotating) {
this.sphere.isVisible = false;
}
// Clean up any pending timeout
if (rotationTimeout) {
clearTimeout(rotationTimeout);
rotationTimeout = null;
}
// console.log("Left mouse released - reset isRotating, ready for middle mouse");
// Reset middle mouse state for clean detection
this.resetMiddleMouseState();
}
break;
}
});
}
/**
* Set up enhanced pinch zoom for trackpad gestures
* Trackpad pinch sends wheel events with ctrlKey=true and smaller deltaY values
* Implements zoom-to-cursor like zoomToMouseLocation does for regular wheel
*/
private setupPinchZoom() {
if (!this.canvas) return;
this.wheelHandler = (e: WheelEvent) => {
// Always consume the wheel so it zooms the scene instead of
// scrolling the embedding page (the viewer is iframed into wiki
// pages). Regular wheel zoom is handled by Babylon's built-in
// mousewheel input, which still runs after this preventDefault;
// the ctrlKey branch below adds boosted trackpad-pinch zoom.
if (e.cancelable) e.preventDefault();
// Only handle pinch gestures (ctrlKey is true for trackpad pinch)
if (e.ctrlKey) {
// Apply boosted zoom for pinch gestures
// Negative deltaY = pinch out (zoom in), positive = pinch in (zoom out)
const zoomDelta = -e.deltaY * this.pinchZoomMultiplier;
const zoomFactor = 1 + (zoomDelta * 0.01);
// Calculate new radius
let newRadius = this.radius / zoomFactor;
// Respect radius limits
if (this.lowerRadiusLimit !== null && newRadius < this.lowerRadiusLimit) {
newRadius = this.lowerRadiusLimit;
} else if (this.upperRadiusLimit !== null && newRadius > this.upperRadiusLimit) {
newRadius = this.upperRadiusLimit;
}
// Zoom to mouse location (like zoomToMouseLocation for regular wheel)
if (this.zoomToMouseLocation && this._scene) {
const radiusChange = this.radius - newRadius;
// Get mouse position relative to canvas
const rect = this.canvas!.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Create a ray from the mouse position
const ray = this._scene.createPickingRay(
mouseX,
mouseY,
null,
this,
false
);
// In orthographic mode the ray origin is on the near plane (not the camera),
// so we must intersect the ray with the plane through the target (perpendicular
// to view) to get the correct point under the cursor. In perspective, ray.origin
// is the camera position so distanceAlongRay = distanceToTarget is correct.
let pointAtTarget: Vector3;
if (this.mode === ArcRotateCamera.ORTHOGRAPHIC_CAMERA) {
const viewDir = ray.direction;
const t = Vector3.Dot(this.target.subtract(ray.origin), viewDir);
pointAtTarget = ray.origin.add(viewDir.scale(t));
} else {
const distanceToTarget = Vector3.Distance(this.position, this.target);
pointAtTarget = ray.origin.add(ray.direction.scale(distanceToTarget));
}
// Calculate how much to move the target toward the mouse point
// The amount is proportional to the zoom change
const moveRatio = radiusChange / this.radius;
const targetOffset = pointAtTarget.subtract(this.target).scale(moveRatio);
// Apply the target offset
this.target.addInPlace(targetOffset);
}
// Apply the new radius
this.radius = newRadius;
}
};
this.canvas.addEventListener('wheel', this.wheelHandler, { passive: false });
}
/**
* Set the pinch zoom sensitivity multiplier
* Higher values = faster zoom on trackpad pinch
* @param multiplier The multiplier (default is 3.0, range 1.0-10.0 recommended)
*/
public setPinchZoomMultiplier(multiplier: number): void {
this.pinchZoomMultiplier = Math.max(0.5, Math.min(20, multiplier));
}
/**
* Get the current pinch zoom multiplier
*/
public getPinchZoomMultiplier(): number {
return this.pinchZoomMultiplier;
}
/**
* Show the rotation sphere at the current target position
*/
private showRotationSphere() {
if (!this.isPointerDownWithShiftAlt) {
this.sphere.position = this.target.clone();
this.sphere.isVisible = true;
const scale = (this.radius / 100);
this.sphere.scaling = new Vector3(scale, scale, scale);
}
}
/**
* Update the rotation sphere position and scale
*/
private updateRotationSphere() {
if (!this.isPointerDownWithShiftAlt && this.sphere) {
this.sphere.position = this.target.clone();
const scale = (this.radius / 100);
this.sphere.scaling = new Vector3(scale, scale, scale);
}
}
/***
* Move camera to position and add the glowing sphere
* Show the user the new rotation axis point
*/
private handleShiftAltClick(clickedPoint: Vector3) {
const tempRadius = this.radius;
// Store the previous target
this.lastCamTarget = this.target.clone();
this.pendingPivotTarget = clickedPoint.clone();
// Show the sphere at the clicked point
this.sphere.position = clickedPoint;
this.sphere.isVisible = true;
const scale = (this.radius / 100) * 2;
this.sphere.scaling = new Vector3(scale, scale, scale);
// Animate the camera to the clicked point
this.lastCamTargetMove = this.moveCamTargetTo(clickedPoint._x, clickedPoint._y, clickedPoint._z, 120, 60);
// Preserve the camera radius
this.radius = tempRadius;
}
/**
* Handle a mouse click when in click-to-set-rotation mode
* Similar to handleShiftAltClick but designed for regular clicks
*/
private handleClick(clickedPoint: Vector3) {
const tempRadius = this.radius;
// Store the previous target
this.lastCamTarget = this.target.clone();
this.pendingPivotTarget = clickedPoint.clone();
// Show the sphere at the clicked point
this.sphere.position = clickedPoint;
this.sphere.isVisible = true;
const scale = (this.radius / 100);
this.sphere.scaling = new Vector3(scale, scale, scale);
// Animate the camera to the clicked point
this.lastCamTargetMove = this.moveCamTargetTo(clickedPoint._x, clickedPoint._y, clickedPoint._z, 120, 60);
// Preserve the camera radius
this.radius = tempRadius;
}
/***
* Reset the camera's target
*/
private _resetCameraTarget() {
// Hide the sphere only if it's not being used for middle mouse rotation or spacebar orbiting
if (!this.isMiddleMouseRotating && !this.isSpacebarPressed) {
this.sphere.isVisible = false;
}
// Stop and clear the animation if it exists
if (this.lastCamTargetMove) {
this.lastCamTargetMove.stop();
this.lastCamTargetMove = null;
// A quick click releases the pointer before the 0.5s target glide
// finishes; stopping mid-flight would leave the orbit center
// partway between old and new. Snap to the picked point instead.
if (this.pendingPivotTarget) {
this.target = this.pendingPivotTarget.clone();
}
}
this.pendingPivotTarget = null;
}
/***
* Move the camera to its new target
* @param x :number The x position of the target
* @param y :number The y position of the target
* @param z :number The z position of the target
* @param fps :number frames per second (defaults to 60)
* @param totalFrames :number total frames of the animation (this/fps = length of animation)
*/
public moveCamTargetTo(x: number, y: number, z: number, fps: number = 60, totalFrames: number = 60): any {
// console.log("moving camera");
const animTarget = new Animation('target', 'target', fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CONSTANT);
const easingFunction = new QuadraticEase();
easingFunction.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT);
animTarget.setKeys([
{ frame: 0, value: this.target },
{ frame: totalFrames, value: new Vector3(x, y, z) }
]);
this.animations.push(animTarget);
const animatable = this._scene.beginAnimation(this, 0, totalFrames, false);
// Add a safety timeout to ensure animation reference is cleared
// Duration = (totalFrames / fps) * 1000ms + 100ms buffer
const animationDuration = (totalFrames / fps) * 1000 + 100;
setTimeout(() => {
if (this.lastCamTargetMove === animatable) {
// console.log("Safety timeout: clearing animation reference");
this.lastCamTargetMove = null;
}
}, animationDuration);
return animatable;
}
/***
* Instantly move the camera to this new spot
* @param x :number The x position of the camera (ignored - derived from other parameters)
* @param y :number The y position of the camera (ignored - derived from other parameters)
* @param z :number The z position of the camera (ignored - derived from other parameters)
* @param tx :number The x position of the camera target
* @param ty :number The y position of the camera target
* @param tz :number The z position of the camera target
* @param r :number The radius of the camera
* @param a :number The alpha of the camera
* @param b :number The beta of the camera
*/
public teleportCamTo(x: number, y: number, z: number, tx: number, ty: number, tz: number, r: number, a: number, b: number) {
// In an ArcRotateCamera, position is derived from target, radius, alpha, and beta
// So we don't need to set position directly - it'll be computed automatically
this.target = new Vector3(tx, ty, tz);
this.radius = r;
this.alpha = a;
this.beta = b;
// The camera will update position automatically
this.rebuildAnglesAndRadius();
}
/***
* Move the camera slowly to a new position and target with optional easing function.
* @param x :number The x position of the camera (will be calculated based on target, radius, alpha, beta)
* @param y :number The y position of the camera (will be calculated based on target, radius, alpha, beta)
* @param z :number The z position of the camera (will be calculated based on target, radius, alpha, beta)
* @param tx :number The x position of the camera target
* @param ty :number The y position of the camera target
* @param tz :number The z position of the camera target
* @param r :number The radius of the camera
* @param a :number The alpha of the camera
* @param b :number The beta of the camera
* @param fps :number Optional Defaults to 60. The frames per second of the animation
* @param totalFrames :number Optional Defaults to 60. The total frames of the animation
* @param easingFunction :EasingFunction Optional Defaults to QuadraticEase. The easing function to use
*/
public async moveCamTo(x: number, y: number, z: number, tx: number, ty: number, tz: number,
r: number, a: number, b: number, fps:number =60, totalFrames:number =60,
easingFunction = new QuadraticEase()) {
// For each easing function, you can choose between EASEIN (default), EASEOUT, EASEINOUT
easingFunction.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT);
// For ArcRotateCamera, we want to ignore the position input and focus on target, radius, alpha, beta
// Input position parameters are ignored, since position is derived from target, radius, alpha, and beta
// Animate target
const animTarget = new Animation('target', 'target', fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CONSTANT);
animTarget.setEasingFunction(easingFunction);
animTarget.setKeys([
{ frame: 0, value: this.target },
{ frame: totalFrames, value: new Vector3(tx, ty, tz) }
]);
this.animations.push(animTarget);
// Animate radius
const animRadius = new Animation('radius', 'radius', fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CONSTANT);
animRadius.setEasingFunction(easingFunction);
animRadius.setKeys([
{ frame: 0, value: this.radius },
{ frame: totalFrames, value: r }
]);
this.animations.push(animRadius);
// Animate alpha (horizontal rotation)
const animAlpha = new Animation('alpha', 'alpha', fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CONSTANT);
animAlpha.setEasingFunction(easingFunction);
animAlpha.setKeys([
{ frame: 0, value: this.alpha },
{ frame: totalFrames, value: a }
]);
this.animations.push(animAlpha);
// Animate beta (vertical rotation)
const animBeta = new Animation('beta', 'beta', fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CONSTANT);
animBeta.setEasingFunction(easingFunction);
animBeta.setKeys([
{ frame: 0, value: this.beta },
{ frame: totalFrames, value: b }
]);
this.animations.push(animBeta);
if (AdomStatics.scene) {
// console.log("Started moveCamTo anim");
const anim = AdomStatics.scene.beginAnimation(this, 0, totalFrames, false);
await anim.waitAsync();
// console.log("Finished moveCamTo anim");
}
}
/**
* Dispose of the camera (for cleanup).
*/
public dispose() {
// Stop any running orbit animation
if (this.orbitAnimationFrameId !== null) {
cancelAnimationFrame(this.orbitAnimationFrameId);
this.orbitAnimationFrameId = null;
}
// Clear any pending click delay timeout
if (this.clickDelayTimeout) {
clearTimeout(this.clickDelayTimeout);
this.clickDelayTimeout = null;
}
// Clear rotation detection timeout
if (this.rotationDetectionTimeout) {
clearTimeout(this.rotationDetectionTimeout);
this.rotationDetectionTimeout = null;
}
// Clear key states
Object.keys(this.keysPressed).forEach(key => {
this.keysPressed[key] = false;
});
// Reset mouse states
this.isMiddleMouseDown = false;
this.isMiddleMouseRotating = false;
// Remove external event listeners
if (typeof window !== 'undefined') {
this.externalEventListeners.forEach((listener, eventName) => {
window.removeEventListener(eventName, listener);
});
this.externalEventListeners.clear();
// console.log('Camera external event listeners removed');
}
// Remove pinch zoom wheel handler
if (this.canvas && this.wheelHandler) {
this.canvas.removeEventListener('wheel', this.wheelHandler);
this.wheelHandler = null;
}
this.canvas = null;
if (this.sphere) {
this.sphere.dispose();
}
if (this.sphereHighlightLayer) {
this.sphereHighlightLayer.dispose();
}
super.dispose();
// console.log("adom camera dispose end");
}
/**
* Set bounds for the camera target
* Pass null for any bound you don't want to constrain
*/
public setTargetBounds(
minX: number | null = null,
maxX: number | null = null,
minY: number | null = null,
maxY: number | null = null,
minZ: number | null = null,
maxZ: number | null = null
): void {
this.targetBounds = { minX, maxX, minY, maxY, minZ, maxZ };
this.enforceBounds = true;
// Immediately enforce bounds if camera is already outside them
this.checkAndEnforceBounds();
}
/**
* Disable bounds checking for the camera target
*/
public disableBounds(): void {
this.enforceBounds = false;
}
/**
* Check if the camera target is within bounds and correct if needed
*/
private checkAndEnforceBounds(): void {
let needsUpdate = false;
let newTarget = this.target.clone();
// Check X bounds
if (this.targetBounds.minX !== null && newTarget.x < this.targetBounds.minX) {
newTarget.x = this.targetBounds.minX;
needsUpdate = true;
}
if (this.targetBounds.maxX !== null && newTarget.x > this.targetBounds.maxX) {
newTarget.x = this.targetBounds.maxX;
needsUpdate = true;
}
// Check Y bounds
if (this.targetBounds.minY !== null && newTarget.y < this.targetBounds.minY) {
newTarget.y = this.targetBounds.minY;
needsUpdate = true;
}
if (this.targetBounds.maxY !== null && newTarget.y > this.targetBounds.maxY) {
newTarget.y = this.targetBounds.maxY;
needsUpdate = true;
}
// Check Z bounds
if (this.targetBounds.minZ !== null && newTarget.z < this.targetBounds.minZ) {
newTarget.z = this.targetBounds.minZ;
needsUpdate = true;
}
if (this.targetBounds.maxZ !== null && newTarget.z > this.targetBounds.maxZ) {
newTarget.z = this.targetBounds.maxZ;
needsUpdate = true;
}
// Update the target if it was out of bounds
if (true) {
// Hide the sphere first
const wasSphereVisible = this.sphere && this.sphere.isVisible;
if (wasSphereVisible) {
this.sphere.isVisible = false;
}
// Update the camera target
this.setTarget(newTarget);
// Instantly update the sphere position and make it visible again if it was visible
if (wasSphereVisible) {
this.sphere.position = newTarget.clone();
this.sphere.isVisible = true;
}
}
}
/**
* Set whether left-click alone should set rotation point (true) or require shift+alt+click (false)
* @param useClickMode If true, normal left-clicks set rotation point like shift+alt+click
* @param delayDuration Optional delay in milliseconds before triggering (defaults to 500ms/0.5sec)
*/
public setClickToSetRotationPoint(useClickMode: boolean, delayDuration: number = 500): void {
this.useClickToSetRotationPoint = useClickMode;
this.clickDelayDuration = delayDuration;
}
/**
* Get current click mode setting
* @returns True if normal left-clicks set rotation point, false if shift+alt+click is required
*/
public getClickToSetRotationPoint(): boolean {
return this.useClickToSetRotationPoint;
}
/**
* Process keyboard input for first-person camera movement
*/
private processKeyboardInput(): void {
if (!Object.values(this.keysPressed).some(pressed => pressed)) {
return; // No keys are pressed, skip processing
}
// Get camera direction vectors for movement
// IMPORTANT: For Babylon.js, Vector3.Forward() is actually pointing toward negative Z
// So we need to reverse it to get the proper forward direction for first-person movement
const forward = this.getDirection(new Vector3(0, 0, -1)); // True forward in camera space
const right = this.getDirection(Vector3.Right()); // Right in camera space
const up = this.upVector; // Global up vector
// For true FPS-style movement, we want movement in the horizontal plane
// So we zero out the Z component and renormalize
const horizontalForward = new Vector3(forward.x, forward.y, 0).normalize();
const horizontalRight = new Vector3(right.x, right.y, 0).normalize();
// Create move direction vector based on keys pressed
const moveDirection = new Vector3(0, 0, 0);
// Correct W/S logic:
// W should move forward along horizontalForward
// S should move backward (opposite of horizontalForward)
if (this.keysPressed['w']) {
// W = move forward in the direction we're facing
moveDirection.addInPlace(horizontalForward);
}
if (this.keysPressed['s']) {
// S = move backward, opposite of the direction we're facing
moveDirection.addInPlace(horizontalForward.scale(-1));
}
if (this.keysPressed['a']) {
// A = strafe left (opposite of right)
moveDirection.addInPlace(horizontalRight.scale(-1));
}
if (this.keysPressed['d']) {
// D = strafe right
moveDirection.addInPlace(horizontalRight);
}
if (this.keysPressed['q']) {
// Q = move down
moveDirection.addInPlace(up.scale(-1));
}
if (this.keysPressed['e']) {
// E = move up
moveDirection.addInPlace(up);
}
// Normalize the move direction if we have any movement
if (moveDirection.length() > 0) {
moveDirection.normalize();
// Scale by move speed, proportional to camera radius so tiny/huge models feel the same
const scaledSpeed = this.moveSpeed * (this.radius / 100);
const movement = moveDirection.scale(scaledSpeed);
// Move both camera position and target to maintain orientation
this.position.addInPlace(movement);
this.target.addInPlace(movement);
// Update orbit sphere position if visible
if (this.sphere && this.sphere.isVisible) {
this.sphere.position = this.target.clone();
}
}
}
/**
* Set the movement speed for first-person controls
*/
public setMoveSpeed(speed: number): void {
this.baseMoveSpeed = speed;
// Apply precision mode slowdown if active
this.moveSpeed = this.precisionMode ? speed / 4 : speed;
}
/**
* Get the current movement speed
*/
public getMoveSpeed(): number {
return this.moveSpeed;
}
/**
* Rotate camera to a specific viewcube angle
* @param alpha The target alpha angle
* @param beta The target beta angle
* @param duration Animation duration in seconds (defaults to 0.5)
*/
public rotateToCubeAngle(alpha: number, beta: number, duration: number = 0.5): void {
// Calculate shortest path for alpha rotation
const normalizeAngle = (angle: number): number => {
return angle - Math.PI * 2 * Math.floor((angle + Math.PI) / (2 * Math.PI));
};
const shortestAngleDistance = (from: number, to: number): number => {
const normalizedFrom = normalizeAngle(from);
const normalizedTo = normalizeAngle(to);
const difference = normalizedTo - normalizedFrom;
if (difference > Math.PI) {
return difference - Math.PI * 2;
} else if (difference < -Math.PI) {
return difference + Math.PI * 2;
}
return difference;
};
const targetAlpha = this.alpha + shortestAngleDistance(this.alpha, alpha);
const targetBeta = this.beta + shortestAngleDistance(this.beta, beta);
if (duration <= 0) {
this.alpha = targetAlpha;
this.beta = targetBeta;
return;
}
const fps = 60;
const totalFrames = fps * duration;
// Create alpha animation
const alphaAnimation = new Animation(
'alphaAnimation',
'alpha',
fps,
Animation.ANIMATIONTYPE_FLOAT,
Animation.ANIMATIONLOOPMODE_CONSTANT
);
alphaAnimation.setKeys([
{ frame: 0, value: this.alpha },
{ frame: totalFrames, value: targetAlpha }
]);
// Create beta animation
const betaAnimation = new Animation(
'betaAnimation',
'beta',
fps,
Animation.ANIMATIONTYPE_FLOAT,
Animation.ANIMATIONLOOPMODE_CONSTANT
);
betaAnimation.setKeys([
{ frame: 0, value: this.beta },
{ frame: totalFrames, value: targetBeta }
]);
this.animations = [alphaAnimation, betaAnimation];
this._scene.beginAnimation(this, 0, totalFrames, false);
}
/**
* Rotate camera to top view
*/
public rotateToCubeTop(duration: number = 0.5): void {
// Top face: position [0, 0, cr] where cr = 2
// For top view: looking straight down
const alpha = this.alpha; // Keep current alpha (rotation around Z)
const beta = 0; // Looking straight down from top
this.rotateToCubeAngle(alpha, beta, duration);
}
/**
* Rotate camera to top-front-right corner view
*/
public rotateToCubeTopFrontRight(duration: number = 0.5): void {
// Top-front-right corner: position [cr-es/2, -(cr-es/2), cr-es/2]
// With cr=2, es=0.75: position ≈ [1.625, -1.625, 1.625]
const x = 1.625, y = -1.625, z = 1.625;
const adjust = -Math.PI / 2;
const alpha = Math.atan2(x, y) + adjust;
const beta = Math.acos(z / Math.sqrt(x * x + y * y + z * z));
this.rotateToCubeAngle(alpha, beta, duration);
}
/**
* Rotate camera to top-front-left corner view
*/
public rotateToCubeTopFrontLeft(duration: number = 0.5): void {
// Top-front-left corner: position [-(cr-es/2), -(cr-es/2), cr-es/2]
// With cr=2, es=0.75: position ≈ [-1.625, -1.625, 1.625]
const x = -1.625, y = -1.625, z = 1.625;
const adjust = -Math.PI / 2;
const alpha = Math.atan2(x, y) + adjust;
const beta = Math.acos(z / Math.sqrt(x * x + y * y + z * z));
this.rotateToCubeAngle(alpha, beta, duration);
}
/**
* Rotate camera to top-back-right corner view
*/
public rotateToCubeTopBackRight(duration: number = 0.5): void {
// Top-back-right corner: position [cr-es/2, cr-es/2, cr-es/2]
// With cr=2, es=0.75: position ≈ [1.625, 1.625, 1.625]
const x = 1.625, y = 1.625, z = 1.625;
const adjust = -Math.PI / 2;
const alpha = Math.atan2(x, y) + adjust;
const beta = Math.acos(z / Math.sqrt(x * x + y * y + z * z));
this.rotateToCubeAngle(alpha, beta, duration);
}
/**
* Rotate camera to top-back-left corner view
*/
public rotateToCubeTopBackLeft(duration: number = 0.5): void {
// Top-back-left corner: position [-(cr-es/2), cr-es/2, cr-es/2]
// With cr=2, es=0.75: position ≈ [-1.625, 1.625, 1.625]
const x = -1.625, y = 1.625, z = 1.625;
const adjust = -Math.PI / 2;
const alpha = Math.atan2(x, y) + adjust;
const beta = Math.acos(z / Math.sqrt(x * x + y * y + z * z));
this.rotateToCubeAngle(alpha, beta, duration);
}
/**
* Toggle between orthographic and perspective camera modes.
* When linked to the moving camera, switching to orthographic is blocked.
* @returns The new camera mode (ORTHOGRAPHIC_CAMERA or PERSPECTIVE_CAMERA)
*/
public toggleCameraMode(): number {
if (this.mode === ArcRotateCamera.ORTHOGRAPHIC_CAMERA) {
this.mode = ArcRotateCamera.PERSPECTIVE_CAMERA;
// console.log('Switched to Perspective Camera');
} else {
this.mode = ArcRotateCamera.ORTHOGRAPHIC_CAMERA;
// Update orthographic bounds when switching to ortho mode
this.updateOrthographicBounds();
// console.log('Switched to Orthographic Camera');
}
return this.mode;
}
/**
* Get the current camera mode
* @returns The current camera mode (ORTHOGRAPHIC_CAMERA or PERSPECTIVE_CAMERA)
*/
public getCameraMode(): number {
return this.mode;
}
/**
* Check if camera is in orthographic mode
* @returns True if orthographic, false if perspective
*/
public isOrthographic(): boolean {
return this.mode === ArcRotateCamera.ORTHOGRAPHIC_CAMERA;
}
}