Skip to content

Commit 98c2f39

Browse files
authored
Feat: Add pan to editor camera (#572)
1 parent 12f15a4 commit 98c2f39

File tree

5 files changed

+297
-0
lines changed

5 files changed

+297
-0
lines changed

editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage";
1818

1919
import { EditorInspectorKeyField } from "../../layout/inspector/fields/key";
20+
import { EditorInspectorNumberField } from "../../layout/inspector/fields/number";
2021

2122
import { Editor } from "../../main";
2223

@@ -162,6 +163,18 @@ export class EditorEditPreferencesComponent extends Component<IEditorEditPrefere
162163
this._saveCameraControls();
163164
}}
164165
/>
166+
167+
<EditorInspectorNumberField
168+
object={camera}
169+
property="panSensitivityMultiplier"
170+
label="Pan Sensitivity"
171+
min={0.1}
172+
max={50}
173+
step={0.5}
174+
onChange={() => {
175+
this._saveCameraControls();
176+
}}
177+
/>
165178
</div>
166179
</div>
167180
</div>
@@ -184,6 +197,7 @@ export class EditorEditPreferencesComponent extends Component<IEditorEditPrefere
184197
keysRight: camera.keysRight,
185198
keysUpward: camera.keysUpward,
186199
keysDownward: camera.keysDownward,
200+
panSensitivityMultiplier: camera.panSensitivityMultiplier,
187201
})
188202
);
189203
} catch (e) {

editor/src/editor/layout/inspector/camera/editor.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ export class EditorCameraInspector extends Component<IEditorInspectorImplementat
3737
<FocalLengthInspector camera={this.props.object} />
3838
</EditorInspectorSectionField>
3939

40+
<EditorInspectorSectionField title="Controls">
41+
<EditorInspectorNumberField
42+
object={this.props.object}
43+
property="panSensitivityMultiplier"
44+
label="Pan Sensitivity"
45+
min={0.1}
46+
max={50}
47+
step={0.5}
48+
tooltip="Controls how responsive the camera panning is. Higher values make panning more sensitive."
49+
/>
50+
</EditorInspectorSectionField>
51+
4052
<EditorInspectorSectionField title="Keys">
4153
<Button variant="secondary" onClick={() => this.props.editor.setState({ editPreferences: true })}>
4254
Configure in preferences...

editor/src/editor/layout/preview.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,10 @@ export class EditorPreview extends Component<IEditorPreviewProps, IEditorPreview
601601
return;
602602
}
603603

604+
if (event.altKey || event.button === 1) {
605+
return;
606+
}
607+
604608
const distance = Vector2.Distance(this._mouseDownPosition, new Vector2(event.clientX, event.clientY));
605609

606610
if (distance > 2) {
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { FreeCamera, ICameraInput, Scene, Vector3 } from "babylonjs";
2+
3+
/**
4+
* FreeCamera input to pan the camera using Alt + Left Mouse Button.
5+
*/
6+
export class EditorFreeCameraPanInput implements ICameraInput<FreeCamera> {
7+
public camera: FreeCamera;
8+
9+
private _scene: Scene;
10+
private _canvas: HTMLCanvasElement | null = null;
11+
12+
private _isPanning: boolean = false;
13+
private _lastClientX: number = 0;
14+
private _lastClientY: number = 0;
15+
16+
private _detachedMouseInput: any | null = null;
17+
18+
public panSensitivityMultiplier: number = 10;
19+
private static readonly _panMinDistance: number = 2;
20+
private static readonly _panMaxDistance: number = 200;
21+
private static readonly _panMaxPixelDelta: number = 40;
22+
private static readonly _defaultFov: number = Math.PI / 4;
23+
24+
private _pointerDownListener: ((ev: PointerEvent) => void) | null = null;
25+
private _pointerMoveListener: ((ev: PointerEvent) => void) | null = null;
26+
private _pointerUpListener: ((ev: PointerEvent) => void) | null = null;
27+
private _pointerCancelListener: ((ev: PointerEvent) => void) | null = null;
28+
29+
public getClassName(): string {
30+
return "EditorFreeCameraPanInput";
31+
}
32+
33+
public getSimpleName(): string {
34+
return "editorPan";
35+
}
36+
37+
public attachControl(noPreventDefaultOrElement?: any, maybeNoPreventDefault?: boolean): void {
38+
let noPreventDefault = false;
39+
if (typeof noPreventDefaultOrElement === "boolean") {
40+
noPreventDefault = noPreventDefaultOrElement;
41+
} else if (typeof maybeNoPreventDefault === "boolean") {
42+
noPreventDefault = maybeNoPreventDefault;
43+
}
44+
45+
this._scene = this.camera.getScene();
46+
this._canvas = this._scene.getEngine().getRenderingCanvas();
47+
if (!this._canvas) {
48+
console.warn("EditorFreeCameraPanInput: No canvas found");
49+
return;
50+
}
51+
52+
document.addEventListener(
53+
"pointerdown",
54+
(this._pointerDownListener = (ev: PointerEvent) => {
55+
// Only handle events on our canvas
56+
if (ev.target !== this._canvas) {
57+
return;
58+
}
59+
60+
const isAltLeft = ev.button === 0 && ev.altKey;
61+
const isMMB = ev.button === 1; // allow middle mouse to pan as well
62+
if (!isAltLeft && !isMMB) {
63+
return;
64+
}
65+
66+
this._beginPan(ev);
67+
if (!noPreventDefault) {
68+
ev.preventDefault();
69+
ev.stopPropagation();
70+
}
71+
}),
72+
true
73+
);
74+
75+
document.addEventListener(
76+
"pointermove",
77+
(this._pointerMoveListener = (ev: PointerEvent) => {
78+
if (!this._isPanning) {
79+
return;
80+
}
81+
82+
if (!noPreventDefault) {
83+
ev.preventDefault();
84+
ev.stopPropagation();
85+
}
86+
87+
this._updatePan(ev);
88+
}),
89+
true
90+
);
91+
92+
const endPan = (ev: PointerEvent) => {
93+
if (!this._isPanning) {
94+
return;
95+
}
96+
if (!noPreventDefault) {
97+
ev.preventDefault();
98+
ev.stopPropagation();
99+
}
100+
this._endPan(ev);
101+
};
102+
103+
document.addEventListener("pointerup", (this._pointerUpListener = endPan), true);
104+
document.addEventListener("pointercancel", (this._pointerCancelListener = endPan), true);
105+
}
106+
107+
public detachControl(): void {
108+
if (this._isPanning) {
109+
this._endPan();
110+
}
111+
112+
if (this._pointerDownListener) {
113+
document.removeEventListener("pointerdown", this._pointerDownListener, true);
114+
this._pointerDownListener = null;
115+
}
116+
if (this._pointerMoveListener) {
117+
document.removeEventListener("pointermove", this._pointerMoveListener, true);
118+
this._pointerMoveListener = null;
119+
}
120+
if (this._pointerUpListener) {
121+
document.removeEventListener("pointerup", this._pointerUpListener, true);
122+
this._pointerUpListener = null;
123+
}
124+
if (this._pointerCancelListener) {
125+
document.removeEventListener("pointercancel", this._pointerCancelListener, true);
126+
this._pointerCancelListener = null;
127+
}
128+
129+
this._canvas = null;
130+
}
131+
132+
public checkInputs(): void {
133+
// no-op
134+
}
135+
136+
private _beginPan(ev: PointerEvent): void {
137+
this._isPanning = true;
138+
this._lastClientX = ev.clientX;
139+
this._lastClientY = ev.clientY;
140+
141+
try {
142+
(ev.target as any)?.setPointerCapture?.(ev.pointerId);
143+
} catch {}
144+
145+
// Detach default mouse rotation input while panning
146+
const attached: any = (this.camera.inputs as any).attached;
147+
this._detachedMouseInput = attached?.mouse ?? attached?.pointers ?? null;
148+
try {
149+
this._detachedMouseInput?.detachControl?.();
150+
} catch {}
151+
152+
try {
153+
document.body.style.cursor = "grabbing";
154+
} catch {}
155+
}
156+
157+
private _updatePan(ev: PointerEvent): void {
158+
const cam = this.camera as any;
159+
let deltaX = ev.clientX - this._lastClientX;
160+
let deltaY = ev.clientY - this._lastClientY;
161+
if (deltaX === 0 && deltaY === 0) {
162+
return;
163+
}
164+
165+
// Clamp pixel deltas to avoid large single-step jumps on missed frames
166+
const maxPix = EditorFreeCameraPanInput._panMaxPixelDelta;
167+
if (deltaX > maxPix) {
168+
deltaX = maxPix;
169+
} else if (deltaX < -maxPix) {
170+
deltaX = -maxPix;
171+
}
172+
if (deltaY > maxPix) {
173+
deltaY = maxPix;
174+
} else if (deltaY < -maxPix) {
175+
deltaY = -maxPix;
176+
}
177+
178+
const engine = this._scene.getEngine();
179+
const renderHeight = engine.getRenderHeight(true);
180+
const fov = cam.fov ?? EditorFreeCameraPanInput._defaultFov;
181+
182+
// Estimate distance to scene for scaling
183+
let distance = 10;
184+
try {
185+
const camPos = cam.globalPosition ?? cam.position;
186+
const pick = this._scene.pick(this._scene.pointerX, this._scene.pointerY, undefined, false);
187+
if (pick?.pickedPoint) {
188+
distance = Vector3.Distance(camPos, pick.pickedPoint);
189+
}
190+
} catch {}
191+
192+
const clampedDistance = Math.min(Math.max(distance, EditorFreeCameraPanInput._panMinDistance), EditorFreeCameraPanInput._panMaxDistance);
193+
const worldPerPixelBase = (2 * Math.tan(fov / 2) * Math.max(clampedDistance, 0.0001)) / Math.max(renderHeight, 1);
194+
const worldPerPixel = worldPerPixelBase * this.panSensitivityMultiplier;
195+
196+
const right = this.camera.getDirection(Vector3.Right());
197+
const up = this.camera.getDirection(Vector3.Up());
198+
const offset = right.scale(-deltaX * worldPerPixel).add(up.scale(deltaY * worldPerPixel));
199+
200+
this.camera.position.addInPlace(offset);
201+
if (cam.target && cam.target.addInPlace) {
202+
try {
203+
cam.target.addInPlace(offset);
204+
cam.setTarget?.(cam.target);
205+
} catch {}
206+
}
207+
208+
this._lastClientX = ev.clientX;
209+
this._lastClientY = ev.clientY;
210+
}
211+
212+
private _endPan(ev?: PointerEvent): void {
213+
this._isPanning = false;
214+
215+
// Re-attach default mouse input if we detached it
216+
try {
217+
this._detachedMouseInput?.attachControl?.();
218+
} catch {}
219+
this._detachedMouseInput = null;
220+
221+
try {
222+
if (ev) {
223+
(ev.target as any)?.releasePointerCapture?.(ev.pointerId);
224+
}
225+
} catch {}
226+
227+
try {
228+
document.body.style.cursor = "default";
229+
} catch {}
230+
}
231+
}

editor/src/editor/nodes/camera.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Node, FreeCamera, Scene, Vector3 } from "babylonjs";
2+
import { EditorFreeCameraPanInput } from "./camera-pan-input";
23

34
import { isDomTextInputFocused } from "../../tools/dom";
45

56
export class EditorCamera extends FreeCamera {
67
private _savedSpeed: number | null = null;
8+
private _panInput: EditorFreeCameraPanInput;
79

810
private _keyboardUpListener: (ev: KeyboardEvent) => void;
911
private _keyboardDownListener: (ev: KeyboardEvent) => void;
@@ -19,6 +21,7 @@ export class EditorCamera extends FreeCamera {
1921
super(name, position, scene, setActiveOnSceneIfNoneActive);
2022

2123
this.inputs.addMouseWheel();
24+
this._panInput = new EditorFreeCameraPanInput();
2225

2326
window.addEventListener(
2427
"keydown",
@@ -49,6 +52,18 @@ export class EditorCamera extends FreeCamera {
4952
);
5053
}
5154

55+
/**
56+
* Override attachControl to ensure pan input is attached
57+
*/
58+
public attachControl(noPreventDefault?: boolean): void {
59+
super.attachControl(noPreventDefault);
60+
61+
// Add pan input after camera is attached
62+
if (this._panInput && !this.inputs.attached.editorPan) {
63+
this.inputs.add(this._panInput);
64+
}
65+
}
66+
5267
/**
5368
* Some preferences for the editor's camera are saved in the local storage in order
5469
* to be global for each project. This function tries to get the preferences from the local storage
@@ -63,6 +78,11 @@ export class EditorCamera extends FreeCamera {
6378
this.keysRight = keys.keysRight;
6479
this.keysUpward = keys.keysUpward;
6580
this.keysDownward = keys.keysDownward;
81+
82+
// Load pan sensitivity multiplier if available
83+
if (keys.panSensitivityMultiplier !== undefined) {
84+
this.panSensitivityMultiplier = keys.panSensitivityMultiplier;
85+
}
6686
} catch (e) {
6787
// Catch silently.
6888
}
@@ -85,6 +105,22 @@ export class EditorCamera extends FreeCamera {
85105
public getClassName(): string {
86106
return "EditorCamera";
87107
}
108+
109+
/**
110+
* Gets the pan sensitivity multiplier for the camera pan input.
111+
* @returns the current pan sensitivity multiplier value.
112+
*/
113+
public get panSensitivityMultiplier(): number {
114+
return this._panInput.panSensitivityMultiplier;
115+
}
116+
117+
/**
118+
* Sets the pan sensitivity multiplier for the camera pan input.
119+
* @param value defines the new pan sensitivity multiplier value.
120+
*/
121+
public set panSensitivityMultiplier(value: number) {
122+
this._panInput.panSensitivityMultiplier = value;
123+
}
88124
}
89125

90126
Node.AddNodeConstructor("EditorCamera", (name, scene) => {

0 commit comments

Comments
 (0)