Skip to content

Commit 31d96b2

Browse files
authored
Add camera controls to 2D field tab (#556)
1 parent 739f3e9 commit 31d96b2

17 files changed

Lines changed: 541 additions & 60 deletions

‎docs/docs/tab-reference/2d-field.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ To zoom, place the cursor over the timeline and scroll up or down. A range can a
2424

2525
</details>
2626

27+
## Camera Controls
28+
29+
Scroll up and down over the field to zoom in and out. Click and drag to pan across the field. Right-click anywhere on the field to open the camera menu with the following options:
30+
31+
- **Unlocked**: Pan and zoom are manual.
32+
- **Locked to Robot**: The camera automatically pans to track the robot.
33+
- **Locked to Robot & Rotation**: The camera automatically pans and rotates to track the robot and its rotation.
34+
35+
In either locked mode, the camera can be freely panned away from the robot. The orientation buttons in the control pane can be used to rotate the camera in any mode.
36+
2737
## Adding Objects
2838

2939
To get started, drag a field to the "Poses" section. Delete an object using the X button, or hide it temporarily by clicking the eye icon or double-clicking the field name. To remove all objects, click the trash can near the axis title and then `Clear All`. Objects can be rearranged in the list by clicking and dragging.

‎src/hub/ScrollSensor.ts‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default class ScrollSensor {
1010
private RESET_MS = 1000;
1111

1212
private container: HTMLElement;
13-
private callback: (x: number, y: number) => void;
13+
private callback: (dx: number, dy: number, isPan: boolean, cursorX: number, cursorY: number) => void;
1414

1515
private lastScrollUpdate = 0;
1616
private resetNext = false;
@@ -19,13 +19,22 @@ export default class ScrollSensor {
1919

2020
private panActive = false;
2121
private panLastCursorX = 0;
22+
private panLastCursorY = 0;
23+
24+
private lastCursorX = 0;
25+
private lastCursorY = 0;
2226

2327
/**
2428
* Creates a new ScrollSensor.
2529
* @param container The container element. The overflow should be "scroll" and the scrollbar should be hidden. The child element should have the dimensions 1000000x1000000px.
2630
* @param callback A function to be called after each scroll event, with the relative change in x and y.
31+
* @param mouseControl The mouse control mode for drag-panning.
2732
*/
28-
constructor(container: HTMLElement, callback: (dx: number, dy: number) => void, enableMouseControls = true) {
33+
constructor(
34+
container: HTMLElement,
35+
callback: (dx: number, dy: number, isPan: boolean, cursorX: number, cursorY: number) => void,
36+
mouseControl: ScrollSensorMouseControl = ScrollSensorMouseControl.PanX
37+
) {
2938
this.container = container;
3039
this.callback = callback;
3140

@@ -35,13 +44,22 @@ export default class ScrollSensor {
3544
this.update();
3645
});
3746

47+
// Prevent capturing spacebar
48+
this.container.addEventListener("keydown", (event) => {
49+
if (event.code === "Space") {
50+
event.preventDefault();
51+
}
52+
});
53+
3854
// Mouse controls
39-
if (enableMouseControls) {
55+
if (mouseControl !== ScrollSensorMouseControl.None) {
4056
container.addEventListener("mousedown", (event) => {
4157
if (event.shiftKey) return;
4258
this.panActive = true;
4359
let x = event.clientX - container.getBoundingClientRect().x;
60+
let y = event.clientY - container.getBoundingClientRect().y;
4461
this.panLastCursorX = x;
62+
this.panLastCursorY = y;
4563
});
4664
container.addEventListener("mouseleave", () => {
4765
this.panActive = false;
@@ -50,10 +68,17 @@ export default class ScrollSensor {
5068
this.panActive = false;
5169
});
5270
container.addEventListener("mousemove", (event) => {
71+
let cursorX = event.clientX - container.getBoundingClientRect().x;
72+
let cursorY = event.clientY - container.getBoundingClientRect().y;
73+
this.lastCursorX = cursorX;
74+
this.lastCursorY = cursorY;
75+
5376
if (this.panActive) {
54-
let cursorX = event.clientX - container.getBoundingClientRect().x;
55-
callback(this.panLastCursorX - cursorX, 0);
77+
let dx = this.panLastCursorX - cursorX;
78+
let dy = mouseControl === ScrollSensorMouseControl.PanXY ? this.panLastCursorY - cursorY : 0;
79+
callback(dx, dy, true, cursorX, cursorY);
5680
this.panLastCursorX = cursorX;
81+
this.panLastCursorY = cursorY;
5782
}
5883
});
5984
}
@@ -89,7 +114,7 @@ export default class ScrollSensor {
89114
let dy = this.container.scrollTop - this.lastScrollTop;
90115
this.lastScrollLeft = this.container.scrollLeft;
91116
this.lastScrollTop = this.container.scrollTop;
92-
this.callback(dx, dy);
117+
this.callback(dx, dy, false, this.lastCursorX, this.lastCursorY);
93118
}
94119

95120
/** Moves the scroll position to the center. */
@@ -101,3 +126,9 @@ export default class ScrollSensor {
101126
this.lastScrollTop = middle;
102127
}
103128
}
129+
130+
export enum ScrollSensorMouseControl {
131+
None,
132+
PanX,
133+
PanXY
134+
}

‎src/hub/SelectionImpl.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ export default class SelectionImpl implements Selection {
5858
});
5959

6060
window.addEventListener("keydown", (event) => {
61-
if (event.target !== document.body && event.target !== window) return;
61+
if (
62+
event.target instanceof HTMLInputElement ||
63+
event.target instanceof HTMLTextAreaElement ||
64+
(event.target instanceof HTMLElement && event.target.isContentEditable)
65+
) {
66+
return;
67+
}
6268
switch (event.code) {
6369
case "Space":
6470
event.preventDefault();

‎src/hub/Tabs.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import TableRenderer from "../shared/renderers/TableRenderer";
2626
import VideoRenderer from "../shared/renderers/VideoRenderer";
2727
import { Units } from "../shared/units";
2828
import { clampValue } from "../shared/util";
29-
import ScrollSensor from "./ScrollSensor";
29+
import ScrollSensor, { ScrollSensorMouseControl } from "./ScrollSensor";
3030
import Timeline from "./Timeline";
3131
import ConsoleController from "./controllers/ConsoleController";
3232
import Field2dController from "./controllers/Field2dController";
@@ -325,7 +325,7 @@ export default class Tabs {
325325
(dx: number, dy: number) => {
326326
this.TAB_BAR.scrollLeft += dx + dy;
327327
},
328-
false
328+
ScrollSensorMouseControl.None
329329
);
330330

331331
// Add timeline
@@ -747,6 +747,13 @@ export default class Tabs {
747747
}
748748
}
749749

750+
/** Switches the selected camera mode for the selected 2D field. */
751+
set2DCamera(index: number) {
752+
if (this.tabList[this.selectedTab].type === TabType.Field2d) {
753+
(this.tabList[this.selectedTab].renderer as Field2dRenderer).set2DCamera(index);
754+
}
755+
}
756+
750757
/** Switches the orbit FOV for the selected 3D field. */
751758
setFov(fov: number) {
752759
if (this.tabList[this.selectedTab].type === TabType.Field3d) {

‎src/hub/controllers/VideoController.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,9 @@ export default class VideoController implements TabController {
181181
if (
182182
root === null ||
183183
root.hidden ||
184-
event.target !== document.body ||
184+
event.target instanceof HTMLInputElement ||
185+
event.target instanceof HTMLTextAreaElement ||
186+
(event.target instanceof HTMLElement && event.target.isContentEditable) ||
185187
(window.platform === "darwin" ? event.metaKey : event.ctrlKey)
186188
)
187189
return;

‎src/hub/hub.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,10 @@ async function handleMainMessage(message: NamedMessage) {
906906
window.tabs.set3DCamera(message.data);
907907
break;
908908

909+
case "set-2d-camera":
910+
window.tabs.set2DCamera(message.data);
911+
break;
912+
909913
case "edit-fov":
910914
window.tabs.setFov(message.data);
911915
break;

‎src/main/electron/main.ts‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { AdvantageScopeAssets } from "../../shared/AdvantageScopeAssets";
3737
import ButtonRect from "../../shared/ButtonRect";
3838
import { ensureThemeContrast } from "../../shared/Colors";
3939
import ExportOptions from "../../shared/ExportOptions";
40+
import { Field2dCameraMode } from "../../shared/Field2dCameraMode";
4041
import LineGraphFilter from "../../shared/LineGraphFilter";
4142
import NamedMessage from "../../shared/NamedMessage";
4243
import Preferences, { DEFAULT_PREFS, getLiveModeName, LiveMode, mergePreferences } from "../../shared/Preferences";
@@ -1211,6 +1212,10 @@ async function handleHubMessage(window: BrowserWindow, message: NamedMessage) {
12111212
);
12121213
break;
12131214

1215+
case "ask-2d-camera":
1216+
select2DCameraPopup(window, message.data.position, message.data.selectedIndex);
1217+
break;
1218+
12141219
case "export-console":
12151220
dialog
12161221
.showSaveDialog(window, {
@@ -1516,6 +1521,45 @@ function select3DCameraPopup(
15161521
});
15171522
}
15181523

1524+
function select2DCameraPopup(window: BrowserWindow, position: [number, number], selectedIndex: Field2dCameraMode) {
1525+
const cameraMenu = new Menu();
1526+
cameraMenu.append(
1527+
new MenuItem({
1528+
label: "Unlocked",
1529+
type: "checkbox",
1530+
checked: selectedIndex === Field2dCameraMode.Unlocked,
1531+
click() {
1532+
sendMessage(window, "set-2d-camera", Field2dCameraMode.Unlocked);
1533+
}
1534+
})
1535+
);
1536+
cameraMenu.append(
1537+
new MenuItem({
1538+
label: "Locked to Robot",
1539+
type: "checkbox",
1540+
checked: selectedIndex === Field2dCameraMode.Robot,
1541+
click() {
1542+
sendMessage(window, "set-2d-camera", Field2dCameraMode.Robot);
1543+
}
1544+
})
1545+
);
1546+
cameraMenu.append(
1547+
new MenuItem({
1548+
label: "Locked to Robot && Rotation",
1549+
type: "checkbox",
1550+
checked: selectedIndex === Field2dCameraMode.RobotAndRotation,
1551+
click() {
1552+
sendMessage(window, "set-2d-camera", Field2dCameraMode.RobotAndRotation);
1553+
}
1554+
})
1555+
);
1556+
cameraMenu.popup({
1557+
window: window,
1558+
x: Math.round(position[0]),
1559+
y: Math.round(position[1])
1560+
});
1561+
}
1562+
15191563
/**
15201564
* Process a message from a download window.
15211565
* @param message The received message
@@ -3113,15 +3157,20 @@ function createSatellite(
31133157
let message: NamedMessage = event.data;
31143158
switch (message.name) {
31153159
case "set-aspect-ratio":
3116-
let aspectRatio = message.data;
3160+
let aspectRatio = message.data.aspectRatio;
3161+
let lock = message.data.lock;
31173162
if (aspectRatio === null) {
31183163
satellite.setAspectRatio(0);
31193164
} else {
31203165
let originalSize = satellite.getContentSize();
31213166
let originalArea = originalSize[0] * originalSize[1];
31223167
let newY = Math.sqrt(originalArea / aspectRatio);
31233168
let newX = aspectRatio * newY;
3124-
satellite.setAspectRatio(aspectRatio);
3169+
if (lock) {
3170+
satellite.setAspectRatio(aspectRatio);
3171+
} else {
3172+
satellite.setAspectRatio(0);
3173+
}
31253174
satellite.setContentSize(Math.round(newX), Math.round(newY));
31263175
}
31273176
break;
@@ -3137,6 +3186,10 @@ function createSatellite(
31373186
);
31383187
break;
31393188

3189+
case "ask-2d-camera":
3190+
select2DCameraPopup(satellite, message.data.position, message.data.selectedIndex);
3191+
break;
3192+
31403193
case "add-table-range":
31413194
hubWindows.forEach((window) => {
31423195
sendMessage(window, "add-table-range", {

‎src/main/lite/main.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AdvantageScopeAssets } from "../../shared/AdvantageScopeAssets";
1010
import { BUILD_DATE, COPYRIGHT, Distribution, DISTRIBUTION, LITE_VERSION } from "../../shared/buildConstants";
1111
import ButtonRect from "../../shared/ButtonRect";
1212
import { ensureThemeContrast } from "../../shared/Colors";
13+
import { Field2dCameraMode } from "../../shared/Field2dCameraMode";
1314
import { HubState } from "../../shared/HubState";
1415
import LineGraphFilter from "../../shared/LineGraphFilter";
1516
import NamedMessage from "../../shared/NamedMessage";
@@ -1205,6 +1206,35 @@ async function handleHubMessage(message: NamedMessage) {
12051206
}
12061207
break;
12071208

1209+
case "ask-2d-camera":
1210+
{
1211+
let position: [number, number] = message.data.position;
1212+
let selectedIndex: Field2dCameraMode = message.data.selectedIndex;
1213+
let menuItems: (MenuItem | Submenu | "-")[] = [
1214+
{
1215+
content: (selectedIndex === Field2dCameraMode.Unlocked ? "\u2714 " : "") + "Unlocked",
1216+
callback() {
1217+
sendMessage(hubPort, "set-2d-camera", Field2dCameraMode.Unlocked);
1218+
}
1219+
},
1220+
{
1221+
content: (selectedIndex === Field2dCameraMode.Robot ? "\u2714 " : "") + "Locked to Robot",
1222+
callback() {
1223+
sendMessage(hubPort, "set-2d-camera", Field2dCameraMode.Robot);
1224+
}
1225+
},
1226+
{
1227+
content:
1228+
(selectedIndex === Field2dCameraMode.RobotAndRotation ? "\u2714 " : "") + "Locked to Robot & Rotation",
1229+
callback() {
1230+
sendMessage(hubPort, "set-2d-camera", Field2dCameraMode.RobotAndRotation);
1231+
}
1232+
}
1233+
];
1234+
openMenu({ x: position[0], y: position[1], width: 0, height: 0 }, menuItems);
1235+
}
1236+
break;
1237+
12081238
default:
12091239
console.warn("Unknown message from hub", message);
12101240
break;

‎src/satellite.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ window.addEventListener("message", (event) => {
175175
}
176176
break;
177177

178+
case "set-2d-camera":
179+
if (type === TabType.Field2d) {
180+
(renderer as Field2dRenderer).set2DCamera(message.data);
181+
}
182+
break;
183+
178184
case "edit-fov":
179185
if (type === TabType.Field3d) {
180186
(renderer as Field3dRenderer).setFov(message.data);
@@ -205,7 +211,7 @@ function processAspectRatio(aspectRatio: number | null) {
205211
if (aspectRatio > MAX_ASPECT_RATIO) aspectRatio = MAX_ASPECT_RATIO;
206212
if (aspectRatio < 1 / MAX_ASPECT_RATIO) aspectRatio = 1 / MAX_ASPECT_RATIO;
207213
}
208-
window.sendMainMessage("set-aspect-ratio", aspectRatio);
214+
window.sendMainMessage("set-aspect-ratio", { aspectRatio, lock: type !== TabType.Field2d });
209215
}
210216
}
211217

@@ -220,7 +226,13 @@ window.addEventListener("beforeunload", () => {
220226
});
221227

222228
window.addEventListener("keydown", (event) => {
223-
if (event.target !== document.body) return;
229+
if (
230+
event.target instanceof HTMLInputElement ||
231+
event.target instanceof HTMLTextAreaElement ||
232+
(event.target instanceof HTMLElement && event.target.isContentEditable)
233+
) {
234+
return;
235+
}
224236
switch (event.code) {
225237
case "Space":
226238
event.preventDefault();

‎src/shared/Field2dCameraMode.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) 2021-2026 Littleton Robotics
2+
// http://github.com/Mechanical-Advantage
3+
//
4+
// Use of this source code is governed by a BSD
5+
// license that can be found in the LICENSE file
6+
// at the root directory of this project.
7+
8+
export enum Field2dCameraMode {
9+
Unlocked = 0,
10+
Robot = 1,
11+
RobotAndRotation = 2
12+
}

0 commit comments

Comments
 (0)