From 276feea41aa3b0f0982c2930413d07057d4beeb4 Mon Sep 17 00:00:00 2001 From: Mason Thomas Date: Mon, 24 Mar 2025 22:24:48 -0500 Subject: [PATCH 01/92] erm all the things gabe and i did Co-authored-by: Gabriel Burbach --- src/client/debug/debug.tsx | 9 +++++++++ src/server/api/api.ts | 17 ++++++++++++++--- src/server/api/managers.ts | 3 +++ src/server/api/tcp-interface.ts | 9 +++++++-- src/server/robot/robot.ts | 5 +++-- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/client/debug/debug.tsx b/src/client/debug/debug.tsx index eeadd893..46342f4d 100644 --- a/src/client/debug/debug.tsx +++ b/src/client/debug/debug.tsx @@ -36,6 +36,12 @@ export function Debug() { fetchIds(); }, [setRobotIds]); + const danceConfigLoad = async () => { + const response = await get("/do-parallel"); + if (!response.ok) { + console.warn("Failed to load dance config"); + } + }; // create the select and move buttons let body: ReactNode; if (robotIds === undefined) { @@ -49,6 +55,9 @@ export function Debug() { selectedRobotId={selectedRobotId} onRobotIdSelected={setSelectedRobotId} /> +
+
{selectedRobotId === undefined ? null : ( <>
diff --git a/src/server/api/api.ts b/src/server/api/api.ts index 2c1eb7f5..f15f0ddd 100644 --- a/src/server/api/api.ts +++ b/src/server/api/api.ts @@ -13,7 +13,7 @@ import { SetRobotVariableMessage, } from "../../common/message/robot-message"; -import { TCPServer } from "./tcp-interface"; +import { tcpServer } from "./managers"; import { Difficulty } from "../../common/client-types"; import { RegisterWebsocketMessage } from "../../common/message/message"; import { clientManager, robotManager, socketManager } from "./managers"; @@ -33,8 +33,7 @@ import { Position } from "../robot/position"; import { DEGREE } from "../../common/units"; import { PacketType } from "../utils/tcp-packet"; -export const tcpServer: TCPServer | null = - USE_VIRTUAL_ROBOTS ? null : new TCPServer(); + export const executor = new CommandExecutor(); export let gameManager: GameManager | null = null; @@ -206,6 +205,18 @@ apiRouter.get("/do-smth", async (_, res) => { res.send({ message: "success" }); }); +apiRouter.get("/do-parallel", async (_, res) => { + console.log("Starting parallel command group"); + const robotsEntries = Array.from(robotManager.idsToRobots.entries()); + console.log(robotsEntries); + for (const [, robot] of robotsEntries) { + console.log("Moving robot " + robot.id); + await robot.sendDrivePacket(1); + } + + res.send({ message: "success" }); +}); + /** * get the current state of the virtual robots for the simulator */ diff --git a/src/server/api/managers.ts b/src/server/api/managers.ts index 697ecb39..40992015 100644 --- a/src/server/api/managers.ts +++ b/src/server/api/managers.ts @@ -7,9 +7,12 @@ import { ClientManager } from "./client-manager"; import { SocketManager } from "./socket-manager"; import { virtualRobots } from "../simulator"; import { USE_VIRTUAL_ROBOTS } from "../utils/env"; +import { TCPServer } from "./tcp-interface"; export const socketManager = new SocketManager({}); export const clientManager = new ClientManager(socketManager); export const robotManager = new RobotManager( USE_VIRTUAL_ROBOTS ? Array.from(virtualRobots.values()) : [], ); +export const tcpServer: TCPServer | null = + USE_VIRTUAL_ROBOTS ? null : new TCPServer({},robotManager); diff --git a/src/server/api/tcp-interface.ts b/src/server/api/tcp-interface.ts index fdd6d008..8e847f6c 100644 --- a/src/server/api/tcp-interface.ts +++ b/src/server/api/tcp-interface.ts @@ -9,6 +9,7 @@ import { } from "../utils/tcp-packet"; import { EventEmitter } from "@posva/event-emitter"; import { randomUUID } from "node:crypto"; +import { RobotManager } from "../robot/robot-manager"; type RobotEventEmitter = EventEmitter<{ actionComplete: { @@ -288,6 +289,7 @@ export class BotTunnel { */ export class TCPServer { private server: net.Server; + priv; /** * creates a tcp server on port from server config and registers passed in ids with their corresponding bot tunnels @@ -296,7 +298,10 @@ export class TCPServer { * * @param connections - bot connections in a id:BotTunnel array */ - constructor(private connections: { [id: string]: BotTunnel } = {}) { + constructor( + private connections: { [id: string]: BotTunnel } = {}, + private robotManager: RobotManager, + ) { this.server = net.createServer(); this.server.on("connection", this.handleConnection.bind(this)); this.server.listen(config["tcpServerPort"], () => { @@ -317,7 +322,6 @@ export class TCPServer { private handleConnection(socket: net.Socket) { const remoteAddress = socket.remoteAddress + ":" + socket.remotePort; console.log("New client connection from %s", remoteAddress); - socket.setNoDelay(true); // create a new bot tunnel for the connection @@ -336,6 +340,7 @@ export class TCPServer { config["bots"][mac] = id; } else { id = config["bots"][mac]; + this.robotManager.addRobot(this.robotManager.getRobot(id)); console.log("Found address ID: " + id); } tunnel.id = id; diff --git a/src/server/robot/robot.ts b/src/server/robot/robot.ts index 50ddce0a..adda5925 100644 --- a/src/server/robot/robot.ts +++ b/src/server/robot/robot.ts @@ -1,7 +1,7 @@ import { FULL_ROTATION, RADIAN, clampHeading } from "../../common/units"; import { Position, ZERO_POSITION } from "./position"; import { GridIndices } from "./grid-indices"; -import { tcpServer } from "../api/api"; +import { tcpServer } from "../api/managers"; import type { BotTunnel } from "../api/tcp-interface"; import { PacketType } from "../utils/tcp-packet"; @@ -112,6 +112,7 @@ export class Robot { */ public async sendDrivePacket(tileDistance: number): Promise { const tunnel = this.getTunnel(); - await tunnel.send({ type: PacketType.DRIVE_TILES, tileDistance }); + console.log(tileDistance); + await tunnel.send({ type: PacketType.DRIVE_TANK, left: 1, right: 1 }); } } From 119c01e09f57868411be3f9101f9c65b3188e3ac Mon Sep 17 00:00:00 2001 From: democat Date: Tue, 25 Mar 2025 15:39:06 -0500 Subject: [PATCH 02/92] format --- src/server/api/api.ts | 1 - src/server/api/managers.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/api/api.ts b/src/server/api/api.ts index f15f0ddd..ba53e6da 100644 --- a/src/server/api/api.ts +++ b/src/server/api/api.ts @@ -33,7 +33,6 @@ import { Position } from "../robot/position"; import { DEGREE } from "../../common/units"; import { PacketType } from "../utils/tcp-packet"; - export const executor = new CommandExecutor(); export let gameManager: GameManager | null = null; diff --git a/src/server/api/managers.ts b/src/server/api/managers.ts index 40992015..f644a9f0 100644 --- a/src/server/api/managers.ts +++ b/src/server/api/managers.ts @@ -15,4 +15,4 @@ export const robotManager = new RobotManager( USE_VIRTUAL_ROBOTS ? Array.from(virtualRobots.values()) : [], ); export const tcpServer: TCPServer | null = - USE_VIRTUAL_ROBOTS ? null : new TCPServer({},robotManager); + USE_VIRTUAL_ROBOTS ? null : new TCPServer({}, robotManager); From a20818117196d322a51282250f9a07da0226e066 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sat, 8 Mar 2025 00:28:13 -0600 Subject: [PATCH 03/92] Extract robot grid component on simulator page --- src/client/debug/simulator.tsx | 85 ++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index 4c6d628b..3d37fd4e 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -25,16 +25,17 @@ const tileSize = 60; const robotSize = tileSize / 2; const cellCount = 12; +type RobotState = { [robotId: string]: SimulatedRobotLocation }; + /** * Creates a robot simulator for testing robot commands * * does not require physical robots to be connected * @returns the simulator screen as a card - */ +*/ export function Simulator() { const navigate = useNavigate(); - type RobotState = { [robotId: string]: SimulatedRobotLocation }; type Action = | { type: "SET_ALL_ROBOTS"; payload: RobotState } @@ -150,45 +151,7 @@ export function Simulator() {
-
- {new Array(cellCount * cellCount) - .fill(undefined) - .map((_, i) => { - const row = Math.floor(i / cellCount); - const col = i % cellCount; - const isCenterCell = - row >= 2 && row < 10 && col >= 2 && col < 10; - return ( -
- ); - })} - {/* TODO: implement onTopOfRobots */} - {Object.entries(robotState).map(([robotId, pos]) => ( - - ))} -
+
{ await fetch(`/__open-in-editor?${params.toString()}`); }; +function RobotGrid({robotState}: {robotState: RobotState}) { + return
+ {new Array(cellCount * cellCount) + .fill(undefined) + .map((_, i) => { + const row = Math.floor(i / cellCount); + const col = i % cellCount; + const isCenterCell = row >= 2 && row < 10 && col >= 2 && col < 10; + return ( +
+ ); + })} + {/* TODO: implement onTopOfRobots */} + {Object.entries(robotState).map(([robotId, pos]) => ( + + ))} +
; +} + /** * the message log, used to show the commands sent to the robot * @param props - the message and time From e6eb8d02bde12d601d7e057179625197d8d37547 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 11 Mar 2025 03:49:52 -0500 Subject: [PATCH 04/92] Stuff --- src/client/debug/simulator.tsx | 4 +-- src/client/editor/editor.tsx | 49 ++++++++++++++++++++++++++++++++++ src/client/main.tsx | 4 +++ src/client/package.json | 4 ++- src/client/router.tsx | 5 ++++ yarn.lock | 26 ++++++++++++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/client/editor/editor.tsx diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index 3d37fd4e..a715a629 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -25,7 +25,7 @@ const tileSize = 60; const robotSize = tileSize / 2; const cellCount = 12; -type RobotState = { [robotId: string]: SimulatedRobotLocation }; +export type RobotState = { [robotId: string]: SimulatedRobotLocation }; /** * Creates a robot simulator for testing robot commands @@ -202,7 +202,7 @@ const openInEditor = async (frame: StackFrame) => { await fetch(`/__open-in-editor?${params.toString()}`); }; -function RobotGrid({robotState}: {robotState: RobotState}) { +export function RobotGrid({robotState}: {robotState: RobotState}) { return
{ + const output: RobotState = {}; + for (let robotIndex = 0; robotIndex < robotCount; robotIndex++) { + const robotId = `robot-${robotIndex}`; + const obj = demoSheet.__experimental_getExistingObject(robotId); + if (!obj) { + console.warn(`Could not find robot ${robotId}`); + continue; + } + + const bp = obj.value.bezierpoint; + output[robotId] = { + position: { + x: bp.x, + y: bp.y, + }, + headingRadians: bp.headingRadians, + } + + } + return output; + } + + const [rs, setRs] = useState(robotState()); + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/client/main.tsx b/src/client/main.tsx index ae399943..ec946310 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -14,6 +14,10 @@ export const queryClient = new QueryClient(); FocusStyleManager.onlyShowFocusOnTabs(); +import studio from '@theatre/studio' + +studio.initialize() + //creates the root of the page and forwards control to the page router ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( <> diff --git a/src/client/package.json b/src/client/package.json index ff946077..fe6afcfa 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -12,7 +12,9 @@ "react-joystick-component": "^6.2.1", "react-router-dom": "^6.21.3", "react-use-websocket": "^4.5.0", - "scss": "^0.2.4" + "scss": "^0.2.4", + "@theatre/core": "^0.7.2", + "@theatre/studio": "^0.7.2" }, "devDependencies": { "@types/react": "^18.2.48", diff --git a/src/client/router.tsx b/src/client/router.tsx index b282a82f..247a8ff7 100644 --- a/src/client/router.tsx +++ b/src/client/router.tsx @@ -7,6 +7,7 @@ import { Lobby } from "./setup/lobby"; import { Home } from "./home"; import { Debug2 } from "./debug/debug2"; import { Simulator } from "./debug/simulator"; +import { Editor } from "./editor/editor"; export const router = createBrowserRouter([ { @@ -41,4 +42,8 @@ export const router = createBrowserRouter([ path: "/game", element: , }, + { + path: "/editor", + element: , + }, ]); diff --git a/yarn.lock b/yarn.lock index 98ee2423..389cee19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -796,6 +796,27 @@ dependencies: "@tanstack/query-core" "5.29.0" +"@theatre/core@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@theatre/core/-/core-0.7.2.tgz#540a6c3ab80c6ce835b5a284694baf3291222c42" + integrity sha512-IDQa/6WY7mIJAtsSd4EgNcM0IUZkl+FrqZ8DdYiCVTFap9ARDNmrngJOeFjJOsnnaHlc5GdEB/jj7fsjbIrAzQ== + dependencies: + "@theatre/dataverse" "0.7.2" + +"@theatre/dataverse@0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@theatre/dataverse/-/dataverse-0.7.2.tgz#25aafe9fe781661939aa991dd46079e0fe4cfd18" + integrity sha512-YyfoyX7EyhFUY2OM5fsM0LPrs1SdgLwpiTMkkvTIoZLdOwvQhstjYq4Yz/8ZncJlRoTWvakfmgvCaBN+QuBYxg== + dependencies: + lodash-es "^4.17.21" + +"@theatre/studio@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@theatre/studio/-/studio-0.7.2.tgz#287b148c17d7799955199dd6b90c59c876746eaf" + integrity sha512-p6LTKzJWVlcHkpGzIlNHh9AkGbB3E+0q9Pjxv+OJoTDe1IK+CMKW695Wp+1//lB4vfC9qShe4z/p+Zaj1q8KtA== + dependencies: + "@theatre/dataverse" "0.7.2" + "@tsconfig/node10@^1.0.7": version "1.0.9" resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz" @@ -2342,6 +2363,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" From 4be96607f7aad39c6bce823b02f5fdcfcb099dbf Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 25 Mar 2025 03:44:53 -0500 Subject: [PATCH 05/92] Rough spline editor UI --- src/client/debug/simulator.tsx | 32 ++-- src/client/editor/editor.tsx | 291 ++++++++++++++++++++++++++++----- src/client/main.tsx | 3 - src/common/spline.ts | 72 ++++++++ 4 files changed, 336 insertions(+), 62 deletions(-) create mode 100644 src/common/spline.ts diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index a715a629..8eda4e89 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -22,7 +22,7 @@ import { } from "../check-dark-mode"; const tileSize = 60; -const robotSize = tileSize / 2; +export const robotSize = tileSize / 2; const cellCount = 12; export type RobotState = { [robotId: string]: SimulatedRobotLocation }; @@ -32,7 +32,7 @@ export type RobotState = { [robotId: string]: SimulatedRobotLocation }; * * does not require physical robots to be connected * @returns the simulator screen as a card -*/ + */ export function Simulator() { const navigate = useNavigate(); @@ -204,13 +204,13 @@ const openInEditor = async (frame: StackFrame) => { export function RobotGrid({robotState}: {robotState: RobotState}) { return
+ style={{ + display: "grid", + gridTemplateColumns: `repeat(${cellCount}, ${tileSize}px)`, + gridTemplateRows: `repeat(${cellCount}, ${tileSize}px)`, + position: "relative", + }} + > {new Array(cellCount * cellCount) .fill(undefined) .map((_, i) => { @@ -231,14 +231,14 @@ export function RobotGrid({robotState}: {robotState: RobotState}) { /> ); })} - {/* TODO: implement onTopOfRobots */} - {Object.entries(robotState).map(([robotId, pos]) => ( - ( + - ))} + ))}
; } diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 1d1a745a..51a58b94 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -1,49 +1,254 @@ -import { useMemo, useState } from "react"; -import { RobotGrid, type RobotState } from "../debug/simulator"; -import { getProject, types } from "@theatre/core"; - -const robottype = {bezierpoint: types.compound({ - x: types.number(0, {range: [0, 60 * 12]}), - y: types.number(0, {range: [0, 60 * 12]}), - headingRadians: types.number(0, {range: [-Math.PI, Math.PI]}), -})} - -const demoSheet = getProject('Chess Bots').sheet('Routine') +import { + RefObject, + useEffect, + useMemo, + useReducer, + useRef, +} from "react"; +import { ContextMenu, Menu, MenuItem, MenuDivider } from "@blueprintjs/core"; +import { + Coords, +Point, + Spline, + SplinePointType, + splineToSvgDrawAttribute, +} from "../../common/spline"; +import { RobotGrid, robotSize } from "../debug/simulator"; export function Editor() { - const [robotCount, setRobotCount] = useState(0); - - const robotState = () => { - const output: RobotState = {}; - for (let robotIndex = 0; robotIndex < robotCount; robotIndex++) { - const robotId = `robot-${robotIndex}`; - const obj = demoSheet.__experimental_getExistingObject(robotId); - if (!obj) { - console.warn(`Could not find robot ${robotId}`); - continue; - } - - const bp = obj.value.bezierpoint; - output[robotId] = { - position: { - x: bp.x, - y: bp.y, - }, - headingRadians: bp.headingRadians, - } - - } - return output; - } - - const [rs, setRs] = useState(robotState()); return (
- - + + + + +
); -} \ No newline at end of file +} + +function SplineEditor({ initialSpline }: { initialSpline: Spline }) { + type SplineEditorAction = + | { type: "DELETE_POINT"; index: number } + | { type: "DELETE_START_POINT" } + | { type: "MOVE_POINT_ENDPOINT"; coords: Coords; index: number } + | { type: "MOVE_START_POINT"; coords: Coords }; + + const [spline, dispatch] = useReducer( + (state: Spline, action: SplineEditorAction): Spline => { + switch (action.type) { + case "DELETE_POINT": { + return { + ...state, + points: state.points.toSpliced(action.index, 1), + }; + } + case "DELETE_START_POINT": { + if (state.points.length === 0) return state; + return { + ...state, + start: { + type: SplinePointType.StartPoint, + point: state.points[0].endPoint, + }, + points: state.points.slice(1), + }; + } + case "MOVE_POINT_ENDPOINT": { + const newPoints = [...state.points]; + newPoints[action.index].endPoint = action.coords; + return { ...state, points: newPoints }; + } + case "MOVE_START_POINT": { + return { + ...state, + start: { + type: SplinePointType.StartPoint, + point: action.coords, + }, + }; + } + } + + return state; + }, + initialSpline, + ); + const path = useMemo(() => splineToSvgDrawAttribute(spline), [spline]); + return ( + <> + + + + 0 ? + () => dispatch({ type: "DELETE_START_POINT" }) + : undefined + } + moveFn={(x, y) => + dispatch({ type: "MOVE_START_POINT", coords: { x, y } }) + } + /> + {spline.points.map((point, index) => ( + dispatch({ type: "DELETE_POINT", index })} + moveFn={(x, y) => + dispatch({ + type: "MOVE_POINT_ENDPOINT", + coords: { x, y }, + index, + }) + } + /> + ))} + + ); +} + +function SplinePoint({ + point, + moveFn, + deleteFn = undefined, +}: { + point: Point; + moveFn: (x: number, y: number) => void; + deleteFn?: () => void; +}) { + // TODO: fix context menu positioning + // TODO: implement remaining context menu methods + + const ref = useRef(null); + useDraggable(ref, (screenX, screenY)=>moveFn(screenX + robotSize / 4, screenY + robotSize / 4)); + + const mainPointColor = + point.type === SplinePointType.StartPoint ? "blue" : "black"; + const mainCoords = + point.type === SplinePointType.StartPoint ? + point.point + : point.endPoint; + + return ( + + {point.type === SplinePointType.CubicBezier && ( + + )} + {point.type === SplinePointType.QuadraticBezier && ( + + )} + + + + + } + > + + + ); +} + + + +function useDraggable( + elRef: RefObject, + updatePosition: (screenX: number, screenY: number) => void, +) { + useEffect(() => { + const el = elRef.current; + if (!el) return; + + let pos1 = 0, + pos2 = 0, + pos3 = 0, + pos4 = 0; + + const onMouseDown = (e) => { + e.preventDefault(); + // get the mouse cursor position at startup: + pos3 = e.clientX; + pos4 = e.clientY; + document.onmouseup = closeDragElement; + // call a fn whenever the cursor moves: + document.onmousemove = elementDrag; + } + + const elementDrag = (e) => { + e.preventDefault(); + pos1 = pos3 - e.clientX; + pos2 = pos4 - e.clientY; + pos3 = e.clientX; + pos4 = e.clientY; + const x = el.offsetLeft - pos1 + const y = el.offsetTop - pos2 + updatePosition(x,y) + } + + const closeDragElement = () => { + document.onmouseup = null; + document.onmousemove = null; + } + + + el.addEventListener("mousedown", onMouseDown) + + return () => { + el.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("mousemove", elementDrag); + document.removeEventListener("mouseup", closeDragElement); + }; + }, [elRef, updatePosition]); +} diff --git a/src/client/main.tsx b/src/client/main.tsx index ec946310..3e18e8b8 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -14,9 +14,6 @@ export const queryClient = new QueryClient(); FocusStyleManager.onlyShowFocusOnTabs(); -import studio from '@theatre/studio' - -studio.initialize() //creates the root of the page and forwards control to the page router ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/common/spline.ts b/src/common/spline.ts new file mode 100644 index 00000000..e8cb74fd --- /dev/null +++ b/src/common/spline.ts @@ -0,0 +1,72 @@ +import { + Number as NumberType, + Record, + Union, + Array, + Literal, + Static, +} from "runtypes"; + +export enum SplinePointType { + StartPoint = "start", + CubicBezier = "cubic", + QuadraticBezier = "quadratic", +} + +export const CoordsSchema = Record({ + x: NumberType, + y: NumberType, +}); +export type Coords = Static + +export const StartPointSchema = Record({ + type: Literal(SplinePointType.StartPoint), + point: CoordsSchema, +}); +export type StartPointSchema = Static; + +export const CubicBezierSchema = Record({ + type: Literal(SplinePointType.CubicBezier), + controlPoint: CoordsSchema, + endPoint: CoordsSchema, +}); + +export const QuadraticBezierSchema = Record({ + type: Literal(SplinePointType.QuadraticBezier), + endPoint: CoordsSchema, +}); + +const Midpoint = Union(CubicBezierSchema, QuadraticBezierSchema); +export type Midpoint = Static; + +export const Spline = Record({ + start: StartPointSchema, + points: Array(Midpoint), +}); +export type Spline = Static; + +export const PointSchema = Union( + StartPointSchema, + CubicBezierSchema, + QuadraticBezierSchema, +); +export type Point = Static; + +function pointToSvgPathCommand(point: Point): string { + switch (point.type) { + case SplinePointType.StartPoint: + return `M${point.point.x},${point.point.y}`; + case SplinePointType.CubicBezier: + return `S${point.controlPoint.x},${point.controlPoint.y} ${point.endPoint.x},${point.endPoint.y}`; + case SplinePointType.QuadraticBezier: + return `T${point.endPoint.x},${point.endPoint.y}`; + } +} + +export function splineToSvgDrawAttribute(spline: Spline): string { + let path = pointToSvgPathCommand(spline.start); + for (const point of spline.points) { + path += ` ${pointToSvgPathCommand(point)}`; + } + return path; +} From 23eebe97611953f6cb0a9e0dde115216c211fd0c Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 25 Mar 2025 03:49:45 -0500 Subject: [PATCH 06/92] Format --- src/client/debug/simulator.tsx | 24 +++++++++++--------- src/client/editor/editor.tsx | 41 +++++++++++++--------------------- src/client/main.tsx | 1 - src/common/spline.ts | 2 +- 4 files changed, 29 insertions(+), 39 deletions(-) diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index 8eda4e89..d6e667e6 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -36,7 +36,6 @@ export type RobotState = { [robotId: string]: SimulatedRobotLocation }; export function Simulator() { const navigate = useNavigate(); - type Action = | { type: "SET_ALL_ROBOTS"; payload: RobotState } | { @@ -202,8 +201,9 @@ const openInEditor = async (frame: StackFrame) => { await fetch(`/__open-in-editor?${params.toString()}`); }; -export function RobotGrid({robotState}: {robotState: RobotState}) { - return
- {new Array(cellCount * cellCount) - .fill(undefined) - .map((_, i) => { + {new Array(cellCount * cellCount).fill(undefined).map((_, i) => { const row = Math.floor(i / cellCount); const col = i % cellCount; - const isCenterCell = row >= 2 && row < 10 && col >= 2 && col < 10; + const isCenterCell = + row >= 2 && row < 10 && col >= 2 && col < 10; return (
); @@ -237,9 +237,11 @@ export function RobotGrid({robotState}: {robotState: RobotState}) { pos={pos} robotId={robotId} key={robotId} - onTopOfRobots={[]} /> + onTopOfRobots={[]} + /> ))} -
; +
+ ); } /** diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 51a58b94..201d1d72 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -1,27 +1,18 @@ -import { - RefObject, - useEffect, - useMemo, - useReducer, - useRef, -} from "react"; +import { RefObject, useEffect, useMemo, useReducer, useRef } from "react"; import { ContextMenu, Menu, MenuItem, MenuDivider } from "@blueprintjs/core"; import { Coords, -Point, + Point, Spline, SplinePointType, splineToSvgDrawAttribute, } from "../../common/spline"; import { RobotGrid, robotSize } from "../debug/simulator"; export function Editor() { - return (
- - - - + - -
+
); } @@ -154,7 +144,9 @@ function SplinePoint({ // TODO: implement remaining context menu methods const ref = useRef(null); - useDraggable(ref, (screenX, screenY)=>moveFn(screenX + robotSize / 4, screenY + robotSize / 4)); + useDraggable(ref, (screenX, screenY) => + moveFn(screenX + robotSize / 4, screenY + robotSize / 4), + ); const mainPointColor = point.type === SplinePointType.StartPoint ? "blue" : "black"; @@ -201,8 +193,6 @@ function SplinePoint({ ); } - - function useDraggable( elRef: RefObject, updatePosition: (screenX: number, screenY: number) => void, @@ -224,7 +214,7 @@ function useDraggable( document.onmouseup = closeDragElement; // call a fn whenever the cursor moves: document.onmousemove = elementDrag; - } + }; const elementDrag = (e) => { e.preventDefault(); @@ -232,18 +222,17 @@ function useDraggable( pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; - const x = el.offsetLeft - pos1 - const y = el.offsetTop - pos2 - updatePosition(x,y) - } + const x = el.offsetLeft - pos1; + const y = el.offsetTop - pos2; + updatePosition(x, y); + }; const closeDragElement = () => { document.onmouseup = null; document.onmousemove = null; - } - + }; - el.addEventListener("mousedown", onMouseDown) + el.addEventListener("mousedown", onMouseDown); return () => { el.removeEventListener("mousedown", onMouseDown); diff --git a/src/client/main.tsx b/src/client/main.tsx index 3e18e8b8..ae399943 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -14,7 +14,6 @@ export const queryClient = new QueryClient(); FocusStyleManager.onlyShowFocusOnTabs(); - //creates the root of the page and forwards control to the page router ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( <> diff --git a/src/common/spline.ts b/src/common/spline.ts index e8cb74fd..35bbaa44 100644 --- a/src/common/spline.ts +++ b/src/common/spline.ts @@ -17,7 +17,7 @@ export const CoordsSchema = Record({ x: NumberType, y: NumberType, }); -export type Coords = Static +export type Coords = Static; export const StartPointSchema = Record({ type: Literal(SplinePointType.StartPoint), From dc130b20147851bdb0695340625e30f13e14e084 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 25 Mar 2025 14:02:01 -0500 Subject: [PATCH 07/92] Downgrade yarn version, install motion Needed to downgrade due to this issue I encountered when installing Framer Motion: https://github.com/yarnpkg/yarn/issues/7807 --- package.json | 2 +- src/client/package.json | 7 ++++--- yarn.lock | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e179fb94..a34f2ea2 100644 --- a/package.json +++ b/package.json @@ -46,5 +46,5 @@ "optionalDependencies": { "bufferutil": "^4.0.8" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "packageManager": "yarn@1.19.0+sha512.40b88ca23f991e8da44f5ef1d6dedeaceea0cd1fbdc526b9cfb2e67a2d6a60cd528f7ef088816febb910707fa792c86c3b47f4dc89970a57e410a5209ec32b79" } diff --git a/src/client/package.json b/src/client/package.json index fe6afcfa..d541acdf 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -6,15 +6,16 @@ "@blueprintjs/icons": "^5.20.0", "@blueprintjs/select": "^5.3.18", "@tanstack/react-query": "^5.29.0", + "@theatre/core": "^0.7.2", + "@theatre/studio": "^0.7.2", + "motion": "^12.6.0", "react": "^18.2.0", "react-chessboard": "^4.5.0", "react-dom": "^18.2.0", "react-joystick-component": "^6.2.1", "react-router-dom": "^6.21.3", "react-use-websocket": "^4.5.0", - "scss": "^0.2.4", - "@theatre/core": "^0.7.2", - "@theatre/studio": "^0.7.2" + "scss": "^0.2.4" }, "devDependencies": { "@types/react": "^18.2.48", diff --git a/yarn.lock b/yarn.lock index 389cee19..096f8a98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2009,6 +2009,15 @@ forwarded@0.2.0: resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== +framer-motion@^12.6.0: + version "12.6.0" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.6.0.tgz#0c41105069d64b790a0d1460b51e4221435cf982" + integrity sha512-91XLZ3VwDlXe9u2ABhTzYBiFQ/qdoiqyTiTCQDDJ4es5/5lzp76hdB+WG7gcNklcQlOmfDZQqVO48tqzY9Z/bQ== + dependencies: + motion-dom "^12.6.0" + motion-utils "^12.5.0" + tslib "^2.4.0" + fresh@0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" @@ -2506,6 +2515,26 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" +motion-dom@^12.6.0: + version "12.6.0" + resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.6.0.tgz#f9fb2499cd7908f7700f5f87295693e47e5a1b74" + integrity sha512-1s/+/V0ny/gfhocSSf0qhkspZK2da7jrwGw7xHzgiQPcimdHaPRcRCoJ3OxEZYBNzy3ma1ERUD+eUStk6a9pQw== + dependencies: + motion-utils "^12.5.0" + +motion-utils@^12.5.0: + version "12.5.0" + resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.5.0.tgz#0e42a6b8030327166ca0d0ea0d5b86d64c21e51d" + integrity sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA== + +motion@^12.6.0: + version "12.6.0" + resolved "https://registry.yarnpkg.com/motion/-/motion-12.6.0.tgz#cbf1451e70c7b105e6d4da7f1fcabf6a9631452e" + integrity sha512-YbwQAeVOhQwTi6I8iA+e8TfsduoV0cbE+k9MDeonlDyN04uz5jiVWKeBR0nXgGyGICnmcTaKe/wsQbHkkM3EMw== + dependencies: + framer-motion "^12.6.0" + tslib "^2.4.0" + ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -3363,6 +3392,11 @@ tslib@^2.0.0, tslib@^2.0.3, tslib@~2.6.2: resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" From 44400e4f1b9bf82c8dd3600f48f471e5520d17cb Mon Sep 17 00:00:00 2001 From: Mason Thomas Date: Mon, 31 Mar 2025 21:16:08 -0500 Subject: [PATCH 08/92] Create bots on connection in bot manager. start parallel command testing Co-authored-by: Gabriel Burbach --- src/server/api/api.ts | 7 ++++++- src/server/api/bot-server-config.json | 2 +- src/server/api/tcp-interface.ts | 4 +++- src/server/robot/robot-manager.ts | 9 +++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/server/api/api.ts b/src/server/api/api.ts index ba53e6da..b221b213 100644 --- a/src/server/api/api.ts +++ b/src/server/api/api.ts @@ -32,6 +32,8 @@ import { VirtualBotTunnel, virtualRobots } from "../simulator"; import { Position } from "../robot/position"; import { DEGREE } from "../../common/units"; import { PacketType } from "../utils/tcp-packet"; +import { AbsoluteMoveCommand } from "../command/move-command"; +import { ParallelCommandGroup } from "../command/command"; export const executor = new CommandExecutor(); @@ -208,10 +210,13 @@ apiRouter.get("/do-parallel", async (_, res) => { console.log("Starting parallel command group"); const robotsEntries = Array.from(robotManager.idsToRobots.entries()); console.log(robotsEntries); + const commands: any[] = []; for (const [, robot] of robotsEntries) { console.log("Moving robot " + robot.id); - await robot.sendDrivePacket(1); + // await robot.sendDrivePacket(1); + commands.push(new AbsoluteMoveCommand(robot.id, new Position(5, 5 ))); } + new ParallelCommandGroup(commands).execute(); res.send({ message: "success" }); }); diff --git a/src/server/api/bot-server-config.json b/src/server/api/bot-server-config.json index 533e132b..a9b1d7e8 100644 --- a/src/server/api/bot-server-config.json +++ b/src/server/api/bot-server-config.json @@ -1,7 +1,7 @@ { "tcpServerPort": 3001, "bots": { - "80:65:99:4d:f3:f0": "robot-15", + "80:65:99:4d:f3:e8": "robot-15", "c0:4e:30:4b:68:76": "robot-11", "80:65:99:4d:f3:9a": "robot-3", "80:65:99:4d:f4:2a": "robot-4" diff --git a/src/server/api/tcp-interface.ts b/src/server/api/tcp-interface.ts index 8e847f6c..4824b1d3 100644 --- a/src/server/api/tcp-interface.ts +++ b/src/server/api/tcp-interface.ts @@ -340,7 +340,9 @@ export class TCPServer { config["bots"][mac] = id; } else { id = config["bots"][mac]; - this.robotManager.addRobot(this.robotManager.getRobot(id)); + if (!(id in this.robotManager.idsToRobots)) { + this.robotManager.createRobotFromId(id); + } console.log("Found address ID: " + id); } tunnel.id = id; diff --git a/src/server/robot/robot-manager.ts b/src/server/robot/robot-manager.ts index daabf1f4..a616e7eb 100644 --- a/src/server/robot/robot-manager.ts +++ b/src/server/robot/robot-manager.ts @@ -1,5 +1,6 @@ import { GridIndices } from "./grid-indices"; import { Robot } from "./robot"; +import config from "../api/bot-server-config.json"; /** * Stores robots. Provides utilities for finding them by position. @@ -24,6 +25,14 @@ export class RobotManager { this.indicesToIds.set(JSON.stringify(robot.defaultIndices), robot.id); } + createRobotFromId(robotId: string) { + const robot = new Robot( + robotId, + config[robotId].homePosition, + config[robotId].defaultPosition, + ); + this.addRobot(robot); + } /** * Retrieves a robot by id. * Throws if no robot is found. From a93a44b7fc6f212ed1b2c4990d300a70abc013ea Mon Sep 17 00:00:00 2001 From: democat Date: Mon, 31 Mar 2025 21:25:09 -0500 Subject: [PATCH 09/92] Load startHeading --- src/server/api/bot-server-config.json | 116 +++++++++++++++----------- src/server/robot/robot-manager.ts | 2 + 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/src/server/api/bot-server-config.json b/src/server/api/bot-server-config.json index a9b1d7e8..2d353964 100644 --- a/src/server/api/bot-server-config.json +++ b/src/server/api/bot-server-config.json @@ -15,7 +15,8 @@ "defaultPosition": { "x": 2, "y": 2 - } + }, + "startHeading": 90 }, "robot-2": { "attributes": {}, @@ -26,7 +27,8 @@ "defaultPosition": { "x": 3, "y": 2 - } + }, + "startHeading": 90 }, "robot-3": { "attributes": {}, @@ -37,7 +39,8 @@ "defaultPosition": { "x": 4, "y": 2 - } + }, + "startHeading": 90 }, "robot-4": { "attributes": {}, @@ -48,7 +51,8 @@ "defaultPosition": { "x": 5, "y": 2 - } + }, + "startHeading": 90 }, "robot-5": { "attributes": {}, @@ -59,7 +63,8 @@ "defaultPosition": { "x": 6, "y": 2 - } + }, + "startHeading": 90 }, "robot-6": { "attributes": {}, @@ -70,7 +75,8 @@ "defaultPosition": { "x": 7, "y": 2 - } + }, + "startHeading": 90 }, "robot-7": { "attributes": {}, @@ -81,7 +87,8 @@ "defaultPosition": { "x": 8, "y": 2 - } + }, + "startHeading": 90 }, "robot-8": { "attributes": {}, @@ -92,7 +99,8 @@ "defaultPosition": { "x": 9, "y": 2 - } + }, + "startHeading": 90 }, "robot-9": { "attributes": {}, @@ -103,7 +111,8 @@ "defaultPosition": { "x": 2, "y": 3 - } + }, + "startHeading": 90 }, "robot-10": { "attributes": {}, @@ -114,7 +123,8 @@ "defaultPosition": { "x": 3, "y": 3 - } + }, + "startHeading": 90 }, "robot-11": { "attributes": {}, @@ -125,7 +135,8 @@ "defaultPosition": { "x": 4, "y": 3 - } + }, + "startHeading": 90 }, "robot-12": { "attributes": {}, @@ -136,7 +147,8 @@ "defaultPosition": { "x": 5, "y": 3 - } + }, + "startHeading": 90 }, "robot-13": { "attributes": {}, @@ -147,7 +159,8 @@ "defaultPosition": { "x": 6, "y": 3 - } + }, + "startHeading": 90 }, "robot-14": { "attributes": {}, @@ -158,13 +171,11 @@ "defaultPosition": { "x": 7, "y": 3 - } + }, + "startHeading": 90 }, "robot-15": { - "attributes": { - "MOTOR_A_DRIVE_MULTIPLIER": 1.3, - "MOTOR_B_DRIVE_MULTIPLIER": 2 - }, + "attributes": {}, "homePosition": { "x": 11, "y": 3 @@ -172,7 +183,8 @@ "defaultPosition": { "x": 8, "y": 3 - } + }, + "startHeading": 90 }, "robot-16": { "attributes": {}, @@ -183,7 +195,8 @@ "defaultPosition": { "x": 9, "y": 3 - } + }, + "startHeading": 90 }, "robot-17": { "attributes": {}, @@ -194,7 +207,8 @@ "defaultPosition": { "x": 2, "y": 8 - } + }, + "startHeading": 270 }, "robot-18": { "attributes": {}, @@ -205,7 +219,8 @@ "defaultPosition": { "x": 3, "y": 8 - } + }, + "startHeading": 270 }, "robot-19": { "attributes": {}, @@ -216,7 +231,8 @@ "defaultPosition": { "x": 4, "y": 8 - } + }, + "startHeading": 270 }, "robot-20": { "attributes": {}, @@ -227,7 +243,8 @@ "defaultPosition": { "x": 5, "y": 8 - } + }, + "startHeading": 270 }, "robot-21": { "attributes": {}, @@ -238,7 +255,8 @@ "defaultPosition": { "x": 6, "y": 8 - } + }, + "startHeading": 270 }, "robot-22": { "attributes": {}, @@ -249,7 +267,8 @@ "defaultPosition": { "x": 7, "y": 8 - } + }, + "startHeading": 270 }, "robot-23": { "attributes": {}, @@ -260,7 +279,8 @@ "defaultPosition": { "x": 8, "y": 8 - } + }, + "startHeading": 270 }, "robot-24": { "attributes": {}, @@ -271,7 +291,8 @@ "defaultPosition": { "x": 9, "y": 8 - } + }, + "startHeading": 270 }, "robot-25": { "attributes": {}, @@ -282,7 +303,8 @@ "defaultPosition": { "x": 2, "y": 9 - } + }, + "startHeading": 270 }, "robot-26": { "attributes": {}, @@ -293,7 +315,8 @@ "defaultPosition": { "x": 3, "y": 9 - } + }, + "startHeading": 270 }, "robot-27": { "attributes": {}, @@ -304,7 +327,8 @@ "defaultPosition": { "x": 4, "y": 9 - } + }, + "startHeading": 270 }, "robot-28": { "attributes": {}, @@ -315,7 +339,8 @@ "defaultPosition": { "x": 5, "y": 9 - } + }, + "startHeading": 270 }, "robot-29": { "attributes": {}, @@ -326,7 +351,8 @@ "defaultPosition": { "x": 6, "y": 9 - } + }, + "startHeading": 270 }, "robot-30": { "attributes": {}, @@ -337,7 +363,8 @@ "defaultPosition": { "x": 7, "y": 9 - } + }, + "startHeading": 270 }, "robot-31": { "attributes": {}, @@ -348,7 +375,8 @@ "defaultPosition": { "x": 8, "y": 9 - } + }, + "startHeading": 270 }, "robot-32": { "attributes": {}, @@ -359,7 +387,8 @@ "defaultPosition": { "x": 9, "y": 9 - } + }, + "startHeading": 270 }, "botConfigSchema": [ { @@ -435,21 +464,6 @@ "name": "WHEEL_DIAMETER_INCHES", "type": "float", "default_value": 4.375 - }, - { - "name": "MOTOR_A_DRIVE_MULTIPLIER", - "type": "float", - "default_value": -1.0 - }, - { - "name": "MOTOR_B_DRIVE_MULTIPLIER", - "type": "float", - "default_value": 1.0 - }, - { - "name": "ENCODER_MULTIPLIER", - "type": "float", - "default_value": -12000.0 } ] } diff --git a/src/server/robot/robot-manager.ts b/src/server/robot/robot-manager.ts index a616e7eb..a12fa759 100644 --- a/src/server/robot/robot-manager.ts +++ b/src/server/robot/robot-manager.ts @@ -1,6 +1,7 @@ import { GridIndices } from "./grid-indices"; import { Robot } from "./robot"; import config from "../api/bot-server-config.json"; +import { DEGREE } from "../../common/units"; /** * Stores robots. Provides utilities for finding them by position. @@ -30,6 +31,7 @@ export class RobotManager { robotId, config[robotId].homePosition, config[robotId].defaultPosition, + config[robotId].startHeading * DEGREE, ); this.addRobot(robot); } From dd96950cdfe3fb0497cc3859f9a999e8335918a2 Mon Sep 17 00:00:00 2001 From: Mason Thomas Date: Mon, 31 Mar 2025 23:53:10 -0500 Subject: [PATCH 10/92] =?UTF-8?q?drive=20tick=20command=20implementation!?= =?UTF-8?q?=20=F0=9F=9A=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Colin Wong --- src/server/api/api.ts | 6 +++--- src/server/command/move-command.ts | 21 +++++++++++++++++++++ src/server/robot/robot.ts | 11 +++++++++++ src/server/utils/tcp-packet.ts | 8 ++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/server/api/api.ts b/src/server/api/api.ts index b221b213..aac19d93 100644 --- a/src/server/api/api.ts +++ b/src/server/api/api.ts @@ -32,7 +32,7 @@ import { VirtualBotTunnel, virtualRobots } from "../simulator"; import { Position } from "../robot/position"; import { DEGREE } from "../../common/units"; import { PacketType } from "../utils/tcp-packet"; -import { AbsoluteMoveCommand } from "../command/move-command"; +import { DriveTicksCommand } from "../command/move-command"; import { ParallelCommandGroup } from "../command/command"; export const executor = new CommandExecutor(); @@ -214,9 +214,9 @@ apiRouter.get("/do-parallel", async (_, res) => { for (const [, robot] of robotsEntries) { console.log("Moving robot " + robot.id); // await robot.sendDrivePacket(1); - commands.push(new AbsoluteMoveCommand(robot.id, new Position(5, 5 ))); + commands.push(new DriveTicksCommand(robot.id, 200000)); } - new ParallelCommandGroup(commands).execute(); + await new ParallelCommandGroup(commands).execute(); res.send({ message: "success" }); }); diff --git a/src/server/command/move-command.ts b/src/server/command/move-command.ts index bb134489..c1b5f82c 100644 --- a/src/server/command/move-command.ts +++ b/src/server/command/move-command.ts @@ -182,6 +182,27 @@ export class AbsoluteMoveCommand extends MoveCommand { } } +export class DriveTicksCommand + extends RobotCommand + implements Reversible +{ + constructor( + robotId: string, + public ticksDistance: number, + ) { + super(robotId); + } + + public async execute(): Promise { + const robot = robotManager.getRobot(this.robotId); + return robot.sendDriveTicksPacket(this.ticksDistance); + } + + public reverse(): DriveTicksCommand { + return new DriveTicksCommand(this.robotId, -this.ticksDistance); + } +} + /** * Moves a robot to a global location. Implements Reversible through a * position supplier to return to the previous position. diff --git a/src/server/robot/robot.ts b/src/server/robot/robot.ts index adda5925..5089904f 100644 --- a/src/server/robot/robot.ts +++ b/src/server/robot/robot.ts @@ -115,4 +115,15 @@ export class Robot { console.log(tileDistance); await tunnel.send({ type: PacketType.DRIVE_TANK, left: 1, right: 1 }); } + + /** + * Send a packet to the robot indicating distance to drive, in ticks. Returns a promise that finishes when the + * robot finishes the action. + * + * @param distanceTicks - The distance to drive forward or backwards by, in ticks. + */ + public async sendDriveTicksPacket(distanceTicks: number): Promise { + const tunnel = this.getTunnel(); + await tunnel.send({ type: PacketType.DRIVE_TICKS, tickDistance: distanceTicks }); + } } diff --git a/src/server/utils/tcp-packet.ts b/src/server/utils/tcp-packet.ts index 33abbd42..4ad65c7c 100644 --- a/src/server/utils/tcp-packet.ts +++ b/src/server/utils/tcp-packet.ts @@ -18,6 +18,7 @@ export enum PacketType { SET_VAR = "SET_VAR", TURN_BY_ANGLE = "TURN_BY_ANGLE", DRIVE_TILES = "DRIVE_TILES", + DRIVE_TICKS = "DRIVE_TICKS", ACTION_SUCCESS = "ACTION_SUCCESS", ACTION_FAIL = "ACTION_FAIL", DRIVE_TANK = "DRIVE_TANK", @@ -126,6 +127,12 @@ export const DRIVE_TILES_SCHEMA = Record({ type: Literal(PacketType.DRIVE_TILES), tileDistance: Float, }); + +export const DRIVE_TICKS_SCHEMA = Record({ + type: Literal(PacketType.DRIVE_TICKS), + tickDistance: Int32, +}); + /** success message */ export const ACTION_SUCCESS_SCHEMA = Record({ type: Literal(PacketType.ACTION_SUCCESS), @@ -157,6 +164,7 @@ export const Packet = Union( SET_VAR_SCHEMA, TURN_BY_ANGLE_SCHEMA, DRIVE_TILES_SCHEMA, + DRIVE_TICKS_SCHEMA, ACTION_SUCCESS_SCHEMA, ACTION_FAIL_SCHEMA, DRIVE_TANK_SCHEMA, From 421649badde5e05ebaeec8095c1427bf609622f4 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 1 Apr 2025 13:45:29 -0500 Subject: [PATCH 11/92] Implement remaining context menu methods, render control points --- src/client/editor/editor.tsx | 100 +++++++++++++++++++++++++++++++---- src/common/spline.ts | 2 + 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 201d1d72..49007b57 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -2,7 +2,9 @@ import { RefObject, useEffect, useMemo, useReducer, useRef } from "react"; import { ContextMenu, Menu, MenuItem, MenuDivider } from "@blueprintjs/core"; import { Coords, + CubicBezier, Point, + QuadraticBezier, Spline, SplinePointType, splineToSvgDrawAttribute, @@ -27,8 +29,11 @@ export function Editor() { endPoint: { x: 100, y: 100 }, }, { - type: SplinePointType.QuadraticBezier, + type: SplinePointType.CubicBezier, endPoint: { x: 315, y: 50 }, + controlPoint: { + x: 300, y: 40 + } }, { type: SplinePointType.QuadraticBezier, @@ -46,7 +51,10 @@ function SplineEditor({ initialSpline }: { initialSpline: Spline }) { | { type: "DELETE_POINT"; index: number } | { type: "DELETE_START_POINT" } | { type: "MOVE_POINT_ENDPOINT"; coords: Coords; index: number } - | { type: "MOVE_START_POINT"; coords: Coords }; + | { type: "MOVE_START_POINT"; coords: Coords } + | {type: "MOVE_CONTROL_POINT", coords: Coords, index: number} + | {type: "SWITCH_TO_QUADRATIC", index: number} + | {type: "SWITCH_TO_CUBIC", index: number} const [spline, dispatch] = useReducer( (state: Spline, action: SplineEditorAction): Spline => { @@ -82,6 +90,48 @@ function SplineEditor({ initialSpline }: { initialSpline: Spline }) { }, }; } + case "MOVE_CONTROL_POINT": { + const point = state.points[action.index] + if (point.type !== SplinePointType.CubicBezier) { + return state; + } + + point.controlPoint = action.coords + + const newPoints = [...state.points]; + newPoints[action.index] = point; + + return { ...state, points: newPoints }; + } + case "SWITCH_TO_QUADRATIC": { + const point = state.points[action.index] + if (point.type !== SplinePointType.CubicBezier) { + return state; + } + + const newPoint: QuadraticBezier = { type: SplinePointType.QuadraticBezier, endPoint: point.endPoint } + + const newPoints = [...state.points]; + newPoints[action.index] = newPoint; + + return { ...state, points: newPoints }; + } + case "SWITCH_TO_CUBIC": { + const point = state.points[action.index] + if (point.type !== SplinePointType.QuadraticBezier) { + return state; + } + + const newPoint: CubicBezier = { type: SplinePointType.CubicBezier, controlPoint: { + x: (point.endPoint.x) / 2, + y: (point.endPoint.y) / 2 + }, endPoint: point.endPoint } + + const newPoints = [...state.points]; + newPoints[action.index] = newPoint; + + return { ...state, points: newPoints }; + } } return state; @@ -101,7 +151,7 @@ function SplineEditor({ initialSpline }: { initialSpline: Spline }) { pointerEvents: "none", }} > - + {spline.points.map((point, index) => ( + <> dispatch({ type: "DELETE_POINT", index })} @@ -125,7 +176,12 @@ function SplineEditor({ initialSpline }: { initialSpline: Spline }) { index, }) } + switchToQuadraticFn={() => dispatch({ type: "SWITCH_TO_QUADRATIC", index })} + switchToCubicFn={() => dispatch({ type: "SWITCH_TO_CUBIC", index })} /> + {/* TODO: add line between control point and end point */} + {point.type === SplinePointType.CubicBezier && dispatch({ type: "MOVE_CONTROL_POINT", coords: { x, y }, index })} />} + ))} ); @@ -134,17 +190,20 @@ function SplineEditor({ initialSpline }: { initialSpline: Spline }) { function SplinePoint({ point, moveFn, + switchToQuadraticFn = undefined, + switchToCubicFn = undefined, deleteFn = undefined, }: { point: Point; moveFn: (x: number, y: number) => void; deleteFn?: () => void; + switchToQuadraticFn?: () => void, + switchToCubicFn?: () => void, }) { // TODO: fix context menu positioning - // TODO: implement remaining context menu methods - const ref = useRef(null); - useDraggable(ref, (screenX, screenY) => + const mainPointRef = useRef(null); + useDraggable(mainPointRef, (screenX, screenY) => moveFn(screenX + robotSize / 4, screenY + robotSize / 4), ); @@ -160,15 +219,15 @@ function SplinePoint({ content={ {point.type === SplinePointType.CubicBezier && ( - + )} {point.type === SplinePointType.QuadraticBezier && ( - + )} @@ -177,7 +236,7 @@ function SplinePoint({ } > void }) { + const controlPointRef = useRef(null); + useDraggable(controlPointRef, (screenX, screenY) => moveControlFn(screenX + robotSize / 4, screenY + robotSize / 4)) + + return ( + + ); +} + function useDraggable( elRef: RefObject, updatePosition: (screenX: number, screenY: number) => void, diff --git a/src/common/spline.ts b/src/common/spline.ts index 35bbaa44..05d16d17 100644 --- a/src/common/spline.ts +++ b/src/common/spline.ts @@ -30,11 +30,13 @@ export const CubicBezierSchema = Record({ controlPoint: CoordsSchema, endPoint: CoordsSchema, }); +export type CubicBezier = Static; export const QuadraticBezierSchema = Record({ type: Literal(SplinePointType.QuadraticBezier), endPoint: CoordsSchema, }); +export type QuadraticBezier = Static; const Midpoint = Union(CubicBezierSchema, QuadraticBezierSchema); export type Midpoint = Static; From ad539911e4da967ea3c39d7322b243e319d80a26 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sat, 12 Apr 2025 12:35:49 -0500 Subject: [PATCH 12/92] Allow robot grid to take children --- src/client/debug/simulator.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index d6e667e6..fd21df15 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -1,5 +1,5 @@ import { Card, Button, H1, Tooltip, Collapse } from "@blueprintjs/core"; -import { useEffect, useReducer, useRef, useState } from "react"; +import { type PropsWithChildren, useEffect, useReducer, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { get, useSocket } from "../api"; import { @@ -201,7 +201,7 @@ const openInEditor = async (frame: StackFrame) => { await fetch(`/__open-in-editor?${params.toString()}`); }; -export function RobotGrid({ robotState }: { robotState: RobotState }) { +export function RobotGrid({ robotState, children }: PropsWithChildren<{ robotState: RobotState }>) { return (
))} + {children}
); } From 68d6c8237b295481a3e0fbafa9461d93ab839814 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sat, 12 Apr 2025 13:00:02 -0500 Subject: [PATCH 13/92] Begin showfile schema --- src/common/show.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++ src/common/spline.ts | 10 +++---- 2 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 src/common/show.ts diff --git a/src/common/show.ts b/src/common/show.ts new file mode 100644 index 00000000..531cc59e --- /dev/null +++ b/src/common/show.ts @@ -0,0 +1,70 @@ +import { + Number, + String, + Record, + Array, + Literal, + Union, + type Static, + Null, + Optional, + Tuple, +} from "runtypes"; +import { + CoordsSchema, + MidpointSchema, + PointSchema, + StartPointSchema, +} from "./spline"; + +// JSON file would use the Showfile type. + +const AudioSchema = Record({ + startMs: Number, + // maybe we use something like capnproto, msgpack, or protobuf so we could + // include an audio file in the showfile. if we really wanted to use JSON we'd + // probably have to base64 encode the file 💀. would work, just feels a little scuffed. + // plus I've always wanted an excuse to use msgpack or capnproto lol + data: String, + tempoBpm: Union(Number, Null), +}); + +enum TimelineEventTypes { + GoToPointEvent = "goto_point", + WaitEvent = "wait", + StartPointEvent = "start_point", +} + +const GoToPointEventSchema = Record({ + durationMs: Number, + target: MidpointSchema, + type: Literal(TimelineEventTypes.GoToPointEvent), +}); + +const WaitEventSchema = Record({ + durationMs: Number, + type: Literal(TimelineEventTypes.WaitEvent), +}); + +const StartPointEventSchema = Record({ + type: Literal(TimelineEventTypes.StartPointEvent), + target: StartPointSchema, +}); + +const TimelineEventSchema = Union(GoToPointEventSchema, WaitEventSchema); +const TimelineLayerSchema = Tuple( + StartPointEventSchema, + Array(TimelineEventSchema), +); + +const ShowfileSchema = Record({ + $chessbots_show_schema_version: Literal(1), + timeline: Array(TimelineLayerSchema), + audio: Optional(AudioSchema), + name: String, +}); + +export type Audio = Static; +export type MovementEvent = Static; +export type TimelineLayer = Static; +export type Showfile = Static; diff --git a/src/common/spline.ts b/src/common/spline.ts index 05d16d17..0b1e522e 100644 --- a/src/common/spline.ts +++ b/src/common/spline.ts @@ -38,14 +38,14 @@ export const QuadraticBezierSchema = Record({ }); export type QuadraticBezier = Static; -const Midpoint = Union(CubicBezierSchema, QuadraticBezierSchema); -export type Midpoint = Static; +export const MidpointSchema = Union(CubicBezierSchema, QuadraticBezierSchema); +export type Midpoint = Static; -export const Spline = Record({ +export const SplineSchema = Record({ start: StartPointSchema, - points: Array(Midpoint), + points: Array(MidpointSchema), }); -export type Spline = Static; +export type Spline = Static; export const PointSchema = Union( StartPointSchema, From 98ba0a6a47576fd521a019212ab7c8920dd3fd94 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sat, 12 Apr 2025 13:20:42 -0500 Subject: [PATCH 14/92] Begin shell of timeline UI --- src/client/debug/simulator.tsx | 13 +- src/client/editor/editor.tsx | 321 +++++++++++++++++++++++++-------- src/client/package.json | 1 + yarn.lock | 5 + 4 files changed, 267 insertions(+), 73 deletions(-) diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index fd21df15..bf018f97 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -1,5 +1,11 @@ import { Card, Button, H1, Tooltip, Collapse } from "@blueprintjs/core"; -import { type PropsWithChildren, useEffect, useReducer, useRef, useState } from "react"; +import { + type PropsWithChildren, + useEffect, + useReducer, + useRef, + useState, +} from "react"; import { useNavigate } from "react-router-dom"; import { get, useSocket } from "../api"; import { @@ -201,7 +207,10 @@ const openInEditor = async (frame: StackFrame) => { await fetch(`/__open-in-editor?${params.toString()}`); }; -export function RobotGrid({ robotState, children }: PropsWithChildren<{ robotState: RobotState }>) { +export function RobotGrid({ + robotState, + children, +}: PropsWithChildren<{ robotState: RobotState }>) { return (
(null); + const [unsavedChanges, setUnsavedChanges] = + usePreventExitWithUnsavedChanges(); return ( -
- - + +
+

+ +

+ + {lastSaved ? + <> + Last saved + + : "Never saved"} + {unsavedChanges && ( + <> + {" "} + •{" "} + + Unsaved changes + + + )} + +
+ + +
); } +const TimelineRow = forwardRef< + HTMLDivElement, + PropsWithChildren<{ title: string }> +>(function TimelineCard(props, ref) { + const { title, children } = props; +// https://codepen.io/Kevin-Geary/pen/BavwqYX +// https://www.youtube.com/watch?v=EQYft7JPKto + return ( + + {title} + {children} + + ); +}); + function SplineEditor({ initialSpline }: { initialSpline: Spline }) { type SplineEditorAction = | { type: "DELETE_POINT"; index: number } | { type: "DELETE_START_POINT" } | { type: "MOVE_POINT_ENDPOINT"; coords: Coords; index: number } | { type: "MOVE_START_POINT"; coords: Coords } - | {type: "MOVE_CONTROL_POINT", coords: Coords, index: number} - | {type: "SWITCH_TO_QUADRATIC", index: number} - | {type: "SWITCH_TO_CUBIC", index: number} + | { type: "MOVE_CONTROL_POINT"; coords: Coords; index: number } + | { type: "SWITCH_TO_QUADRATIC"; index: number } + | { type: "SWITCH_TO_CUBIC"; index: number }; const [spline, dispatch] = useReducer( (state: Spline, action: SplineEditorAction): Spline => { @@ -91,42 +211,49 @@ function SplineEditor({ initialSpline }: { initialSpline: Spline }) { }; } case "MOVE_CONTROL_POINT": { - const point = state.points[action.index] + const point = state.points[action.index]; if (point.type !== SplinePointType.CubicBezier) { return state; } - - point.controlPoint = action.coords - + + point.controlPoint = action.coords; + const newPoints = [...state.points]; newPoints[action.index] = point; return { ...state, points: newPoints }; } case "SWITCH_TO_QUADRATIC": { - const point = state.points[action.index] + const point = state.points[action.index]; if (point.type !== SplinePointType.CubicBezier) { return state; } - - const newPoint: QuadraticBezier = { type: SplinePointType.QuadraticBezier, endPoint: point.endPoint } - + + const newPoint: QuadraticBezier = { + type: SplinePointType.QuadraticBezier, + endPoint: point.endPoint, + }; + const newPoints = [...state.points]; newPoints[action.index] = newPoint; return { ...state, points: newPoints }; } case "SWITCH_TO_CUBIC": { - const point = state.points[action.index] + const point = state.points[action.index]; if (point.type !== SplinePointType.QuadraticBezier) { return state; } - - const newPoint: CubicBezier = { type: SplinePointType.CubicBezier, controlPoint: { - x: (point.endPoint.x) / 2, - y: (point.endPoint.y) / 2 - }, endPoint: point.endPoint } - + + const newPoint: CubicBezier = { + type: SplinePointType.CubicBezier, + controlPoint: { + x: point.endPoint.x / 2, + y: point.endPoint.y / 2, + }, + endPoint: point.endPoint, + }; + const newPoints = [...state.points]; newPoints[action.index] = newPoint; @@ -166,21 +293,38 @@ function SplineEditor({ initialSpline }: { initialSpline: Spline }) { /> {spline.points.map((point, index) => ( <> - dispatch({ type: "DELETE_POINT", index })} - moveFn={(x, y) => - dispatch({ - type: "MOVE_POINT_ENDPOINT", - coords: { x, y }, - index, - }) - } - switchToQuadraticFn={() => dispatch({ type: "SWITCH_TO_QUADRATIC", index })} - switchToCubicFn={() => dispatch({ type: "SWITCH_TO_CUBIC", index })} - /> - {/* TODO: add line between control point and end point */} - {point.type === SplinePointType.CubicBezier && dispatch({ type: "MOVE_CONTROL_POINT", coords: { x, y }, index })} />} + + dispatch({ type: "DELETE_POINT", index }) + } + moveFn={(x, y) => + dispatch({ + type: "MOVE_POINT_ENDPOINT", + coords: { x, y }, + index, + }) + } + switchToQuadraticFn={() => + dispatch({ type: "SWITCH_TO_QUADRATIC", index }) + } + switchToCubicFn={() => + dispatch({ type: "SWITCH_TO_CUBIC", index }) + } + /> + {/* TODO: add line between control point and end point */} + {point.type === SplinePointType.CubicBezier && ( + + dispatch({ + type: "MOVE_CONTROL_POINT", + coords: { x, y }, + index, + }) + } + /> + )} ))} @@ -197,8 +341,8 @@ function SplinePoint({ point: Point; moveFn: (x: number, y: number) => void; deleteFn?: () => void; - switchToQuadraticFn?: () => void, - switchToCubicFn?: () => void, + switchToQuadraticFn?: () => void; + switchToCubicFn?: () => void; }) { // TODO: fix context menu positioning @@ -219,10 +363,16 @@ function SplinePoint({ content={ {point.type === SplinePointType.CubicBezier && ( - + )} {point.type === SplinePointType.QuadraticBezier && ( - + )} void }) { +function SplineControlPoint({ + point, + moveControlFn, +}: { + point: Coords; + moveControlFn: (x: number, y: number) => void; +}) { const controlPointRef = useRef(null); - useDraggable(controlPointRef, (screenX, screenY) => moveControlFn(screenX + robotSize / 4, screenY + robotSize / 4)) + useDraggable(controlPointRef, (screenX, screenY) => + moveControlFn(screenX + robotSize / 4, screenY + robotSize / 4), + ); return ( { + const onBeforeUnload = (e: BeforeUnloadEvent) => { + if (unsavedChanges) { + e.preventDefault(); + e.returnValue = ""; + } + }; + + window.addEventListener("beforeunload", onBeforeUnload); + + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [unsavedChanges]); + + return [unsavedChanges, setUnsavedChanges] as const; +} diff --git a/src/client/package.json b/src/client/package.json index d541acdf..8d187cbb 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -14,6 +14,7 @@ "react-dom": "^18.2.0", "react-joystick-component": "^6.2.1", "react-router-dom": "^6.21.3", + "react-timeago": "^8.0.0", "react-use-websocket": "^4.5.0", "scss": "^0.2.4" }, diff --git a/yarn.lock b/yarn.lock index 096f8a98..8177c4c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2966,6 +2966,11 @@ react-router@6.22.3: dependencies: "@remix-run/router" "1.15.3" +react-timeago@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/react-timeago/-/react-timeago-8.0.0.tgz#9a6ce075ffac0746fd6e27520ce936014129d767" + integrity sha512-I/Lsewkw87xryZVXU8mv8o2lsnxTd7uHBJF9fl76vRx/GkAFNctgSg9q0hOKvgv9V6cTXmDiuXJJYyblD4kGRA== + react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz" From 812b21b98ac085ee9febbb5fb2fadec1cd9f5c72 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sat, 12 Apr 2025 14:43:46 -0500 Subject: [PATCH 15/92] Add hotkeys, file opening/saving --- src/client/editor/editor.tsx | 302 +++++++++++++++++++++++++++-------- src/client/package.json | 5 +- src/common/show.ts | 65 +++++++- yarn.lock | 99 ++++++++---- 4 files changed, 373 insertions(+), 98 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 0175128a..4922bdc9 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -1,7 +1,9 @@ +import { decode as cborDecode, encode as cborEncode } from "cbor-x"; import { forwardRef, PropsWithChildren, type RefObject, + useCallback, useEffect, useMemo, useReducer, @@ -22,6 +24,8 @@ import { Card, ButtonGroup, Tag, + useHotkeys, + HotkeyConfig, } from "@blueprintjs/core"; import { Coords, @@ -33,15 +37,139 @@ import { splineToSvgDrawAttribute, } from "../../common/spline"; import { RobotGrid, robotSize } from "../debug/simulator"; -import TimeAgo from "react-timeago"; + +import { fileOpen, fileSave } from "browser-fs-access"; +import { + createNewShowfile, + ShowfileSchema, + timelineLayerToSpline, +} from "../../common/show"; export function Editor() { + const [initialShow, setInitialShow] = useState(createNewShowfile()); + const { + value: show, + canUndo, + canRedo, + undo, + redo, + } = useStateWithTrackedHistory(initialShow); + // TODO: fix viewport height / timeline height - const [lastSaved, setLastSaved] = useState(null); const [unsavedChanges, setUnsavedChanges] = usePreventExitWithUnsavedChanges(); + const [fsHandle, setFsHandle] = useState(null); + + const openShowfile = useCallback(async () => { + const blob = await fileOpen({ + mimeTypes: ["application/chessbots-showfile"], + extensions: ["cbor"], + }); + + let data: unknown | null = null; + try { + data = cborDecode(await blob.bytes()); + } catch (e) { + // TODO: toast or alert + console.warn("Failed to decode showfile as CBOR", e); + return; + } + + const show = ShowfileSchema.check(data); + + if (!show) { + // TODO: toast or alert + console.warn("File was CBOR but not a valid showfile", data); + return; + } + + setInitialShow(show); + if (blob.handle) setFsHandle(blob.handle); + + setUnsavedChanges(false); + }, []); + + function saveShowfileAs() { + // TODO: implement + setUnsavedChanges(false); + } + + async function saveShowfile() { + + const blob = new Blob([cborEncode(initialShow)], { + type: "application/chessbots-showfile", + }); + await fileSave( + blob, + { + mimeTypes: ["application/chessbots-showfile"], + extensions: ["cbor"], + fileName: show.name + ".cbor", + }, + fsHandle, + ); + + setUnsavedChanges(false); + } + + const hotkeys = useMemo( + () => [ + { + combo: "mod+s", + group: "Debug", + global: true, + label: "Save", + onKeyDown: (e) => { + e.preventDefault(); + saveShowfile(); + }, + }, + { + combo: "mod+z", + group: "Debug", + global: true, + label: "Undo", + onKeyDown: undo, + }, + { + combo: "mod+y", + group: "Debug", + global: true, + label: "Redo", + onKeyDown: redo, + }, + { + combo: "mod+shift+s", + group: "Debug", + global: true, + label: "Save As...", + onKeyDown: (e) => { + e.preventDefault(); + saveShowfileAs(); + }, + }, + { + combo: "mod+o", + group: "Debug", + global: true, + label: "Open...", + onKeyDown: (e) => { + e.preventDefault(); + openShowfile(); + }, + }, + ], + [redo, undo, saveShowfile, saveShowfileAs, openShowfile], + ); + + const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys); + return ( -
+
- - {lastSaved ? - <> - Last saved - - : "Never saved"} - {unsavedChanges && ( - <> - {" "} - •{" "} - - Unsaved changes - - - )} - + {unsavedChanges && + Unsaved changes + }
From 519d7a96d1fc43eda824330d8cfa831f41b15631 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sat, 12 Apr 2025 15:18:26 -0500 Subject: [PATCH 18/92] Refactor editor into separate files --- src/client/editor/editor.tsx | 463 ++-------------------------- src/client/editor/hooks.ts | 137 ++++++++ src/client/editor/points.tsx | 103 +++++++ src/client/editor/spline-editor.tsx | 175 +++++++++++ 4 files changed, 441 insertions(+), 437 deletions(-) create mode 100644 src/client/editor/hooks.ts create mode 100644 src/client/editor/points.tsx create mode 100644 src/client/editor/spline-editor.tsx diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 9375eaaf..b9a40b8f 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -1,20 +1,13 @@ import { decode as cborDecode, encode as cborEncode } from "cbor-x"; import { forwardRef, - PropsWithChildren, - type RefObject, + type PropsWithChildren, useCallback, useEffect, useMemo, - useReducer, - useRef, useState, } from "react"; import { - ContextMenu, - Menu, - MenuItem, - MenuDivider, Section, Text, EditableText, @@ -25,18 +18,9 @@ import { ButtonGroup, Tag, useHotkeys, - HotkeyConfig, + type HotkeyConfig, } from "@blueprintjs/core"; -import { - Coords, - CubicBezier, - Point, - QuadraticBezier, - Spline, - SplinePointType, - splineToSvgDrawAttribute, -} from "../../common/spline"; -import { RobotGrid, robotSize } from "../debug/simulator"; +import { RobotGrid } from "../debug/simulator"; import { fileOpen, fileSave } from "browser-fs-access"; import { @@ -45,9 +29,15 @@ import { timelineLayerToSpline, } from "../../common/show"; import { diff } from "deep-object-diff"; +import { + usePreventExitWithUnsavedChanges, + useStateWithTrackedHistory, +} from "./hooks"; +import { SplineEditor } from "./spline-editor"; export function Editor() { const [initialShow, setInitialShow] = useState(createNewShowfile()); + const [fsHandle, setFsHandle] = useState(null); const { value: show, setValue: setShow, @@ -70,8 +60,6 @@ export function Editor() { } }, [initialShow, show, setUnsavedChanges]); - const [fsHandle, setFsHandle] = useState(null); - const openShowfile = useCallback(async () => { const blob = await fileOpen({ mimeTypes: ["application/chessbots-showfile"], @@ -100,9 +88,9 @@ export function Editor() { if (blob.handle) setFsHandle(blob.handle); console.log(show); - }, []); + }, [setShow]); - async function saveShowfile() { + const saveShowfile = useCallback(async () => { const blob = new Blob([cborEncode(show)], { type: "application/chessbots-showfile", }); @@ -117,13 +105,13 @@ export function Editor() { ); setInitialShow(show); - } + }, [fsHandle, show]); const hotkeys = useMemo( () => [ { combo: "mod+s", - group: "Debug", + group: "File", global: true, label: "Save", onKeyDown: (e) => { @@ -131,30 +119,30 @@ export function Editor() { saveShowfile(); }, }, + { + combo: "mod+o", + group: "File", + global: true, + label: "Open...", + onKeyDown: (e) => { + e.preventDefault(); + openShowfile(); + }, + }, { combo: "mod+z", - group: "Debug", + group: "Edit", global: true, label: "Undo", onKeyDown: undo, }, { combo: "mod+y", - group: "Debug", + group: "Edit", global: true, label: "Redo", onKeyDown: redo, }, - { - combo: "mod+o", - group: "Debug", - global: true, - label: "Open...", - onKeyDown: (e) => { - e.preventDefault(); - openShowfile(); - }, - }, ], [redo, undo, saveShowfile, openShowfile], ); @@ -262,6 +250,7 @@ const TimelineRow = forwardRef< PropsWithChildren<{ title: string }> >(function TimelineCard(props, ref) { const { title, children } = props; + // TODO: add borders between columns. v low priority // https://codepen.io/Kevin-Geary/pen/BavwqYX // https://www.youtube.com/watch?v=EQYft7JPKto return ( @@ -278,403 +267,3 @@ const TimelineRow = forwardRef< ); }); - -function SplineEditor({ initialSpline }: { initialSpline: Spline }) { - type SplineEditorAction = - | { type: "DELETE_POINT"; index: number } - | { type: "DELETE_START_POINT" } - | { type: "MOVE_POINT_ENDPOINT"; coords: Coords; index: number } - | { type: "MOVE_START_POINT"; coords: Coords } - | { type: "MOVE_CONTROL_POINT"; coords: Coords; index: number } - | { type: "SWITCH_TO_QUADRATIC"; index: number } - | { type: "SWITCH_TO_CUBIC"; index: number }; - - const [spline, dispatch] = useReducer( - (state: Spline, action: SplineEditorAction): Spline => { - switch (action.type) { - case "DELETE_POINT": { - return { - ...state, - points: state.points.toSpliced(action.index, 1), - }; - } - case "DELETE_START_POINT": { - if (state.points.length === 0) return state; - return { - ...state, - start: { - type: SplinePointType.StartPoint, - point: state.points[0].endPoint, - }, - points: state.points.slice(1), - }; - } - case "MOVE_POINT_ENDPOINT": { - const newPoints = [...state.points]; - newPoints[action.index].endPoint = action.coords; - return { ...state, points: newPoints }; - } - case "MOVE_START_POINT": { - return { - ...state, - start: { - type: SplinePointType.StartPoint, - point: action.coords, - }, - }; - } - case "MOVE_CONTROL_POINT": { - const point = state.points[action.index]; - if (point.type !== SplinePointType.CubicBezier) { - return state; - } - - point.controlPoint = action.coords; - - const newPoints = [...state.points]; - newPoints[action.index] = point; - - return { ...state, points: newPoints }; - } - case "SWITCH_TO_QUADRATIC": { - const point = state.points[action.index]; - if (point.type !== SplinePointType.CubicBezier) { - return state; - } - - const newPoint: QuadraticBezier = { - type: SplinePointType.QuadraticBezier, - endPoint: point.endPoint, - }; - - const newPoints = [...state.points]; - newPoints[action.index] = newPoint; - - return { ...state, points: newPoints }; - } - case "SWITCH_TO_CUBIC": { - const point = state.points[action.index]; - if (point.type !== SplinePointType.QuadraticBezier) { - return state; - } - - const newPoint: CubicBezier = { - type: SplinePointType.CubicBezier, - controlPoint: { - x: point.endPoint.x / 2, - y: point.endPoint.y / 2, - }, - endPoint: point.endPoint, - }; - - const newPoints = [...state.points]; - newPoints[action.index] = newPoint; - - return { ...state, points: newPoints }; - } - } - - return state; - }, - initialSpline, - ); - const path = useMemo(() => splineToSvgDrawAttribute(spline), [spline]); - return ( - <> - - - - 0 ? - () => dispatch({ type: "DELETE_START_POINT" }) - : undefined - } - moveFn={(x, y) => - dispatch({ type: "MOVE_START_POINT", coords: { x, y } }) - } - /> - {spline.points.map((point, index) => ( - <> - - dispatch({ type: "DELETE_POINT", index }) - } - moveFn={(x, y) => - dispatch({ - type: "MOVE_POINT_ENDPOINT", - coords: { x, y }, - index, - }) - } - switchToQuadraticFn={() => - dispatch({ type: "SWITCH_TO_QUADRATIC", index }) - } - switchToCubicFn={() => - dispatch({ type: "SWITCH_TO_CUBIC", index }) - } - /> - {/* TODO: add line between control point and end point */} - {point.type === SplinePointType.CubicBezier && ( - - dispatch({ - type: "MOVE_CONTROL_POINT", - coords: { x, y }, - index, - }) - } - /> - )} - - ))} - - ); -} - -function SplinePoint({ - point, - moveFn, - switchToQuadraticFn = undefined, - switchToCubicFn = undefined, - deleteFn = undefined, -}: { - point: Point; - moveFn: (x: number, y: number) => void; - deleteFn?: () => void; - switchToQuadraticFn?: () => void; - switchToCubicFn?: () => void; -}) { - // TODO: fix context menu positioning - - const mainPointRef = useRef(null); - useDraggable(mainPointRef, (screenX, screenY) => - moveFn(screenX + robotSize / 4, screenY + robotSize / 4), - ); - - const mainPointColor = - point.type === SplinePointType.StartPoint ? "blue" : "black"; - const mainCoords = - point.type === SplinePointType.StartPoint ? - point.point - : point.endPoint; - - return ( - - {point.type === SplinePointType.CubicBezier && ( - - )} - {point.type === SplinePointType.QuadraticBezier && ( - - )} - - - -
- } - > - - - ); -} - -function SplineControlPoint({ - point, - moveControlFn, -}: { - point: Coords; - moveControlFn: (x: number, y: number) => void; -}) { - const controlPointRef = useRef(null); - useDraggable(controlPointRef, (screenX, screenY) => - moveControlFn(screenX + robotSize / 4, screenY + robotSize / 4), - ); - - return ( - - ); -} - -function useDraggable( - elRef: RefObject, - updatePosition: (screenX: number, screenY: number) => void, -) { - useEffect(() => { - const el = elRef.current; - if (!el) return; - - let pos1 = 0, - pos2 = 0, - pos3 = 0, - pos4 = 0; - - const onMouseDown = (e) => { - e.preventDefault(); - // get the mouse cursor position at startup: - pos3 = e.clientX; - pos4 = e.clientY; - document.onmouseup = closeDragElement; - // call a fn whenever the cursor moves: - document.onmousemove = elementDrag; - }; - - const elementDrag = (e) => { - e.preventDefault(); - pos1 = pos3 - e.clientX; - pos2 = pos4 - e.clientY; - pos3 = e.clientX; - pos4 = e.clientY; - const x = el.offsetLeft - pos1; - const y = el.offsetTop - pos2; - updatePosition(x, y); - }; - - const closeDragElement = () => { - document.onmouseup = null; - document.onmousemove = null; - }; - - el.addEventListener("mousedown", onMouseDown); - - return () => { - el.removeEventListener("mousedown", onMouseDown); - document.removeEventListener("mousemove", elementDrag); - document.removeEventListener("mouseup", closeDragElement); - }; - }, [elRef, updatePosition]); -} - -function usePreventExitWithUnsavedChanges() { - const [unsavedChanges, setUnsavedChanges] = useState(false); - - useEffect(() => { - const onBeforeUnload = (e: BeforeUnloadEvent) => { - if (unsavedChanges) { - e.preventDefault(); - e.returnValue = ""; - } - }; - - window.addEventListener("beforeunload", onBeforeUnload); - - return () => { - window.removeEventListener("beforeunload", onBeforeUnload); - }; - }, [unsavedChanges]); - - return [unsavedChanges, setUnsavedChanges] as const; -} - -function useStateWithTrackedHistory(initialValue: T) { - type Action = - | { type: "set"; value: T } - | { type: "undo" } - | { type: "redo" } - | { type: "replace"; value: T }; - type State = { - value: T; - history: T[]; - index: number; - }; - const [state, dispatch] = useReducer( - (state: State, action: Action) => { - switch (action.type) { - case "set": - return { - ...state, - value: action.value, - history: [...state.history, state.value], - index: state.history.length, - }; - case "undo": - if (state.index === 0) return state; - return { - ...state, - value: state.history[state.index - 1], - index: state.index - 1, - }; - case "redo": - if (state.index === state.history.length) return state; - return { - ...state, - value: state.history[state.index + 1], - index: state.index + 1, - }; - case "replace": - return { - ...state, - value: action.value, - history: [], - index: 0, - }; - } - }, - { - value: initialValue, - history: [], - index: 0, - }, - ); - - const undo = useCallback(() => dispatch({ type: "undo" }), []); - const redo = useCallback(() => dispatch({ type: "redo" }), []); - const canUndo = state.index > 0; - const canRedo = state.index < state.history.length - 1; - - const setValue = useCallback((value: T) => { - dispatch({ type: "set", value }); - }, []); - - const { value } = state; - - return { value, setValue, canUndo, canRedo, undo, redo }; -} diff --git a/src/client/editor/hooks.ts b/src/client/editor/hooks.ts new file mode 100644 index 00000000..a3e886cf --- /dev/null +++ b/src/client/editor/hooks.ts @@ -0,0 +1,137 @@ +import { type RefObject, useEffect, useState, useReducer, useCallback } from "react"; + +export function useDraggable( + elRef: RefObject, + updatePosition: (screenX: number, screenY: number) => void, +) { + useEffect(() => { + const el = elRef.current; + if (!el) return; + + // TODO: better name for these? + let pos1 = 0, + pos2 = 0, + pos3 = 0, + pos4 = 0; + + const onMouseDown = (e) => { + e.preventDefault(); + // get the mouse cursor position at startup: + pos3 = e.clientX; + pos4 = e.clientY; + document.onmouseup = closeDragElement; + // call a fn whenever the cursor moves: + document.onmousemove = elementDrag; + }; + + const elementDrag = (e) => { + e.preventDefault(); + pos1 = pos3 - e.clientX; + pos2 = pos4 - e.clientY; + pos3 = e.clientX; + pos4 = e.clientY; + const x = el.offsetLeft - pos1; + const y = el.offsetTop - pos2; + updatePosition(x, y); + }; + + const closeDragElement = () => { + document.onmouseup = null; + document.onmousemove = null; + }; + + el.addEventListener("mousedown", onMouseDown); + + return () => { + el.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("mousemove", elementDrag); + document.removeEventListener("mouseup", closeDragElement); + }; + }, [elRef, updatePosition]); +} + +export function usePreventExitWithUnsavedChanges() { + const [unsavedChanges, setUnsavedChanges] = useState(false); + + useEffect(() => { + const onBeforeUnload = (e: BeforeUnloadEvent) => { + if (unsavedChanges) { + e.preventDefault(); + e.returnValue = ""; + } + }; + + window.addEventListener("beforeunload", onBeforeUnload); + + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [unsavedChanges]); + + return [unsavedChanges, setUnsavedChanges] as const; +} + +export function useStateWithTrackedHistory(initialValue: T) { + type Action = + | { type: "set"; value: T } + | { type: "undo" } + | { type: "redo" } + | { type: "replace"; value: T }; + type State = { + value: T; + history: T[]; + index: number; + }; + const [state, dispatch] = useReducer( + (state: State, action: Action) => { + switch (action.type) { + case "set": + return { + ...state, + value: action.value, + history: [...state.history, state.value], + index: state.history.length, + }; + case "undo": + if (state.index === 0) return state; + return { + ...state, + value: state.history[state.index - 1], + index: state.index - 1, + }; + case "redo": + if (state.index === state.history.length) return state; + return { + ...state, + value: state.history[state.index + 1], + index: state.index + 1, + }; + case "replace": + return { + ...state, + value: action.value, + history: [], + index: 0, + }; + } + }, + { + value: initialValue, + history: [], + index: 0, + }, + ); + + const undo = useCallback(() => dispatch({ type: "undo" }), []); + const redo = useCallback(() => dispatch({ type: "redo" }), []); + const canUndo = state.index > 0; + const canRedo = state.index < state.history.length - 1; + + const setValue = useCallback((value: T) => { + dispatch({ type: "set", value }); + }, []); + + const { value } = state; + + return { value, setValue, canUndo, canRedo, undo, redo }; +} diff --git a/src/client/editor/points.tsx b/src/client/editor/points.tsx new file mode 100644 index 00000000..1dd4271d --- /dev/null +++ b/src/client/editor/points.tsx @@ -0,0 +1,103 @@ +import { ContextMenu, Menu, MenuItem, MenuDivider } from "@blueprintjs/core"; +import { useRef } from "react"; +import { type Point, SplinePointType, type Coords } from "../../common/spline"; +import { robotSize } from "../debug/simulator"; +import { useDraggable } from "./hooks"; + +export function SplinePoint({ + point, + moveFn, + switchToQuadraticFn = undefined, + switchToCubicFn = undefined, + deleteFn = undefined, +}: { + point: Point; + moveFn: (x: number, y: number) => void; + deleteFn?: () => void; + switchToQuadraticFn?: () => void; + switchToCubicFn?: () => void; +}) { + // TODO: fix context menu positioning + const mainPointRef = useRef(null); + useDraggable(mainPointRef, (screenX, screenY) => + moveFn(screenX + robotSize / 4, screenY + robotSize / 4), + ); + + const mainPointColor = + point.type === SplinePointType.StartPoint ? "blue" : "black"; + const mainCoords = + point.type === SplinePointType.StartPoint ? + point.point + : point.endPoint; + + return ( + + {point.type === SplinePointType.CubicBezier && ( + + )} + {point.type === SplinePointType.QuadraticBezier && ( + + )} + + + +
+ } + > + + + ); +} +export function SplineControlPoint({ + point, + moveControlFn, +}: { + point: Coords; + moveControlFn: (x: number, y: number) => void; +}) { + const controlPointRef = useRef(null); + useDraggable(controlPointRef, (screenX, screenY) => + moveControlFn(screenX + robotSize / 4, screenY + robotSize / 4), + ); + + return ( + + ); +} diff --git a/src/client/editor/spline-editor.tsx b/src/client/editor/spline-editor.tsx new file mode 100644 index 00000000..46c8f0c5 --- /dev/null +++ b/src/client/editor/spline-editor.tsx @@ -0,0 +1,175 @@ +import { useReducer, useMemo } from "react"; +import { + type Spline, + type Coords, + SplinePointType, + type QuadraticBezier, + type CubicBezier, + splineToSvgDrawAttribute, +} from "../../common/spline"; +import { SplinePoint, SplineControlPoint } from "./points"; + +export function SplineEditor({ initialSpline }: { initialSpline: Spline }) { + type SplineEditorAction = + | { type: "DELETE_POINT"; index: number } + | { type: "DELETE_START_POINT" } + | { type: "MOVE_POINT_ENDPOINT"; coords: Coords; index: number } + | { type: "MOVE_START_POINT"; coords: Coords } + | { type: "MOVE_CONTROL_POINT"; coords: Coords; index: number } + | { type: "SWITCH_TO_QUADRATIC"; index: number } + | { type: "SWITCH_TO_CUBIC"; index: number }; + + const [spline, dispatch] = useReducer( + (state: Spline, action: SplineEditorAction): Spline => { + switch (action.type) { + case "DELETE_POINT": { + return { + ...state, + points: state.points.toSpliced(action.index, 1), + }; + } + case "DELETE_START_POINT": { + if (state.points.length === 0) return state; + return { + ...state, + start: { + type: SplinePointType.StartPoint, + point: state.points[0].endPoint, + }, + points: state.points.slice(1), + }; + } + case "MOVE_POINT_ENDPOINT": { + const newPoints = [...state.points]; + newPoints[action.index].endPoint = action.coords; + return { ...state, points: newPoints }; + } + case "MOVE_START_POINT": { + return { + ...state, + start: { + type: SplinePointType.StartPoint, + point: action.coords, + }, + }; + } + case "MOVE_CONTROL_POINT": { + const point = state.points[action.index]; + if (point.type !== SplinePointType.CubicBezier) { + return state; + } + + point.controlPoint = action.coords; + + const newPoints = [...state.points]; + newPoints[action.index] = point; + + return { ...state, points: newPoints }; + } + case "SWITCH_TO_QUADRATIC": { + const point = state.points[action.index]; + if (point.type !== SplinePointType.CubicBezier) { + return state; + } + + const newPoint: QuadraticBezier = { + type: SplinePointType.QuadraticBezier, + endPoint: point.endPoint, + }; + + const newPoints = [...state.points]; + newPoints[action.index] = newPoint; + + return { ...state, points: newPoints }; + } + case "SWITCH_TO_CUBIC": { + const point = state.points[action.index]; + if (point.type !== SplinePointType.QuadraticBezier) { + return state; + } + + const newPoint: CubicBezier = { + type: SplinePointType.CubicBezier, + controlPoint: { + x: point.endPoint.x / 2, + y: point.endPoint.y / 2, + }, + endPoint: point.endPoint, + }; + + const newPoints = [...state.points]; + newPoints[action.index] = newPoint; + + return { ...state, points: newPoints }; + } + } + + return state; + }, + initialSpline, + ); + const path = useMemo(() => splineToSvgDrawAttribute(spline), [spline]); + return ( + <> + + + + 0 ? + () => dispatch({ type: "DELETE_START_POINT" }) + : undefined + } + moveFn={(x, y) => + dispatch({ type: "MOVE_START_POINT", coords: { x, y } }) + } + /> + {spline.points.map((point, index) => ( + <> + + dispatch({ type: "DELETE_POINT", index }) + } + moveFn={(x, y) => + dispatch({ + type: "MOVE_POINT_ENDPOINT", + coords: { x, y }, + index, + }) + } + switchToQuadraticFn={() => + dispatch({ type: "SWITCH_TO_QUADRATIC", index }) + } + switchToCubicFn={() => + dispatch({ type: "SWITCH_TO_CUBIC", index }) + } + /> + {/* TODO: add line between control point and end point */} + {point.type === SplinePointType.CubicBezier && ( + + dispatch({ + type: "MOVE_CONTROL_POINT", + coords: { x, y }, + index, + }) + } + /> + )} + + ))} + + ); +} From d7e625d34a6a73ce688f946ae0a16a1f3b22dedd Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sat, 12 Apr 2025 15:33:32 -0500 Subject: [PATCH 19/92] Change default show name --- src/common/show.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/common/show.ts b/src/common/show.ts index 0857ea86..05955211 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -81,6 +81,7 @@ export function timelineLayerToSpline(layer: TimelineLayer): Spline { }; } +// TODO: empty showfile export function createNewShowfile(): Showfile { return { $chessbots_show_schema_version: 1, @@ -105,6 +106,10 @@ export function createNewShowfile(): Showfile { endPoint: { x: 100, y: 100 }, }, }, + { + type: TimelineEventTypes.WaitEvent, + durationMs: 5000, + }, { type: TimelineEventTypes.GoToPointEvent, durationMs: 1000, @@ -128,6 +133,6 @@ export function createNewShowfile(): Showfile { ], ], ], - name: "new show", + name: `Show ${new Date().toDateString()} ${new Date().toLocaleTimeString()}`, }; } From 34b444dbf5564432f1854b3ecca598e3eec354ba Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sat, 12 Apr 2025 18:59:48 -0500 Subject: [PATCH 20/92] Lint + format --- src/client/editor/hooks.ts | 8 +++++++- src/common/show.ts | 6 ++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/client/editor/hooks.ts b/src/client/editor/hooks.ts index a3e886cf..9f4689c1 100644 --- a/src/client/editor/hooks.ts +++ b/src/client/editor/hooks.ts @@ -1,4 +1,10 @@ -import { type RefObject, useEffect, useState, useReducer, useCallback } from "react"; +import { + type RefObject, + useEffect, + useState, + useReducer, + useCallback, +} from "react"; export function useDraggable( elRef: RefObject, diff --git a/src/common/show.ts b/src/common/show.ts index 05955211..e3003009 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -11,9 +11,7 @@ import { Tuple, } from "runtypes"; import { - CoordsSchema, MidpointSchema, - PointSchema, Spline, SplinePointType, StartPointSchema, @@ -107,8 +105,8 @@ export function createNewShowfile(): Showfile { }, }, { - type: TimelineEventTypes.WaitEvent, - durationMs: 5000, + type: TimelineEventTypes.WaitEvent, + durationMs: 5000, }, { type: TimelineEventTypes.GoToPointEvent, From b77c15fb126fe9059d748b8266c5937968c06682 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sun, 13 Apr 2025 00:43:06 -0500 Subject: [PATCH 21/92] Rough impl of play/pause --- src/client/debug/simulator.tsx | 1 + src/client/editor/editor.tsx | 195 +++++++++++++++++++++++++++++++-- src/common/show.ts | 42 ++++--- 3 files changed, 215 insertions(+), 23 deletions(-) diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index bf018f97..f74f6786 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -207,6 +207,7 @@ const openInEditor = async (frame: StackFrame) => { await fetch(`/__open-in-editor?${params.toString()}`); }; +// TODO: refactor out of debug since we use it in more than just simulator? export function RobotGrid({ robotState, children, diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index b9a40b8f..9873f43c 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -19,14 +19,17 @@ import { Tag, useHotkeys, type HotkeyConfig, + Elevation, } from "@blueprintjs/core"; import { RobotGrid } from "../debug/simulator"; import { fileOpen, fileSave } from "browser-fs-access"; import { + type TimelineEvents, createNewShowfile, ShowfileSchema, timelineLayerToSpline, + EVENT_TYPE_TO_COLOR, } from "../../common/show"; import { diff } from "deep-object-diff"; import { @@ -34,6 +37,22 @@ import { useStateWithTrackedHistory, } from "./hooks"; import { SplineEditor } from "./spline-editor"; +import { + motion, + useMotionValue, + useMotionValueEvent, + useTime, + useTransform, +} from "motion/react"; + +const RULER_TICK_INTERVAL_MS = 250; +const RULER_EXTRA_TICK_COUNT = 10; +// TODO: make ruler tick size configurable so we can zoom. relatively low priority. would be nice if gestures could be supported too +const RULER_TICK_GAP = "1rem"; + +function millisToXPosition(millis: number): string { + return `calc(${millis} / ${RULER_TICK_INTERVAL_MS} * ${RULER_TICK_GAP})`; +} export function Editor() { const [initialShow, setInitialShow] = useState(createNewShowfile()); @@ -107,6 +126,15 @@ export function Editor() { setInitialShow(show); }, [fsHandle, show]); + // TODO: figure out how to get this from the showfile + const SEQUENCE_LENGTH_MS = 5 * 1000; + + const { currentTimestamp, playing, togglePlaying } = + usePlayHead(SEQUENCE_LENGTH_MS); + const seekBarWidth = useTransform(() => + millisToXPosition(currentTimestamp.get()), + ); + const hotkeys = useMemo( () => [ { @@ -143,8 +171,18 @@ export function Editor() { label: "Redo", onKeyDown: redo, }, + { + combo: "space", + group: "Play/Pause", + global: true, + label: "Play/Pause", + onKeyDown: (e) => { + e.preventDefault(); + togglePlaying(); + }, + }, ], - [redo, undo, saveShowfile, openShowfile], + [redo, undo, saveShowfile, openShowfile, togglePlaying], ); const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys); @@ -208,6 +246,7 @@ export function Editor() { /> + {/* TODO: render robots */} {/* TODO: think more about how state changes will make it up from the editor to this component */} {show.timeline @@ -223,29 +262,83 @@ export function Editor() { style={{ height: "100%" }} rightElement={
); } -const TimelineRow = forwardRef< +const TimelineEvent = forwardRef( + function TimelineEvent(props, ref) { + const { event } = props; + // TODO: add context menu for deleting events and adding a new event before and after this one + + // TODO: add handles to the edges of the event to edit durations. add a switch for ripple vs rolling edits + + // ripple edits mean that editing the duration of an event has a ripple effect on ALL the other events in the same layer, shifting all the subsequent event start times by the same amount (so only one event's duration is actually changing) + // rolling edits mean that editing the duration of an event also affects the duration of the event that immediately follows it in the same layer, such that adjusting the duration of this event doesn't shift the start timestamp of the subsequent events in the same layer + + return ( + + {event.type} + + ); + }, +); + +const TimelineLayer = forwardRef< HTMLDivElement, PropsWithChildren<{ title: string }> >(function TimelineCard(props, ref) { @@ -267,3 +360,85 @@ const TimelineRow = forwardRef< ); }); + +function Ruler({ sequenceLengthMs }: { sequenceLengthMs: number }) { + return ( + +
+ {new Array( + Math.round(sequenceLengthMs / RULER_TICK_INTERVAL_MS) + + RULER_EXTRA_TICK_COUNT, + ) + .fill(1) + .map((_, i) => { + // Check if this tick marks a 1000ms interval (every 4 ticks if RULER_INTERVAL_MS is 250) + const isMajorTick = + i % (1000 / RULER_TICK_INTERVAL_MS) === 0; + return ( +
+ ); + })} +
+ + ); +} + +function usePlayHead(endDurationMs: number, startMs = 0) { + const [playing, setPlaying] = useState(false); + const currentTimestamp = useMotionValue(startMs); + const time = useTime(); + const lastAccessedTime = useMotionValue(0); + + const setTimestamp = (timestamp: number) => { + currentTimestamp.set(timestamp); + }; + + const togglePlaying = () => { + setPlaying((p) => { + if (!p) { + // When starting playback, initialize the lastAccessedTime + lastAccessedTime.set(time.get()); + } + return !p; + }); + }; + + useMotionValueEvent(time, "change", (currentTime) => { + if (!playing) { + return; + } + + const prevTime = lastAccessedTime.get(); + const elapsedMs = currentTime - prevTime; + + // Update timestamp based on elapsed time + const newTimestamp = currentTimestamp.get() + elapsedMs; + + if (newTimestamp >= endDurationMs) { + // Loop back to start when reaching the end + currentTimestamp.set(0); + } else { + currentTimestamp.set(newTimestamp); + } + + // Update last accessed time for next calculation + lastAccessedTime.set(currentTime); + }); + + return { currentTimestamp, playing, togglePlaying, setTimestamp }; +} diff --git a/src/common/show.ts b/src/common/show.ts index e3003009..3f88f9ff 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -1,7 +1,7 @@ import { Number, String, - Record, + Record as RuntypesRecord, Array, Literal, Union, @@ -12,14 +12,14 @@ import { } from "runtypes"; import { MidpointSchema, - Spline, + type Spline, SplinePointType, StartPointSchema, } from "./spline"; // JSON file would use the Showfile type. -const AudioSchema = Record({ +const AudioSchema = RuntypesRecord({ startMs: Number, // maybe we use something like capnproto, msgpack, or protobuf so we could // include an audio file in the showfile. if we really wanted to use JSON we'd @@ -29,26 +29,27 @@ const AudioSchema = Record({ tempoBpm: Union(Number, Null), }); -enum TimelineEventTypes { - GoToPointEvent = "goto_point", - WaitEvent = "wait", - StartPointEvent = "start_point", -} +const TimelineEventTypes = { + GoToPointEvent: "goto_point", + WaitEvent: "wait", + StartPointEvent: "start_point", +} as const; -const GoToPointEventSchema = Record({ +const GoToPointEventSchema = RuntypesRecord({ durationMs: Number, target: MidpointSchema, type: Literal(TimelineEventTypes.GoToPointEvent), }); -const WaitEventSchema = Record({ +const WaitEventSchema = RuntypesRecord({ durationMs: Number, type: Literal(TimelineEventTypes.WaitEvent), }); -const StartPointEventSchema = Record({ +const StartPointEventSchema = RuntypesRecord({ type: Literal(TimelineEventTypes.StartPointEvent), target: StartPointSchema, + durationMs: Number, }); const TimelineEventSchema = Union(GoToPointEventSchema, WaitEventSchema); @@ -57,7 +58,11 @@ const TimelineLayerSchema = Tuple( Array(TimelineEventSchema), ); -export const ShowfileSchema = Record({ +export type TimelineEvents = + | Static + | Static; + +export const ShowfileSchema = RuntypesRecord({ $chessbots_show_schema_version: Literal(1), timeline: Array(TimelineLayerSchema), audio: Optional(AudioSchema), @@ -65,7 +70,6 @@ export const ShowfileSchema = Record({ }); export type Audio = Static; -export type MovementEvent = Static; export type TimelineLayer = Static; export type Showfile = Static; @@ -94,6 +98,7 @@ export function createNewShowfile(): Showfile { y: 70, }, }, + durationMs: 7500, }, [ { @@ -134,3 +139,14 @@ export function createNewShowfile(): Showfile { name: `Show ${new Date().toDateString()} ${new Date().toLocaleTimeString()}`, }; } + +import { Colors } from "@blueprintjs/core"; + +export const EVENT_TYPE_TO_COLOR: Record< + (typeof TimelineEventTypes)[keyof typeof TimelineEventTypes], + (typeof Colors)[keyof typeof Colors] +> = { + [TimelineEventTypes.GoToPointEvent]: Colors.BLUE2, + [TimelineEventTypes.WaitEvent]: Colors.GRAY2, + [TimelineEventTypes.StartPointEvent]: Colors.GREEN2, +}; From 962c446cddc7a3451c997e1b96c2d99939c21810 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Sun, 13 Apr 2025 22:59:25 -0500 Subject: [PATCH 22/92] Optimize renders, audio playback, timeline layer addition/deletion --- src/client/editor/editor.tsx | 151 +++++++++++++++++++++++----- src/client/editor/points.tsx | 32 +++--- src/client/editor/spline-editor.tsx | 37 ++++--- src/common/show.ts | 20 ++-- 4 files changed, 173 insertions(+), 67 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 9873f43c..3474cbfb 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from "react"; import { @@ -30,6 +31,8 @@ import { ShowfileSchema, timelineLayerToSpline, EVENT_TYPE_TO_COLOR, + type TimelineLayer, + TimelineEventTypes, } from "../../common/show"; import { diff } from "deep-object-diff"; import { @@ -44,6 +47,7 @@ import { useTime, useTransform, } from "motion/react"; +import { SplinePointType } from "../../common/spline"; const RULER_TICK_INTERVAL_MS = 250; const RULER_EXTRA_TICK_COUNT = 10; @@ -54,6 +58,9 @@ function millisToXPosition(millis: number): string { return `calc(${millis} / ${RULER_TICK_INTERVAL_MS} * ${RULER_TICK_GAP})`; } +// TODO: reading blob.bytes works in safari but not chrome. why? +// TODO: ui for adding/removing audio - remove current hotkey as this was mainly for testing + export function Editor() { const [initialShow, setInitialShow] = useState(createNewShowfile()); const [fsHandle, setFsHandle] = useState(null); @@ -66,6 +73,8 @@ export function Editor() { redo, } = useStateWithTrackedHistory(initialShow); + const { audio } = show; + // TODO: fix viewport height / timeline height const [unsavedChanges, setUnsavedChanges] = usePreventExitWithUnsavedChanges(); @@ -82,7 +91,8 @@ export function Editor() { const openShowfile = useCallback(async () => { const blob = await fileOpen({ mimeTypes: ["application/chessbots-showfile"], - extensions: ["cbor"], + extensions: [".cbor"], + description: "Chess Bots Showfile", }); let data: unknown | null = null; @@ -105,8 +115,6 @@ export function Editor() { setInitialShow(show); setShow(show); if (blob.handle) setFsHandle(blob.handle); - - console.log(show); }, [setShow]); const saveShowfile = useCallback(async () => { @@ -117,7 +125,7 @@ export function Editor() { blob, { mimeTypes: ["application/chessbots-showfile"], - extensions: ["cbor"], + extensions: [".cbor"], fileName: show.name + ".cbor", }, fsHandle, @@ -126,6 +134,18 @@ export function Editor() { setInitialShow(show); }, [fsHandle, show]); + const loadAudioFromFile = useCallback(async () => { + const blob = await fileOpen({ + mimeTypes: ["audio/mpeg", "audio/wav"], + extensions: [".mp3", ".wav"], + }); + const audio = await blob.bytes(); + setShow({ + ...show, + audio: { startMs: 0, data: audio, tempoBpm: null, mimeType: blob.type }, + }); + }, [setShow, show]); + // TODO: figure out how to get this from the showfile const SEQUENCE_LENGTH_MS = 5 * 1000; @@ -181,15 +201,84 @@ export function Editor() { togglePlaying(); }, }, + { + combo: "mod+shift+f", + group: "Edit", + global: true, + label: "Load Audio", + onKeyDown: (e) => { + e.preventDefault(); + loadAudioFromFile(); + }, + }, + ], + [ + redo, + undo, + saveShowfile, + openShowfile, + togglePlaying, + loadAudioFromFile, ], - [redo, undo, saveShowfile, openShowfile, togglePlaying], ); + const editName = useCallback( + (value: string) => setShow({ ...show, name: value }), + [show, setShow], + ); const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys); + const audioRef = useRef(new Audio()); + + useEffect(() => { + if (!audio) return; + + audioRef.current.src = URL.createObjectURL(new Blob([audio.data], { + type: audio.mimeType, + })); + audioRef.current.load(); + }, [audio]); + + useEffect(() => { + if (!audioRef.current) return; + + if (playing && audioRef.current.paused) { + audioRef.current.currentTime = Math.min( + currentTimestamp.get() / 1000, + audioRef.current.duration, + ); + audioRef.current.play(); + } + + if (!playing && !audioRef.current.paused) { + audioRef.current.pause(); + } + }, [playing, currentTimestamp]); + + const addRobot = useCallback(() => { + const newLayer: TimelineLayer = [ + { + type: TimelineEventTypes.StartPointEvent, + target: { + type: SplinePointType.StartPoint, + point: { + x: 0, + y: 70, + }, + }, + durationMs: 7500, + }, + [], + ]; + setShow({ + ...show, + timeline: [...show.timeline, newLayer], + }); + }, [show, setShow]); + return (
@@ -205,9 +294,7 @@ export function Editor() { - setShow({ ...show, name: value }) - } + onChange={editName} /> {unsavedChanges && ( @@ -255,12 +342,13 @@ export function Editor() { ))} - {/* TODO: implement add button */}
} + rightElement={ +
); } -const TimelineEvent = forwardRef( - function TimelineEvent(props, ref) { - const { event } = props; - // TODO: add context menu for deleting events and adding a new event before and after this one +function ReorderableTimelineEvent({ + event, + onDurationChange, +}: PropsWithChildren<{ + event: TimelineEvents; + onDurationChange: (ms: number) => void; +}>) { + const controls = useDragControls(); - // TODO: add handles to the edges of the event to edit durations. add a switch for ripple vs rolling edits + return ( + + controls.start(e)} + onDurationChange={onDurationChange} + /> + + ); +} - // ripple edits mean that editing the duration of an event has a ripple effect on ALL the other events in the same layer, shifting all the subsequent event start times by the same amount (so only one event's duration is actually changing) - // rolling edits mean that editing the duration of an event also affects the duration of the event that immediately follows it in the same layer, such that adjusting the duration of this event doesn't shift the start timestamp of the subsequent events in the same layer +function TimelineEvent(props: { + event: TimelineEvents; + onPointerDownOnDragHandle?: ( + event: React.PointerEvent, + ) => void; + onDurationChange?: (deltaMs: number) => void; +}) { + const ref = useRef(null); + const { event, onPointerDownOnDragHandle, onDurationChange } = props; + useEffect(() => { + if (!onDurationChange) return; + if (!ref.current) return; + const el = ref.current; - return ( - + interact(el).resizable({ + edges: { + right: true, + }, + listeners: { + move: function (event) { + event.preventDefault(); + event.stopPropagation(); + onDurationChange(pixelsToMillis(event.deltaRect.width)); + }, + }, + }); + }, [event.type, onDurationChange]); + + // TODO: add context menu for deleting events and adding a new event before and after this one + + return ( + + {event.type} - - ); - }, -); + + {onPointerDownOnDragHandle && ( + + )} + + ); +} const TimelineLayer = forwardRef< HTMLDivElement, @@ -383,7 +500,7 @@ function Ruler({ sequenceLengthMs }: { sequenceLengthMs: number }) { overflowX: "scroll", justifyContent: "space-between", alignItems: "start", - gap: RULER_TICK_GAP, + gap: RULER_TICK_GAP_PX, }} > {new Array( diff --git a/src/client/editor/showfile-state.tsx b/src/client/editor/showfile-state.tsx index 2b6c9773..49c64436 100644 --- a/src/client/editor/showfile-state.tsx +++ b/src/client/editor/showfile-state.tsx @@ -14,6 +14,7 @@ import { GoToPointEvent, TimelineLayer, NonStartPointEvent, + TimelineDurationUpdateMode, } from "../../common/show"; import { SplinePointType, @@ -30,6 +31,12 @@ import { export function useShowfile() { const [initialShow, setInitialShow] = useState(createNewShowfile()); + // See comment on TimelineDurationUpdateMode declaration in src/common/show.ts for more info. + const [timelineDurationUpdateMode, setTimelineDurationUpdateMode] = + useState< + (typeof TimelineDurationUpdateMode)[keyof typeof TimelineDurationUpdateMode] + >(TimelineDurationUpdateMode.Ripple); + const [fsHandle, setFsHandle] = useState(null); const [unsavedChanges, setUnsavedChanges] = @@ -390,6 +397,54 @@ export function useShowfile() { [show, setShow], ); + const updateTimelineEventDurations = useCallback( + (layerIndex: number, eventId: string, deltaMs: number) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) { + console.log("Layer not found"); + return; + } + + const [startPoint, remainingEvents] = layer; + + const updatedEventList = [startPoint, ...remainingEvents]; + const eventToUpdateIndex = updatedEventList.findIndex( + (event) => event.id === eventId, + ); + if (eventToUpdateIndex === -1) { + console.log("Event not found", updatedEventList, layer); + return; + } + + const shouldUpdateSubsequentEventDuration = + timelineDurationUpdateMode === + TimelineDurationUpdateMode.Rolling && + eventToUpdateIndex < updatedEventList.length - 1; + + const eventToUpdate = updatedEventList[eventToUpdateIndex]; + const newDurationMs = eventToUpdate.durationMs + deltaMs; + eventToUpdate.durationMs = newDurationMs; + updatedEventList[eventToUpdateIndex] = eventToUpdate; + + if (shouldUpdateSubsequentEventDuration) { + const subsequentEventIndex = eventToUpdateIndex + 1; + const subsequentEvent = updatedEventList[subsequentEventIndex]; + const newSubsequentEventDurationMs = + subsequentEvent.durationMs - deltaMs; + subsequentEvent.durationMs = newSubsequentEventDurationMs; + updatedEventList[subsequentEventIndex] = subsequentEvent; + } + + newTimeline[layerIndex] = [ + updatedEventList[0] as StartPointEvent, + updatedEventList.slice(1) as NonStartPointEvent[], + ]; + setShow({ ...show, timeline: newTimeline }); + }, + [show, setShow, timelineDurationUpdateMode], + ); + return { updateTimelineEventOrders, show, @@ -414,5 +469,8 @@ export function useShowfile() { canRedo, canUndo, sequenceLengthMs, + timelineDurationUpdateMode, + setTimelineDurationUpdateMode, + updateTimelineEventDurations, }; } diff --git a/src/client/package.json b/src/client/package.json index fa86a1fe..06963ff4 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -9,6 +9,7 @@ "browser-fs-access": "^0.35.0", "cbor-x": "^1.6.0", "deep-object-diff": "^1.1.9", + "interactjs": "^1.10.27", "motion": "^12.6.0", "react": "^18.2.0", "react-chessboard": "^4.5.0", diff --git a/src/common/show.ts b/src/common/show.ts index adf4eff0..3a28dcda 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -68,7 +68,7 @@ export type TimelineEvents = NonStartPointEvent | StartPointEvent; */ export const ShowfileSchema = RuntypesRecord({ // Be sure to increment the schema version number when making breaking changes to the showfile schema. - $chessbots_show_schema_version: Literal(1), + $chessbots_show_schema_version: Literal(2), // The timeline is an array of timeline 'layers' - a layer consists of an array that includes all the events for one robot. timeline: Array(TimelineLayerSchema), audio: Optional( @@ -105,7 +105,7 @@ export function timelineLayerToSpline(layer: TimelineLayer): Spline { */ export function createNewShowfile(): Showfile { return { - $chessbots_show_schema_version: 1, + $chessbots_show_schema_version: 2, timeline: [ [ { @@ -172,3 +172,22 @@ export const EVENT_TYPE_TO_COLOR: Record< [TimelineEventTypes.WaitEvent]: Colors.GRAY2, [TimelineEventTypes.StartPointEvent]: Colors.GREEN2, }; + +/* + * The timeline duration update mode determines how the duration of timeline events + * is updated when the user edits a timeline event's duration. + * + * Being in ripple edit mode means that editing the duration of an event has a + * ripple effect on ALL the other events in the same layer, shifting + * all the subsequent event start times by the same amount (so only + * one event's duration is actually changing). + * + * Being in rolling edit mode mean that editing the duration of an event also affects the + * duration of the event that immediately follows it in the same layer, such + * that adjusting the duration of this event doesn't shift the start timestamp + * of the subsequent events in the same layer. + */ +export const TimelineDurationUpdateMode = { + Rolling: "rolling", + Ripple: "ripple", +} as const; diff --git a/yarn.lock b/yarn.lock index 656b27cc..45fc1169 100644 --- a/yarn.lock +++ b/yarn.lock @@ -601,6 +601,11 @@ resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz" integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw== +"@interactjs/types@1.10.27": + version "1.10.27" + resolved "https://registry.yarnpkg.com/@interactjs/types/-/types-1.10.27.tgz#10afd71cef2498e2b5192cf0d46f937d8ceb767f" + integrity sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA== + "@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": version "0.3.3" resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz" @@ -2332,6 +2337,13 @@ inherits@2, inherits@2.0.4: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +interactjs@^1.10.27: + version "1.10.27" + resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.27.tgz#16499aba4987a5ccfdaddca7d1ba7bb1118e14d0" + integrity sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA== + dependencies: + "@interactjs/types" "1.10.27" + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz" From dda5fc8b3faca737ef9a12896efd7ac36107012f Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 15 Apr 2025 02:41:10 -0500 Subject: [PATCH 36/92] Rename showfile state file --- src/client/editor/{showfile-state.tsx => showfile-state.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/client/editor/{showfile-state.tsx => showfile-state.ts} (100%) diff --git a/src/client/editor/showfile-state.tsx b/src/client/editor/showfile-state.ts similarity index 100% rename from src/client/editor/showfile-state.tsx rename to src/client/editor/showfile-state.ts From 09415aae5628ce6f73c2b6edb179841ddaf7366a Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 15 Apr 2025 02:47:23 -0500 Subject: [PATCH 37/92] Format --- src/common/show.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/common/show.ts b/src/common/show.ts index 3a28dcda..6562fe30 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -177,11 +177,11 @@ export const EVENT_TYPE_TO_COLOR: Record< * The timeline duration update mode determines how the duration of timeline events * is updated when the user edits a timeline event's duration. * - * Being in ripple edit mode means that editing the duration of an event has a + * Being in ripple edit mode means that editing the duration of an event has a * ripple effect on ALL the other events in the same layer, shifting * all the subsequent event start times by the same amount (so only - * one event's duration is actually changing). - * + * one event's duration is actually changing). + * * Being in rolling edit mode mean that editing the duration of an event also affects the * duration of the event that immediately follows it in the same layer, such * that adjusting the duration of this event doesn't shift the start timestamp From fceff555086d200a8d07f9962c9c7747b5e26174 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 15 Apr 2025 18:54:17 -0500 Subject: [PATCH 38/92] Address comments on schemas and types --- src/common/show.ts | 13 +++++++------ src/common/spline.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/common/show.ts b/src/common/show.ts index 6562fe30..a105c309 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -47,16 +47,17 @@ const StartPointEventSchema = RuntypesRecord({ }); export type StartPointEvent = Static; -const TimelineEventSchema = Union(GoToPointEventSchema, WaitEventSchema); -export type NonStartPointEvent = Static; +const NonStartPointEventSchema = Union(GoToPointEventSchema, WaitEventSchema); +export type NonStartPointEvent = Static; + // TODO: refactor this to be an object consisting of 2 keys: // startPoint and remainingEvents so that it is more self-documenting const TimelineLayerSchema = Tuple( StartPointEventSchema, - Array(TimelineEventSchema), + Array(NonStartPointEventSchema), ); export type TimelineLayer = Static; -export type TimelineEvents = NonStartPointEvent | StartPointEvent; +export type TimelineEvents = GoToPointEvent | WaitEvent | StartPointEvent; /** * The showfile schema. @@ -100,8 +101,8 @@ export function timelineLayerToSpline(layer: TimelineLayer): Spline { } /** - * Creates a new empty showfile. - * @returns The new empty showfile. + * Creates a new example showfile. + * @returns The new example showfile. */ export function createNewShowfile(): Showfile { return { diff --git a/src/common/spline.ts b/src/common/spline.ts index fb185a50..925c063f 100644 --- a/src/common/spline.ts +++ b/src/common/spline.ts @@ -9,8 +9,8 @@ import { export enum SplinePointType { StartPoint = "start", - CubicBezier = "cubic", QuadraticBezier = "quadratic", + CubicBezier = "cubic", } export const CoordsSchema = Record({ @@ -55,9 +55,12 @@ export const PointSchema = Union( export type Point = Static; /** - * Converts a point to an SVG path command. This is used in combination with the {@link splineToSvgDrawAttribute} function to convert a spline to a string that can be used as the `d` attribute of an SVG path. + * Converts a point to an SVG path command. This is used in combination with the {@link splineToSvgDrawAttribute} function to convert a spline to a string that can be used as the `d` attribute of an SVG path. + * + * @remarks + * For more context on the implementation/syntax, check out this MDN article about SVG paths: https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Paths * @param point - the point to convert. - * @returns - the SVG path command for the point. + * @returns the SVG path command for the point. */ function pointToSvgPathCommand(point: Point): string { switch (point.type) { From 1cef70ac69e316df881e9dc3e3196b7c44f53fb1 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 15 Apr 2025 19:05:22 -0500 Subject: [PATCH 39/92] Refactor TimelineLayerSchema as object instead of tuple for clarity --- src/client/editor/editor.tsx | 2 +- src/client/editor/showfile-state.ts | 63 +++++++++++++++++------------ src/client/editor/spline-editor.tsx | 2 +- src/common/show.ts | 29 ++++++------- src/common/spline.ts | 4 +- 5 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 1ebe50b0..84ff1b34 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -308,7 +308,7 @@ export function Editor() { Stuff here {show.timeline.map( - ([startPoint, remainingEvents], layerIndex) => { + ({ startPoint, remainingEvents }, layerIndex) => { return ( { - const newLayer: TimelineLayer = [ - { + const newLayer: TimelineLayer = { + startPoint: { id: crypto.randomUUID(), type: TimelineEventTypes.StartPointEvent, target: { @@ -329,8 +338,8 @@ export function useShowfile() { }, durationMs: 7500, }, - [], - ]; + remainingEvents: [], + }; setShow({ ...show, timeline: [...show.timeline, newLayer], @@ -390,8 +399,8 @@ export function useShowfile() { const newTimeline = [...show.timeline]; const layer = newTimeline[layerIndex]; if (!layer) return; - const [startPoint] = layer; - newTimeline[layerIndex] = [startPoint, newList]; + const { startPoint } = layer; + newTimeline[layerIndex] = { startPoint, remainingEvents: newList }; setShow({ ...show, timeline: newTimeline }); }, [show, setShow], @@ -406,7 +415,7 @@ export function useShowfile() { return; } - const [startPoint, remainingEvents] = layer; + const { startPoint, remainingEvents } = layer; const updatedEventList = [startPoint, ...remainingEvents]; const eventToUpdateIndex = updatedEventList.findIndex( @@ -436,10 +445,12 @@ export function useShowfile() { updatedEventList[subsequentEventIndex] = subsequentEvent; } - newTimeline[layerIndex] = [ - updatedEventList[0] as StartPointEvent, - updatedEventList.slice(1) as NonStartPointEvent[], - ]; + newTimeline[layerIndex] = { + startPoint: updatedEventList[0] as StartPointEvent, + remainingEvents: updatedEventList.slice( + 1, + ) as NonStartPointEvent[], + }; setShow({ ...show, timeline: newTimeline }); }, [show, setShow, timelineDurationUpdateMode], diff --git a/src/client/editor/spline-editor.tsx b/src/client/editor/spline-editor.tsx index f3d47695..826d39a5 100644 --- a/src/client/editor/spline-editor.tsx +++ b/src/client/editor/spline-editor.tsx @@ -33,7 +33,7 @@ export function SplineEditor({ onDeletePoint, onSwitchPointType, }: SplineEditorProps) { - const [, remainingEvents] = layer; + const { remainingEvents } = layer; const spline = useMemo(() => timelineLayerToSpline(layer), [layer]); const path = useMemo(() => splineToSvgDrawAttribute(spline), [spline]); diff --git a/src/common/show.ts b/src/common/show.ts index a105c309..eb6d8c8c 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -7,7 +7,6 @@ import { Union, type Static, Optional, - Tuple, InstanceOf, } from "runtypes"; import { @@ -50,12 +49,10 @@ export type StartPointEvent = Static; const NonStartPointEventSchema = Union(GoToPointEventSchema, WaitEventSchema); export type NonStartPointEvent = Static; -// TODO: refactor this to be an object consisting of 2 keys: -// startPoint and remainingEvents so that it is more self-documenting -const TimelineLayerSchema = Tuple( - StartPointEventSchema, - Array(NonStartPointEventSchema), -); +const TimelineLayerSchema = RuntypesRecord({ + startPoint: StartPointEventSchema, + remainingEvents: Array(NonStartPointEventSchema), +}); export type TimelineLayer = Static; export type TimelineEvents = GoToPointEvent | WaitEvent | StartPointEvent; @@ -69,8 +66,8 @@ export type TimelineEvents = GoToPointEvent | WaitEvent | StartPointEvent; */ export const ShowfileSchema = RuntypesRecord({ // Be sure to increment the schema version number when making breaking changes to the showfile schema. - $chessbots_show_schema_version: Literal(2), - // The timeline is an array of timeline 'layers' - a layer consists of an array that includes all the events for one robot. + $chessbots_show_schema_version: Literal(3), + // The timeline is an array of timeline 'layers'. A layer consists of an array that includes all the events for one robot. timeline: Array(TimelineLayerSchema), audio: Optional( RuntypesRecord({ @@ -88,10 +85,10 @@ export type Showfile = Static; * @returns - the spline representation of the timeline layer. */ export function timelineLayerToSpline(layer: TimelineLayer): Spline { - const [startPoint, events] = layer; + const { startPoint, remainingEvents } = layer; return { start: startPoint.target, - points: events + points: remainingEvents .filter((event) => event.type === TimelineEventTypes.GoToPointEvent) .map( (event) => @@ -106,10 +103,10 @@ export function timelineLayerToSpline(layer: TimelineLayer): Spline { */ export function createNewShowfile(): Showfile { return { - $chessbots_show_schema_version: 2, + $chessbots_show_schema_version: 3, timeline: [ - [ - { + { + startPoint: { type: TimelineEventTypes.StartPointEvent, target: { type: SplinePointType.StartPoint, @@ -121,7 +118,7 @@ export function createNewShowfile(): Showfile { durationMs: 7500, id: "4f21401d-07cf-434f-a73c-6482ab82f210", }, - [ + remainingEvents: [ { type: TimelineEventTypes.GoToPointEvent, durationMs: 1000, @@ -159,7 +156,7 @@ export function createNewShowfile(): Showfile { id: "4f21401d-07cf-434f-a73c-6482ab82f214", }, ], - ], + }, ], name: `Show ${new Date().toDateString()} ${new Date().toLocaleTimeString()}`, }; diff --git a/src/common/spline.ts b/src/common/spline.ts index 925c063f..566703ad 100644 --- a/src/common/spline.ts +++ b/src/common/spline.ts @@ -55,8 +55,8 @@ export const PointSchema = Union( export type Point = Static; /** - * Converts a point to an SVG path command. This is used in combination with the {@link splineToSvgDrawAttribute} function to convert a spline to a string that can be used as the `d` attribute of an SVG path. - * + * Converts a point to an SVG path command. This is used in combination with the {@link splineToSvgDrawAttribute} function to convert a spline to a string that can be used as the `d` attribute of an SVG path. + * * @remarks * For more context on the implementation/syntax, check out this MDN article about SVG paths: https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Paths * @param point - the point to convert. From e23180e7cef9d5b456891b9c7114520b6b167e4c Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Tue, 15 Apr 2025 21:32:21 -0500 Subject: [PATCH 40/92] Move some timeline components into separate files --- src/client/editor/editor.tsx | 205 ++------------------------------- src/client/editor/timeline.tsx | 176 ++++++++++++++++++++++++++++ src/common/show.ts | 16 +++ 3 files changed, 202 insertions(+), 195 deletions(-) create mode 100644 src/client/editor/timeline.tsx diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 84ff1b34..2e2fe7e4 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -1,17 +1,9 @@ -import { - forwardRef, - type PropsWithChildren, - useEffect, - useMemo, - useRef, -} from "react"; +import { useMemo } from "react"; import { Section, - Text, EditableText, Button, H2, - SectionCard, Card, ButtonGroup, Tag, @@ -21,35 +13,21 @@ import { } from "@blueprintjs/core"; import { RobotGrid } from "../debug/simulator"; import { - type TimelineEvents, - EVENT_TYPE_TO_COLOR, - type TimelineLayer, + millisToPixels, NonStartPointEvent, + RULER_TICK_GAP_PX, TimelineDurationUpdateMode, } from "../../common/show"; import { SplineEditor } from "./spline-editor"; -import { motion, useDragControls, useTransform } from "motion/react"; +import { motion, useTransform } from "motion/react"; import { useShowfile } from "./showfile-state"; import { Reorder } from "motion/react"; -import interact from "interactjs"; - -const RULER_TICK_INTERVAL_MS = 100; -// TODO: make ruler tick size configurable so we can zoom. relatively low priority. would be nice if gestures could be supported too -const RULER_TICK_GAP_PX = 12; - -const RULER_EXTRA_TICK_COUNT = Math.round( - window.innerWidth / 4 / RULER_TICK_GAP_PX, -); - -function millisToPixels(millis: number): number { - return (millis / RULER_TICK_INTERVAL_MS) * RULER_TICK_GAP_PX; -} - -function pixelsToMillis(pixels: number): number { - return (pixels / RULER_TICK_GAP_PX) * RULER_TICK_INTERVAL_MS; -} - -// function +import { + Ruler, + TimelineLayer, + TimelineEvent, + ReorderableTimelineEvent, +} from "./timeline"; // TODO: ui for adding/removing audio - remove current hotkey as this was mainly for testing @@ -370,166 +348,3 @@ export function Editor() {
); } - -function ReorderableTimelineEvent({ - event, - onDurationChange, -}: PropsWithChildren<{ - event: TimelineEvents; - onDurationChange: (ms: number) => void; -}>) { - const controls = useDragControls(); - - return ( - - controls.start(e)} - onDurationChange={onDurationChange} - /> - - ); -} - -function TimelineEvent(props: { - event: TimelineEvents; - onPointerDownOnDragHandle?: ( - event: React.PointerEvent, - ) => void; - onDurationChange?: (deltaMs: number) => void; -}) { - const ref = useRef(null); - const { event, onPointerDownOnDragHandle, onDurationChange } = props; - useEffect(() => { - if (!onDurationChange) return; - if (!ref.current) return; - const el = ref.current; - - interact(el).resizable({ - edges: { - right: true, - }, - listeners: { - move: function (event) { - event.preventDefault(); - event.stopPropagation(); - onDurationChange(pixelsToMillis(event.deltaRect.width)); - }, - }, - }); - }, [event.type, onDurationChange]); - - // TODO: add context menu for deleting events and adding a new event before and after this one - - return ( - - - {event.type} - - {onPointerDownOnDragHandle && ( - - )} - - ); -} - -const TimelineLayer = forwardRef< - HTMLDivElement, - PropsWithChildren<{ title: string; onDelete?: () => void }> ->(function TimelineCard({ title, children, onDelete }, ref) { - // TODO: add borders between columns. v low priority - // https://codepen.io/Kevin-Geary/pen/BavwqYX - // https://www.youtube.com/watch?v=EQYft7JPKto - return ( - - - {title} - {onDelete && ( -
@@ -478,35 +474,7 @@ const addTimestampToTimelineEvents = (events: TimelineEvents[]) => { return eventsWithTs; }; -const MotionRobot = motion.create(Robot); -function AnimatableRobot({ - robotId, - layer: {startPoint, remainingEvents}, - timestamp, -}: { - robotId: string; - layer: TimelineLayer; - timestamp: MotionValue; -}) { - const allEventsWithTimestamp = addTimestampToTimelineEvents([startPoint, ...remainingEvents].filter(e => e.type !== TimelineEventTypes.WaitEvent)); - const pairedEvents = arrayToNTuples(allEventsWithTimestamp, 2); - - // const offsetPath = useTransform(timestamp, pairedEvents.map(([e1]) => e1.endTimestamp)); - return ( - - ); -} /* diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts index 3eb31087..cad15c89 100644 --- a/src/client/editor/showfile-state.ts +++ b/src/client/editor/showfile-state.ts @@ -12,7 +12,7 @@ import { StartPointEvent, TimelineEventTypes, GoToPointEvent, - TimelineLayer, + TimelineLayerType, NonStartPointEvent, TimelineDurationUpdateMode, CHESSBOTS_SHOWFILE_MIME_TYPE, @@ -351,7 +351,7 @@ export function useShowfile() { ); const addRobot = useCallback(() => { - const newLayer: TimelineLayer = { + const newLayer: TimelineLayerType = { startPoint: { id: crypto.randomUUID(), type: TimelineEventTypes.StartPointEvent, diff --git a/src/client/editor/spline-editor.tsx b/src/client/editor/spline-editor.tsx index 826d39a5..0fc5ae71 100644 --- a/src/client/editor/spline-editor.tsx +++ b/src/client/editor/spline-editor.tsx @@ -6,13 +6,13 @@ import { } from "../../common/spline"; import { SplinePoint, SplineControlPoint } from "./points"; import { - type TimelineLayer, + type TimelineLayerType, timelineLayerToSpline, TimelineEventTypes, } from "../../common/show"; interface SplineEditorProps { - layer: TimelineLayer; + layer: TimelineLayerType; onStartPointMove: (newCoords: Coords) => void; onPointMove: (pointIndex: number, newCoords: Coords) => void; onControlPointMove: (pointIndex: number, newCoords: Coords) => void; diff --git a/src/common/show.ts b/src/common/show.ts index 9c5045b0..f8e9fdec 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -53,7 +53,7 @@ const TimelineLayerSchema = RuntypesRecord({ startPoint: StartPointEventSchema, remainingEvents: Array(NonStartPointEventSchema), }); -export type TimelineLayer = Static; +export type TimelineLayerType = Static; export type TimelineEvents = GoToPointEvent | WaitEvent | StartPointEvent; /** @@ -84,7 +84,7 @@ export type Showfile = Static; * @param layer - the timeline layer to convert. * @returns - the spline representation of the timeline layer. */ -export function timelineLayerToSpline(layer: TimelineLayer): Spline { +export function timelineLayerToSpline(layer: TimelineLayerType): Spline { const { startPoint, remainingEvents } = layer; return { start: startPoint.target, @@ -111,8 +111,8 @@ export function createNewShowfile(): Showfile { target: { type: SplinePointType.StartPoint, point: { - x: 0, - y: 70, + x: 20, + y: 140, }, }, durationMs: 7500, From 9b53a57524adc936ba89c99c2afd61657f709170 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 24 Apr 2025 18:23:42 -0500 Subject: [PATCH 54/92] Scuffed/broken implementation of robot motion --- src/client/editor/editor.tsx | 253 +++++++++++++++++----------- src/client/editor/motion-robot.tsx | 90 ++++++++++ src/client/editor/showfile-state.ts | 25 ++- src/common/getRobotStateAtTime.ts | 163 ++++++++++++++++++ src/common/spline.ts | 39 +++++ 5 files changed, 466 insertions(+), 104 deletions(-) create mode 100644 src/client/editor/motion-robot.tsx create mode 100644 src/common/getRobotStateAtTime.ts diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index acc111bc..6fa7a86e 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -16,17 +16,14 @@ import { NumericInput, Pre, } from "@blueprintjs/core"; -import { RobotGrid, robotSize, Robot } from "../debug/simulator"; +import { RobotGrid, robotSize } from "../debug/simulator"; import { GridCursorMode, millisToPixels, NonStartPointEvent, RULER_TICK_GAP_PX, TimelineDurationUpdateMode, - TimelineEvents, - TimelineEventTypes, } from "../../common/show"; -import { SplineEditor } from "./spline-editor"; import { Ruler, TimelineLayer, @@ -38,9 +35,69 @@ import { motion, MotionValue, useTransform, + useAnimate, + useMotionValueEvent, } from "motion/react"; import { useShowfile } from "./showfile-state"; import { Reorder } from "motion/react"; +import { getRobotStateAtTime } from "../../common/getRobotStateAtTime"; +import { MotionRobot } from "./motion-robot"; +import { TimelineLayerType } from "../../common/show"; +import { SplineEditor } from "./spline-editor"; + +// Helper component to manage animation for a single robot +function AnimatedRobotRenderer({ + layer, + timestamp, + robotId, +}: { + layer: TimelineLayerType; + timestamp: MotionValue; + robotId: string; +}) { + const [scope, animate] = useAnimate(); + + // Get initial state to avoid undefined on first render + const initialState = getRobotStateAtTime(layer, timestamp.get()); + + useMotionValueEvent(timestamp, "change", (latestTimestamp) => { + const { position, headingRadians } = getRobotStateAtTime( + layer, + latestTimestamp, + ); + const targetX = position.x - robotSize / 2; + const targetY = position.y - robotSize / 2; + const targetRotate = headingRadians * (180 / Math.PI); + + // Animate using transform + if (scope.current) { + animate( + scope.current, + { + transform: `translateX(${targetX}px) translateY(${targetY}px) rotate(${targetRotate}deg)`, + }, + { duration: 0, ease: "linear" }, + ); + } + }); + + // Calculate initial transform string + const initialX = initialState.position.x - robotSize / 2; + const initialY = initialState.position.y - robotSize / 2; + const initialRotate = initialState.headingRadians * (180 / Math.PI); + const initialTransform = `translateX(${initialX}px) translateY(${initialY}px) rotate(${initialRotate}deg)`; + + return ( + + ); +} export function Editor() { const { @@ -215,70 +272,100 @@ export function Editor() { )} -
-
- - - - {gridCursorMode === GridCursorMode.Pen && ( -
{ - const { x: mouseX, y: mouseY } = e.nativeEvent; - const { left: gridOriginX, top: gridOriginY } = - e.currentTarget.getBoundingClientRect(); - const x = mouseX - gridOriginX; - const y = mouseY - gridOriginY; - addPointToSelectedLayer( - x + robotSize / 4, - y + robotSize / 4, - ); - }} - >
- )} - {show.timeline.map((layer, index) => ( - <> - - handleStartPointMove(index, coords) - } - onPointMove={(pointIdx, coords) => - handlePointMove(index, pointIdx, coords) - } - onControlPointMove={(pointIdx, coords) => - handleControlPointMove(index, pointIdx, coords) - } - onDeleteStartPoint={() => - handleDeleteStartPoint(index) - } - onDeletePoint={(pointIdx) => - handleDeletePoint(index, pointIdx) - } - onSwitchPointType={(pointIdx, newType) => - handleSwitchPointType(index, pointIdx, newType) - } - /> - {/* TODO: render bots */} - - ))} -
-
-
-
{JSON.stringify({...show, audio: show.audio ? {data: "[binary data]", mimeType: show.audio.mimeType} : undefined}, null, 2)}
+
+
+ + {gridCursorMode === GridCursorMode.Pen && ( +
{ + const { x: mouseX, y: mouseY } = + e.nativeEvent; + const { + left: gridOriginX, + top: gridOriginY, + } = e.currentTarget.getBoundingClientRect(); + const x = mouseX - gridOriginX; + const y = mouseY - gridOriginY; + addPointToSelectedLayer( + x + robotSize / 4, + y + robotSize / 4, + ); + }} + >
+ )} + {show.timeline.map((layer, index) => ( + <> + {/* TODO: render bots */} -
+ + handleStartPointMove(index, coords) + } + onPointMove={(pointIdx, coords) => + handlePointMove(index, pointIdx, coords) + } + onControlPointMove={(pointIdx, coords) => + handleControlPointMove( + index, + pointIdx, + coords, + ) + } + onDeleteStartPoint={() => + handleDeleteStartPoint(index) + } + onDeletePoint={(pointIdx) => + handleDeletePoint(index, pointIdx) + } + onSwitchPointType={(pointIdx, newType) => + handleSwitchPointType( + index, + pointIdx, + newType, + ) + } + /> + + ))} + {/* Render Animated Robots separately */} + {show.timeline.map((layer, index) => ( + + ))} + +
+
+
+                        {JSON.stringify(
+                            {
+                                ...show,
+                                audio:
+                                    show.audio ?
+                                        {
+                                            data: "[binary data]",
+                                            mimeType: show.audio.mimeType,
+                                        }
+                                    :   undefined,
+                            },
+                            null,
+                            2,
+                        )}
+                    
+
); } - - -const addTimestampToTimelineEvents = (events: TimelineEvents[]) => { - let endTimestamp = 0; - - const eventsWithTs = events.map((e) => { - endTimestamp += e.durationMs; - - return { - ...e, - endTimestamp, - }; - }); - - return eventsWithTs; -}; - - - - -/* - * Splits an array into tuples of max length n. - */ -function arrayToNTuples(array: T[], n: number): T[][] { - return array.reduce((acc, item, index) => { - if (index % n === 0) { - acc.push([]); - } - acc[acc.length - 1].push(item); - return acc; - }, [] as T[][]); -} diff --git a/src/client/editor/motion-robot.tsx b/src/client/editor/motion-robot.tsx new file mode 100644 index 00000000..fd00b2d9 --- /dev/null +++ b/src/client/editor/motion-robot.tsx @@ -0,0 +1,90 @@ +import { CSSProperties, forwardRef } from "react"; +import { motion } from "framer-motion"; +import { Tooltip } from "@blueprintjs/core"; +import { robotSize } from "../debug/simulator"; // Reuse size +import { robotColor, innerRobotColor } from "../check-dark-mode"; +import { Coords } from "../../common/spline"; + +// Simple representation for display, doesn't need full simulation data +interface MotionRobotProps { + robotId: string; + position?: Coords; // Optional for initial render + style?: CSSProperties; + onTopOfRobotsCount?: number; // Simplified collision indication +} + +/** + * A Framer Motion animated robot component for the editor grid. + * Uses x, y, rotate for positioning and animation. + */ +export const MotionRobot = forwardRef( + function MotionRobot( + { robotId, position, style, onTopOfRobotsCount = 0 }, + ref, + ) { + // Convert position (if provided) to initial styles, but expect + // x, y, rotate to be controlled by useAnimate in the parent. + const initialStyle = + position ? + { + // Framer motion uses different origin - center based? Check docs. + // Let's assume x/y map directly for now and adjust if needed. + // The original used left/bottom. Motion x/y work from top/left. + x: `${position.x}px`, + y: `${position.y}px`, + // We apply rotation via the rotate transform property. + } + : {}; + + return ( + + +
+
+
+ + + ); + }, +); diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts index cad15c89..4dcc35ae 100644 --- a/src/client/editor/showfile-state.ts +++ b/src/client/editor/showfile-state.ts @@ -1,11 +1,9 @@ -// @ts-expect-error: chessbots client is a CommonJS module, but this library is a ES Module, so we need to tell TypeScript that it's okay import { decode as cborDecode, encode as cborEncode } from "cbor-x"; -// @ts-expect-error: chessbots client is a CommonJS module, but this library is a ES Module, so we need to tell TypeScript that it's okay import { fileOpen, fileSave } from "browser-fs-access"; import { diff } from "deep-object-diff"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createNewShowfile, ShowfileSchema, @@ -372,8 +370,25 @@ export function useShowfile() { }); }, [show, setShow]); - // TODO: figure out how to get this from the showfile - const sequenceLengthMs = 10 * 1000; + // Calculate sequenceLengthMs dynamically + const sequenceLengthMs = useMemo(() => { + let maxDuration = 0; + show.timeline.forEach(layer => { + let currentLayerDuration = 0; + // Add start point duration first if it exists + if (layer.startPoint) { + currentLayerDuration += layer.startPoint.durationMs; + } + // Add remaining events durations + layer.remainingEvents.forEach(event => { + currentLayerDuration += event.durationMs; + }); + maxDuration = Math.max(maxDuration, currentLayerDuration); + }); + // Use the max duration across all layers. + // Provide a default minimum duration if there are no events. + return maxDuration > 0 ? maxDuration : 10000; // Default to 10s + }, [show.timeline]); // TODO: continue adding comments to code below this line const { currentTimestamp, playing, togglePlaying } = diff --git a/src/common/getRobotStateAtTime.ts b/src/common/getRobotStateAtTime.ts new file mode 100644 index 00000000..6c52247f --- /dev/null +++ b/src/common/getRobotStateAtTime.ts @@ -0,0 +1,163 @@ +import { + type TimelineLayerType, + TimelineEventTypes, + type GoToPointEvent, + type StartPointEvent, + type NonStartPointEvent, +} from "./show"; +import { + type Coords, + type Midpoint, + SplinePointType, + evaluateQuadraticBezier, + evaluateCubicBezier, + derivativeQuadraticBezier, + derivativeCubicBezier, + reflectPoint, +} from "./spline"; + +export interface RobotState { + position: Coords; + headingRadians: number; +} + +/** + * Calculates the robot's position and heading at a specific timestamp within its timeline layer. + * + * @param layer - The timeline layer for the robot. + * @param timestampMs - The timestamp in milliseconds. + * @returns The robot's state (position and heading) at the given time. + */ +export function getRobotStateAtTime( + layer: TimelineLayerType, + timestampMs: number, +): RobotState { + let currentTimeMs = 0; + let previousPoint: Coords = layer.startPoint.target.point; + let previousHeadingRad = 0; // Default initial heading + let previousMidpointType: SplinePointType | null = null; // Track type of previous segment + let previousActualCP1: Coords | null = null; + let previousActualCP2: Coords | null = null; // Only relevant if previous was Cubic + + const allEvents = [layer.startPoint, ...layer.remainingEvents]; // Combine for easier iteration + + for (let i = 0; i < allEvents.length; i++) { + const currentEvent = allEvents[i]; + const eventStartTimeMs = currentTimeMs; + const eventEndTimeMs = currentTimeMs + currentEvent.durationMs; + + if (timestampMs >= eventStartTimeMs && timestampMs < eventEndTimeMs) { + // Timestamp falls within this event + const timeIntoEvent = timestampMs - eventStartTimeMs; + const progress = currentEvent.durationMs > 0 ? timeIntoEvent / currentEvent.durationMs : 1; // Avoid division by zero + + if (currentEvent.type === TimelineEventTypes.StartPointEvent) { + // Robot waits at the start point during its initial duration + return { position: previousPoint, headingRadians: previousHeadingRad }; + } + + if (currentEvent.type === TimelineEventTypes.WaitEvent) { + // Robot waits at the end of the previous movement + return { position: previousPoint, headingRadians: previousHeadingRad }; + } + + if (currentEvent.type === TimelineEventTypes.GoToPointEvent) { + const target = currentEvent.target; + const startPos = previousPoint; + const endPos = target.endPoint; + let p1: Coords; + let p2: Coords; + let derivativeFunc: (t: number) => Coords; + let evaluateFunc: (t: number) => Coords; + + let currentActualCP1: Coords | null = null; + let currentActualCP2: Coords | null = null; + + if (target.type === SplinePointType.QuadraticBezier) { + if (previousMidpointType === SplinePointType.QuadraticBezier && previousActualCP1) { + p1 = reflectPoint(previousActualCP1, startPos); + } else if (previousMidpointType === SplinePointType.CubicBezier && previousActualCP2) { + p1 = reflectPoint(previousActualCP2, startPos); + } else { + p1 = startPos; + } + currentActualCP1 = p1; + evaluateFunc = (t) => evaluateQuadraticBezier(startPos, p1, endPos, t); + derivativeFunc = (t) => derivativeQuadraticBezier(startPos, p1, endPos, t); + } + else { // Cubic Bezier + const controlPoint2 = target.controlPoint; + if (previousMidpointType === SplinePointType.CubicBezier && previousActualCP2) { + p1 = reflectPoint(previousActualCP2, startPos); + } else { + p1 = startPos; + } + currentActualCP1 = p1; + currentActualCP2 = controlPoint2; + evaluateFunc = (t) => evaluateCubicBezier(startPos, p1, controlPoint2, endPos, t); + derivativeFunc = (t) => derivativeCubicBezier(startPos, p1, controlPoint2, endPos, t); + } + + const currentPosition = evaluateFunc(progress); + const derivative = derivativeFunc(progress); + const currentHeading = Math.atan2(derivative.y, derivative.x); + + return { position: currentPosition, headingRadians: isNaN(currentHeading) ? previousHeadingRad : currentHeading }; + } + } + + // Update state for the next iteration + currentTimeMs = eventEndTimeMs; + if (currentEvent.type === TimelineEventTypes.GoToPointEvent) { + previousPoint = currentEvent.target.endPoint; + previousMidpointType = currentEvent.target.type; + + let final_p1: Coords | null = null; + let final_p2: Coords | null = null; + let derivativeFunc_final: (t: number) => Coords; + const target = currentEvent.target; + const startPos = (previousMidpointType !== null) ? previousPoint : layer.startPoint.target.point; + + const _startPos_deriv = previousPoint; + const endPos = target.endPoint; + + if (target.type === SplinePointType.QuadraticBezier) { + if (previousMidpointType === SplinePointType.QuadraticBezier && previousActualCP1) { + final_p1 = reflectPoint(previousActualCP1, _startPos_deriv); + } else if (previousMidpointType === SplinePointType.CubicBezier && previousActualCP2) { + final_p1 = reflectPoint(previousActualCP2, _startPos_deriv); + } else { + final_p1 = _startPos_deriv; + } + previousActualCP1 = final_p1; + previousActualCP2 = null; + derivativeFunc_final = (t) => derivativeQuadraticBezier(_startPos_deriv, final_p1!, endPos, t); + } else { + const controlPoint2 = target.controlPoint; + if (previousMidpointType === SplinePointType.CubicBezier && previousActualCP2) { + final_p1 = reflectPoint(previousActualCP2, _startPos_deriv); + } else { + final_p1 = _startPos_deriv; + } + final_p2 = controlPoint2; + previousActualCP1 = final_p1; + previousActualCP2 = final_p2; + derivativeFunc_final = (t) => derivativeCubicBezier(_startPos_deriv, final_p1!, final_p2!, endPos, t); + } + const finalDerivative = derivativeFunc_final(1); + const finalHeading = Math.atan2(finalDerivative.y, finalDerivative.x); + previousHeadingRad = isNaN(finalHeading) ? previousHeadingRad : finalHeading; + + } else { + if (currentEvent.type === TimelineEventTypes.StartPointEvent) { + previousPoint = currentEvent.target.point; + } + previousMidpointType = null; + previousActualCP1 = null; + previousActualCP2 = null; + } + } + + // If timestamp is beyond the last event, stay at the final position and heading + return { position: previousPoint, headingRadians: previousHeadingRad }; +} \ No newline at end of file diff --git a/src/common/spline.ts b/src/common/spline.ts index 566703ad..4017ca36 100644 --- a/src/common/spline.ts +++ b/src/common/spline.ts @@ -91,3 +91,42 @@ export function splineToSvgDrawAttribute(spline: Spline): string { } return path; } + +export function evaluateQuadraticBezier(p0: Coords, p1: Coords, p2: Coords, t: number): Coords { + const mt = 1 - t; + const x = mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x; + const y = mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y; + return { x, y }; +} + +export function evaluateCubicBezier(p0: Coords, p1: Coords, p2: Coords, p3: Coords, t: number): Coords { + const mt = 1 - t; + const mt2 = mt * mt; + const t2 = t * t; + const x = mt2 * mt * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t2 * t * p3.x; + const y = mt2 * mt * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t2 * t * p3.y; + return { x, y }; +} + +export function derivativeQuadraticBezier(p0: Coords, p1: Coords, p2: Coords, t: number): Coords { + // Derivative: 2(1-t)(p1-p0) + 2t(p2-p1) + const x = 2 * (1 - t) * (p1.x - p0.x) + 2 * t * (p2.x - p1.x); + const y = 2 * (1 - t) * (p1.y - p0.y) + 2 * t * (p2.y - p1.y); + return { x, y }; +} + +export function derivativeCubicBezier(p0: Coords, p1: Coords, p2: Coords, p3: Coords, t: number): Coords { + // Derivative: 3(1-t)^2(p1-p0) + 6(1-t)t(p2-p1) + 3t^2(p3-p2) + const mt = 1 - t; + const x = 3 * mt * mt * (p1.x - p0.x) + 6 * mt * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x); + const y = 3 * mt * mt * (p1.y - p0.y) + 6 * mt * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y); + return { x, y }; +} + +/** Reflect point p over pivot */ +export function reflectPoint(p: Coords, pivot: Coords): Coords { + return { + x: pivot.x + (pivot.x - p.x), + y: pivot.y + (pivot.y - p.y), + }; +} From ed6aeba07a318c18a0c12e24b6eeaec44202f7e2 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 24 Apr 2025 21:01:33 -0500 Subject: [PATCH 55/92] Format --- src/client/debug/simulator.tsx | 7 +- src/client/editor/showfile-state.ts | 8 +- src/common/getRobotStateAtTime.ts | 127 +++++++++++++++++++--------- src/common/spline.ts | 52 ++++++++++-- 4 files changed, 139 insertions(+), 55 deletions(-) diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx index e60a51d1..6600abda 100644 --- a/src/client/debug/simulator.tsx +++ b/src/client/debug/simulator.tsx @@ -362,12 +362,7 @@ export const Robot = forwardRef< onTopOfRobots: string[]; style?: CSSProperties; } ->(function Robot({ - pos, - robotId, - onTopOfRobots, - style, -}, ref) { +>(function Robot({ pos, robotId, onTopOfRobots, style }, ref) { return (
{ let maxDuration = 0; - show.timeline.forEach(layer => { + show.timeline.forEach((layer) => { let currentLayerDuration = 0; // Add start point duration first if it exists if (layer.startPoint) { - currentLayerDuration += layer.startPoint.durationMs; + currentLayerDuration += layer.startPoint.durationMs; } // Add remaining events durations - layer.remainingEvents.forEach(event => { + layer.remainingEvents.forEach((event) => { currentLayerDuration += event.durationMs; }); maxDuration = Math.max(maxDuration, currentLayerDuration); diff --git a/src/common/getRobotStateAtTime.ts b/src/common/getRobotStateAtTime.ts index 6c52247f..f01618ad 100644 --- a/src/common/getRobotStateAtTime.ts +++ b/src/common/getRobotStateAtTime.ts @@ -1,13 +1,6 @@ -import { - type TimelineLayerType, - TimelineEventTypes, - type GoToPointEvent, - type StartPointEvent, - type NonStartPointEvent, -} from "./show"; +import { type TimelineLayerType, TimelineEventTypes } from "./show"; import { type Coords, - type Midpoint, SplinePointType, evaluateQuadraticBezier, evaluateCubicBezier, @@ -49,16 +42,25 @@ export function getRobotStateAtTime( if (timestampMs >= eventStartTimeMs && timestampMs < eventEndTimeMs) { // Timestamp falls within this event const timeIntoEvent = timestampMs - eventStartTimeMs; - const progress = currentEvent.durationMs > 0 ? timeIntoEvent / currentEvent.durationMs : 1; // Avoid division by zero + const progress = + currentEvent.durationMs > 0 ? + timeIntoEvent / currentEvent.durationMs + : 1; // Avoid division by zero if (currentEvent.type === TimelineEventTypes.StartPointEvent) { // Robot waits at the start point during its initial duration - return { position: previousPoint, headingRadians: previousHeadingRad }; + return { + position: previousPoint, + headingRadians: previousHeadingRad, + }; } if (currentEvent.type === TimelineEventTypes.WaitEvent) { // Robot waits at the end of the previous movement - return { position: previousPoint, headingRadians: previousHeadingRad }; + return { + position: previousPoint, + headingRadians: previousHeadingRad, + }; } if (currentEvent.type === TimelineEventTypes.GoToPointEvent) { @@ -66,43 +68,68 @@ export function getRobotStateAtTime( const startPos = previousPoint; const endPos = target.endPoint; let p1: Coords; - let p2: Coords; let derivativeFunc: (t: number) => Coords; let evaluateFunc: (t: number) => Coords; - let currentActualCP1: Coords | null = null; - let currentActualCP2: Coords | null = null; - if (target.type === SplinePointType.QuadraticBezier) { - if (previousMidpointType === SplinePointType.QuadraticBezier && previousActualCP1) { + if ( + previousMidpointType === + SplinePointType.QuadraticBezier && + previousActualCP1 + ) { p1 = reflectPoint(previousActualCP1, startPos); - } else if (previousMidpointType === SplinePointType.CubicBezier && previousActualCP2) { + } else if ( + previousMidpointType === SplinePointType.CubicBezier && + previousActualCP2 + ) { p1 = reflectPoint(previousActualCP2, startPos); } else { p1 = startPos; } - currentActualCP1 = p1; - evaluateFunc = (t) => evaluateQuadraticBezier(startPos, p1, endPos, t); - derivativeFunc = (t) => derivativeQuadraticBezier(startPos, p1, endPos, t); - } - else { // Cubic Bezier + evaluateFunc = (t) => + evaluateQuadraticBezier(startPos, p1, endPos, t); + derivativeFunc = (t) => + derivativeQuadraticBezier(startPos, p1, endPos, t); + } else { + // Cubic Bezier const controlPoint2 = target.controlPoint; - if (previousMidpointType === SplinePointType.CubicBezier && previousActualCP2) { + if ( + previousMidpointType === SplinePointType.CubicBezier && + previousActualCP2 + ) { p1 = reflectPoint(previousActualCP2, startPos); } else { p1 = startPos; } - currentActualCP1 = p1; - currentActualCP2 = controlPoint2; - evaluateFunc = (t) => evaluateCubicBezier(startPos, p1, controlPoint2, endPos, t); - derivativeFunc = (t) => derivativeCubicBezier(startPos, p1, controlPoint2, endPos, t); + evaluateFunc = (t) => + evaluateCubicBezier( + startPos, + p1, + controlPoint2, + endPos, + t, + ); + derivativeFunc = (t) => + derivativeCubicBezier( + startPos, + p1, + controlPoint2, + endPos, + t, + ); } const currentPosition = evaluateFunc(progress); const derivative = derivativeFunc(progress); const currentHeading = Math.atan2(derivative.y, derivative.x); - return { position: currentPosition, headingRadians: isNaN(currentHeading) ? previousHeadingRad : currentHeading }; + return { + position: currentPosition, + headingRadians: + isNaN(currentHeading) ? previousHeadingRad : ( + currentHeading + ), + }; } } @@ -116,25 +143,39 @@ export function getRobotStateAtTime( let final_p2: Coords | null = null; let derivativeFunc_final: (t: number) => Coords; const target = currentEvent.target; - const startPos = (previousMidpointType !== null) ? previousPoint : layer.startPoint.target.point; const _startPos_deriv = previousPoint; const endPos = target.endPoint; if (target.type === SplinePointType.QuadraticBezier) { - if (previousMidpointType === SplinePointType.QuadraticBezier && previousActualCP1) { + if ( + previousMidpointType === SplinePointType.QuadraticBezier && + previousActualCP1 + ) { final_p1 = reflectPoint(previousActualCP1, _startPos_deriv); - } else if (previousMidpointType === SplinePointType.CubicBezier && previousActualCP2) { + } else if ( + previousMidpointType === SplinePointType.CubicBezier && + previousActualCP2 + ) { final_p1 = reflectPoint(previousActualCP2, _startPos_deriv); } else { final_p1 = _startPos_deriv; } previousActualCP1 = final_p1; previousActualCP2 = null; - derivativeFunc_final = (t) => derivativeQuadraticBezier(_startPos_deriv, final_p1!, endPos, t); + derivativeFunc_final = (t) => + derivativeQuadraticBezier( + _startPos_deriv, + final_p1!, + endPos, + t, + ); } else { const controlPoint2 = target.controlPoint; - if (previousMidpointType === SplinePointType.CubicBezier && previousActualCP2) { + if ( + previousMidpointType === SplinePointType.CubicBezier && + previousActualCP2 + ) { final_p1 = reflectPoint(previousActualCP2, _startPos_deriv); } else { final_p1 = _startPos_deriv; @@ -142,12 +183,22 @@ export function getRobotStateAtTime( final_p2 = controlPoint2; previousActualCP1 = final_p1; previousActualCP2 = final_p2; - derivativeFunc_final = (t) => derivativeCubicBezier(_startPos_deriv, final_p1!, final_p2!, endPos, t); + derivativeFunc_final = (t) => + derivativeCubicBezier( + _startPos_deriv, + final_p1!, + final_p2!, + endPos, + t, + ); } const finalDerivative = derivativeFunc_final(1); - const finalHeading = Math.atan2(finalDerivative.y, finalDerivative.x); - previousHeadingRad = isNaN(finalHeading) ? previousHeadingRad : finalHeading; - + const finalHeading = Math.atan2( + finalDerivative.y, + finalDerivative.x, + ); + previousHeadingRad = + isNaN(finalHeading) ? previousHeadingRad : finalHeading; } else { if (currentEvent.type === TimelineEventTypes.StartPointEvent) { previousPoint = currentEvent.target.point; @@ -160,4 +211,4 @@ export function getRobotStateAtTime( // If timestamp is beyond the last event, stay at the final position and heading return { position: previousPoint, headingRadians: previousHeadingRad }; -} \ No newline at end of file +} diff --git a/src/common/spline.ts b/src/common/spline.ts index 4017ca36..36eae7e0 100644 --- a/src/common/spline.ts +++ b/src/common/spline.ts @@ -92,34 +92,70 @@ export function splineToSvgDrawAttribute(spline: Spline): string { return path; } -export function evaluateQuadraticBezier(p0: Coords, p1: Coords, p2: Coords, t: number): Coords { +export function evaluateQuadraticBezier( + p0: Coords, + p1: Coords, + p2: Coords, + t: number, +): Coords { const mt = 1 - t; const x = mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x; const y = mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y; return { x, y }; } -export function evaluateCubicBezier(p0: Coords, p1: Coords, p2: Coords, p3: Coords, t: number): Coords { +export function evaluateCubicBezier( + p0: Coords, + p1: Coords, + p2: Coords, + p3: Coords, + t: number, +): Coords { const mt = 1 - t; const mt2 = mt * mt; const t2 = t * t; - const x = mt2 * mt * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t2 * t * p3.x; - const y = mt2 * mt * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t2 * t * p3.y; + const x = + mt2 * mt * p0.x + + 3 * mt2 * t * p1.x + + 3 * mt * t2 * p2.x + + t2 * t * p3.x; + const y = + mt2 * mt * p0.y + + 3 * mt2 * t * p1.y + + 3 * mt * t2 * p2.y + + t2 * t * p3.y; return { x, y }; } -export function derivativeQuadraticBezier(p0: Coords, p1: Coords, p2: Coords, t: number): Coords { +export function derivativeQuadraticBezier( + p0: Coords, + p1: Coords, + p2: Coords, + t: number, +): Coords { // Derivative: 2(1-t)(p1-p0) + 2t(p2-p1) const x = 2 * (1 - t) * (p1.x - p0.x) + 2 * t * (p2.x - p1.x); const y = 2 * (1 - t) * (p1.y - p0.y) + 2 * t * (p2.y - p1.y); return { x, y }; } -export function derivativeCubicBezier(p0: Coords, p1: Coords, p2: Coords, p3: Coords, t: number): Coords { +export function derivativeCubicBezier( + p0: Coords, + p1: Coords, + p2: Coords, + p3: Coords, + t: number, +): Coords { // Derivative: 3(1-t)^2(p1-p0) + 6(1-t)t(p2-p1) + 3t^2(p3-p2) const mt = 1 - t; - const x = 3 * mt * mt * (p1.x - p0.x) + 6 * mt * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x); - const y = 3 * mt * mt * (p1.y - p0.y) + 6 * mt * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y); + const x = + 3 * mt * mt * (p1.x - p0.x) + + 6 * mt * t * (p2.x - p1.x) + + 3 * t * t * (p3.x - p2.x); + const y = + 3 * mt * mt * (p1.y - p0.y) + + 6 * mt * t * (p2.y - p1.y) + + 3 * t * t * (p3.y - p2.y); return { x, y }; } From a21fab5186342cf161aec2117a9096cbe8d23a4f Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 24 Apr 2025 21:15:45 -0500 Subject: [PATCH 56/92] Add web debug configuration --- .vscode/launch.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 158e0ca6..1f09e038 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,10 +4,17 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Debug ChessBots Client", + "request": "launch", + "type": "chrome", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}" + }, { "type": "node", "request": "launch", - "name": "Debug ChessBot Server", + "name": "Debug ChessBots Server", "runtimeExecutable": "ts-node", "program": "${workspaceFolder}/src/server/main.ts", "restart": true, From 697854d21263efd3c0e03559ddd32ab238a61cbf Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 24 Apr 2025 21:16:36 -0500 Subject: [PATCH 57/92] Add delete to event context menu --- src/client/editor/editor.tsx | 7 +++++++ src/client/editor/showfile-state.ts | 19 +++++++++++++++++++ src/client/editor/timeline.tsx | 10 +++++++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 6fa7a86e..2808c93a 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -137,6 +137,7 @@ export function Editor() { setSelectedLayerIndex, selectedLayerIndex, removeAudio, + deleteTimelineEvent } = useShowfile(); // TODO: fix viewport height / timeline height @@ -529,6 +530,12 @@ export function Editor() { ms, ) } + onDelete={() => + deleteTimelineEvent( + layerIndex, + event.id, + ) + } /> ); })} diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts index 29364dfc..d026c8bc 100644 --- a/src/client/editor/showfile-state.ts +++ b/src/client/editor/showfile-state.ts @@ -557,6 +557,24 @@ export function useShowfile() { audio: undefined, }); }, [show, setShow]); + + const deleteTimelineEvent = useCallback( + (layerIndex: number, eventId: string) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) { + return; + } + const { startPoint, remainingEvents } = layer; + const eventIndex = remainingEvents.findIndex( + (event) => event.id === eventId, + ); + const events = remainingEvents.toSpliced(eventIndex, 1); // Remove the event + newTimeline[layerIndex] = { startPoint, remainingEvents: events }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, setShow], + ); return { updateTimelineEventOrders, show, @@ -594,5 +612,6 @@ export function useShowfile() { setSelectedLayerIndex, selectedLayerIndex, removeAudio, + deleteTimelineEvent }; } diff --git a/src/client/editor/timeline.tsx b/src/client/editor/timeline.tsx index f0629c2e..de96c87d 100644 --- a/src/client/editor/timeline.tsx +++ b/src/client/editor/timeline.tsx @@ -23,9 +23,11 @@ import { export function ReorderableTimelineEvent({ event, onDurationChange, + onDelete, }: PropsWithChildren<{ event: TimelineEvents; onDurationChange: (ms: number) => void; + onDelete: () => void; }>) { const controls = useDragControls(); @@ -39,6 +41,7 @@ export function ReorderableTimelineEvent({ event={event} onPointerDownOnDragHandle={(e) => controls.start(e)} onDurationChange={onDurationChange} + onDelete={onDelete} /> ); @@ -50,9 +53,10 @@ export function TimelineEvent(props: { event: React.PointerEvent, ) => void; onDurationChange?: (deltaMs: number) => void; + onDelete?: () => void; }) { const ref = useRef(null); - const { event, onPointerDownOnDragHandle, onDurationChange } = props; + const { event, onPointerDownOnDragHandle, onDurationChange, onDelete } = props; useEffect(() => { if (!onDurationChange) return; if (!ref.current) return; @@ -72,14 +76,14 @@ export function TimelineEvent(props: { }); }, [event.type, onDurationChange]); - // TODO: add context menu for deleting events and adding a new event before and after this one + // TODO: add context menu for adding a new event before and after this one return ( {/* TODO: add wait, spin before/after current event */} - + } > From e96cb05674efe8abe112bf8919f0a1f8884e1be9 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 24 Apr 2025 23:40:34 -0500 Subject: [PATCH 58/92] Add drag-to-seek, add wait event --- src/client/editor/editor.tsx | 93 +++++++++++++++++++++-------- src/client/editor/showfile-state.ts | 30 +++++++++- src/client/editor/timeline.tsx | 40 +++++++++++-- 3 files changed, 131 insertions(+), 32 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 2808c93a..3a173a44 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -1,6 +1,4 @@ -// TODO: how to add wait events? - -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Section, EditableText, @@ -21,6 +19,7 @@ import { GridCursorMode, millisToPixels, NonStartPointEvent, + pixelsToMillis, RULER_TICK_GAP_PX, TimelineDurationUpdateMode, } from "../../common/show"; @@ -44,6 +43,7 @@ import { getRobotStateAtTime } from "../../common/getRobotStateAtTime"; import { MotionRobot } from "./motion-robot"; import { TimelineLayerType } from "../../common/show"; import { SplineEditor } from "./spline-editor"; +import interact from "interactjs"; // Helper component to manage animation for a single robot function AnimatedRobotRenderer({ @@ -137,7 +137,9 @@ export function Editor() { setSelectedLayerIndex, selectedLayerIndex, removeAudio, - deleteTimelineEvent + deleteTimelineEvent, + setTimestamp, + addWaitEventAtIndex, } = useShowfile(); // TODO: fix viewport height / timeline height @@ -204,6 +206,25 @@ export function Editor() { const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys); + const seekBarRef = useRef(null); + + useEffect(() => { + if (!seekBarRef.current) return; + const el = seekBarRef.current; + interact(el).resizable({ + edges: { + right: true, + }, + listeners: { + move: function (event) { + event.preventDefault(); + event.stopPropagation(); + const newTimestamp = pixelsToMillis(event.rect.width); + setTimestamp(newTimestamp); + }, + }, + }); + }, [setTimestamp]); return (
- {remainingEvents.map((event) => { - return ( - - updateTimelineEventDurations( - layerIndex, - event.id, + {remainingEvents.map( + (event, eventIndex) => { + return ( + - deleteTimelineEvent( - layerIndex, - event.id, - ) - } - /> - ); - })} + ) => + updateTimelineEventDurations( + layerIndex, + event.id, + ms, + ) + } + onDelete={() => + deleteTimelineEvent( + layerIndex, + event.id, + ) + } + onAddWaitEvent={( + position, + ) => + ( + position === + "before" + ) ? + addWaitEventAtIndex( + layerIndex, + eventIndex, + ) + : addWaitEventAtIndex( + layerIndex, + eventIndex + + 1, + ) + } + /> + ); + }, + )}
{/* TODO: add ability to add events */} diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts index d026c8bc..fb23515b 100644 --- a/src/client/editor/showfile-state.ts +++ b/src/client/editor/showfile-state.ts @@ -18,6 +18,7 @@ import { CHESSBOTS_SHOWFILE_MIME_TYPE, CHESSBOTS_SHOWFILE_EXTENSION, GridCursorMode, + WaitEvent, } from "../../common/show"; import { SplinePointType, @@ -393,7 +394,7 @@ export function useShowfile() { }, [show.timeline]); // TODO: continue adding comments to code below this line - const { currentTimestamp, playing, togglePlaying } = + const { currentTimestamp, playing, togglePlaying, setTimestamp } = usePlayHead(sequenceLengthMs); const { audio } = show; @@ -575,6 +576,29 @@ export function useShowfile() { }, [show, setShow], ); + + const addWaitEventAtIndex = useCallback( + (layerIndex: number, eventIndex: number) => { + const newTimeline = [...show.timeline]; + const layer = newTimeline[layerIndex]; + if (!layer) return; + const { startPoint, remainingEvents } = layer; + const events = [...remainingEvents]; + const eventToAdd: WaitEvent = { + id: crypto.randomUUID(), + type: TimelineEventTypes.WaitEvent, + durationMs: defaultEventDurationMs, + }; + events.splice(eventIndex, 0, eventToAdd); + newTimeline[layerIndex] = { + startPoint, + remainingEvents: events, + }; + setShow({ ...show, timeline: newTimeline }); + }, + [show, defaultEventDurationMs, setShow], + ); + return { updateTimelineEventOrders, show, @@ -612,6 +636,8 @@ export function useShowfile() { setSelectedLayerIndex, selectedLayerIndex, removeAudio, - deleteTimelineEvent + deleteTimelineEvent, + setTimestamp, + addWaitEventAtIndex, }; } diff --git a/src/client/editor/timeline.tsx b/src/client/editor/timeline.tsx index de96c87d..5cd19814 100644 --- a/src/client/editor/timeline.tsx +++ b/src/client/editor/timeline.tsx @@ -24,10 +24,12 @@ export function ReorderableTimelineEvent({ event, onDurationChange, onDelete, + onAddWaitEvent, }: PropsWithChildren<{ event: TimelineEvents; onDurationChange: (ms: number) => void; - onDelete: () => void; + onDelete?: () => void; + onAddWaitEvent?: (position: "before" | "after") => void; }>) { const controls = useDragControls(); @@ -42,6 +44,7 @@ export function ReorderableTimelineEvent({ onPointerDownOnDragHandle={(e) => controls.start(e)} onDurationChange={onDurationChange} onDelete={onDelete} + onAddWaitEvent={onAddWaitEvent} /> ); @@ -54,9 +57,16 @@ export function TimelineEvent(props: { ) => void; onDurationChange?: (deltaMs: number) => void; onDelete?: () => void; + onAddWaitEvent?: (position: "before" | "after") => void; }) { const ref = useRef(null); - const { event, onPointerDownOnDragHandle, onDurationChange, onDelete } = props; + const { + event, + onPointerDownOnDragHandle, + onDurationChange, + onDelete, + onAddWaitEvent, + } = props; useEffect(() => { if (!onDurationChange) return; if (!ref.current) return; @@ -82,8 +92,30 @@ export function TimelineEvent(props: { - {/* TODO: add wait, spin before/after current event */} - + {/* TODO: add spin before/after current event */} + {onDelete && ( + + )} + {onAddWaitEvent && ( + <> + onAddWaitEvent("after")} + /> + + )} + {onAddWaitEvent && ( + <> + onAddWaitEvent("before")} + /> + + )} } > From e670b80526954736ff60badb911a4d89ad9c8fe3 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 24 Apr 2025 23:54:52 -0500 Subject: [PATCH 59/92] Adjust opacity of non selected splines --- src/client/editor/editor.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 3a173a44..05668417 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -327,6 +327,7 @@ export function Editor() { <> {/* TODO: render bots */} +
+
))} {/* Render Animated Robots separately */} From f6c2fe730a39cb9675f84454bba02981d6795ddb Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 24 Apr 2025 23:55:02 -0500 Subject: [PATCH 60/92] Format --- src/client/editor/editor.tsx | 71 ++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 05668417..6730f88b 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -327,37 +327,54 @@ export function Editor() { <> {/* TODO: render bots */} -
- - handleStartPointMove(index, coords) - } - onPointMove={(pointIdx, coords) => - handlePointMove(index, pointIdx, coords) - } - onControlPointMove={(pointIdx, coords) => - handleControlPointMove( - index, +
+ + handleStartPointMove(index, coords) + } + onPointMove={(pointIdx, coords) => + handlePointMove( + index, + pointIdx, + coords, + ) + } + onControlPointMove={( pointIdx, coords, - ) - } - onDeleteStartPoint={() => - handleDeleteStartPoint(index) - } - onDeletePoint={(pointIdx) => - handleDeletePoint(index, pointIdx) - } - onSwitchPointType={(pointIdx, newType) => - handleSwitchPointType( - index, + ) => + handleControlPointMove( + index, + pointIdx, + coords, + ) + } + onDeleteStartPoint={() => + handleDeleteStartPoint(index) + } + onDeletePoint={(pointIdx) => + handleDeletePoint(index, pointIdx) + } + onSwitchPointType={( pointIdx, newType, - ) - } - /> + ) => + handleSwitchPointType( + index, + pointIdx, + newType, + ) + } + />
))} From b8ec128a6fd6b4e1c442ad70fa6eb84e3159cfd0 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Fri, 25 Apr 2025 02:10:32 -0500 Subject: [PATCH 61/92] Try memoizing return value of `useShowfile` --- src/client/editor/showfile-state.ts | 46 +++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts index fb23515b..0ed12b72 100644 --- a/src/client/editor/showfile-state.ts +++ b/src/client/editor/showfile-state.ts @@ -599,7 +599,7 @@ export function useShowfile() { [show, defaultEventDurationMs, setShow], ); - return { + const showfileApi = useMemo(() => ({ updateTimelineEventOrders, show, unsavedChanges, @@ -639,5 +639,47 @@ export function useShowfile() { deleteTimelineEvent, setTimestamp, addWaitEventAtIndex, - }; + }), [ + updateTimelineEventOrders, + show, + unsavedChanges, + loadAudioFromFile, + handleStartPointMove, + handlePointMove, + handleControlPointMove, + handleDeleteStartPoint, + handleDeletePoint, + handleSwitchPointType, + saveShowfile, + openShowfile, + editName, + undo, + redo, + addRobot, + currentTimestamp, + playing, + togglePlaying, + deleteLayer, + canRedo, + canUndo, + sequenceLengthMs, + timelineDurationUpdateMode, + setTimelineDurationUpdateMode, + updateTimelineEventDurations, + gridCursorMode, + setGridCursorMode, + defaultPointType, + setDefaultPointType, + setDefaultEventDurationMs, + defaultEventDurationMs, + addPointToSelectedLayer, + setSelectedLayerIndex, + selectedLayerIndex, + removeAudio, + deleteTimelineEvent, + setTimestamp, + addWaitEventAtIndex, + ]); + + return showfileApi; } From 9823c28ea9a636bf02012fb053d043c862c9f0ce Mon Sep 17 00:00:00 2001 From: Mason Thomas Date: Fri, 25 Apr 2025 02:30:54 -0500 Subject: [PATCH 62/92] create cubic and quadratic drive schemas --- src/server/robot/robot.ts | 35 +++++++++++++++++++++++++++++++++- src/server/utils/tcp-packet.ts | 25 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/server/robot/robot.ts b/src/server/robot/robot.ts index 5089904f..09546100 100644 --- a/src/server/robot/robot.ts +++ b/src/server/robot/robot.ts @@ -124,6 +124,39 @@ export class Robot { */ public async sendDriveTicksPacket(distanceTicks: number): Promise { const tunnel = this.getTunnel(); - await tunnel.send({ type: PacketType.DRIVE_TICKS, tickDistance: distanceTicks }); + await tunnel.send({ + type: PacketType.DRIVE_TICKS, + tickDistance: distanceTicks, + }); + } + + public async sendDriveCubicPacket( + startPosition: Position, + endPosition: Position, + controlPosition: Position, + timeDeltaMs: number, + ): Promise { + const tunnel = this.getTunnel(); + await tunnel.send({ + type: PacketType.DRIVE_CUBIC_SPLINE, + startPosition: startPosition, + endPosition: endPosition, + controlPosition: controlPosition, + timeDeltaMs: timeDeltaMs, + }); + } + + public async sendDriveQuadraticPacket( + startPosition: Position, + endPosition: Position, + timeDeltaMs: number, + ): Promise { + const tunnel = this.getTunnel(); + await tunnel.send({ + type: PacketType.DRIVE_QUADRATIC_SPLINE, + startPosition: startPosition, + endPosition: endPosition, + timeDeltaMs: timeDeltaMs, + }); } } diff --git a/src/server/utils/tcp-packet.ts b/src/server/utils/tcp-packet.ts index 4ad65c7c..8ac564a8 100644 --- a/src/server/utils/tcp-packet.ts +++ b/src/server/utils/tcp-packet.ts @@ -23,6 +23,8 @@ export enum PacketType { ACTION_FAIL = "ACTION_FAIL", DRIVE_TANK = "DRIVE_TANK", ESTOP = "ESTOP", + DRIVE_CUBIC_SPLINE = "DRIVE_CUBIC_SPLINE", + DRIVE_QUADRATIC_SPLINE = "DRIVE_QUADRATIC_SPLINE", } const Float = NumberType.withConstraint((n) => Number.isFinite(n), { @@ -37,6 +39,11 @@ const MotorPower = Float.withConstraint((n) => -1 <= n && n <= 1, { name: "motor_power", }); +const Position = Record({ + x: Float, + y: Float, +}); + // MUST be kept in sync with chessBotArduino/include/packet.h PacketType export const SERVER_PROTOCOL_VERSION = 1; @@ -152,6 +159,22 @@ export const DRIVE_TANK_SCHEMA = Record({ left: MotorPower, right: MotorPower, }); + +export const DRIVE_CUBIC_SPLINE_SCHEMA = Record({ + type: Literal(PacketType.DRIVE_CUBIC_SPLINE), + startPosition: Position, + endPosition: Position, + controlPosition: Position, + timeDeltaMs: Int32, +}); + +export const DRIVE_QUADRATIC_SPLINE_SCHEMA = Record({ + type: Literal(PacketType.DRIVE_QUADRATIC_SPLINE), + startPosition: Position, + endPosition: Position, + timeDeltaMs: Int32, +}); + export const ESTOP_SCHEMA = Record({ type: Literal(PacketType.ESTOP) }); export const Packet = Union( @@ -169,6 +192,8 @@ export const Packet = Union( ACTION_FAIL_SCHEMA, DRIVE_TANK_SCHEMA, ESTOP_SCHEMA, + DRIVE_CUBIC_SPLINE_SCHEMA, + DRIVE_QUADRATIC_SPLINE_SCHEMA, ); export type Packet = Static; From 1247a402e4af1910c811401bdd55d8a0b3248a03 Mon Sep 17 00:00:00 2001 From: Mason Thomas Date: Fri, 25 Apr 2025 02:59:00 -0500 Subject: [PATCH 63/92] create move commands --- src/server/command/move-command.ts | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/server/command/move-command.ts b/src/server/command/move-command.ts index c1b5f82c..bb3a1dea 100644 --- a/src/server/command/move-command.ts +++ b/src/server/command/move-command.ts @@ -83,6 +83,47 @@ export class RotateToStartCommand extends RobotCommand { } } +export class DriveCubicSplineCommand extends RobotCommand { + constructor( + robotId: string, + public startPosition: Position, + public endPosition: Position, + public controlPosition: Position, + public timeDeltaMs: number, + ) { + super(robotId); + } + + public async execute(): Promise { + const robot = robotManager.getRobot(this.robotId); + return robot.sendDriveCubicPacket( + this.startPosition, + this.endPosition, + this.controlPosition, + this.timeDeltaMs, + ); + } +} + +export class DriveQuadraticSplineCommand extends RobotCommand { + constructor( + robotId: string, + public startPosition: Position, + public endPosition: Position, + public timeDeltaMs: number, + ) { + super(robotId); + } + public async execute(): Promise { + const robot = robotManager.getRobot(this.robotId); + return robot.sendDriveQuadraticPacket( + this.startPosition, + this.endPosition, + this.timeDeltaMs, + ); + } +} + /** * Drives a robot for a distance equal to a number of tiles. Distance * may be negative, indicating the robot drives backwards. From ef913095da8c3068ca0e193d168ec4535d1e0d83 Mon Sep 17 00:00:00 2001 From: democat Date: Fri, 25 Apr 2025 03:34:31 -0500 Subject: [PATCH 64/92] Add Time Point and WaitCommand --- src/server/command/command.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/server/command/command.ts b/src/server/command/command.ts index 2859f1d9..8ab4a60b 100644 --- a/src/server/command/command.ts +++ b/src/server/command/command.ts @@ -20,6 +20,13 @@ export interface Command { * @param next - The command which is run next. */ then(next: Command): SequentialCommandGroup; + + /** + * Decorates the command with a "time point" - if the command finishes in less than the + * given duration, it will wait until this number of seconds has elapsed before continuing. + * @param seconds - The number of seconds that the command will execute for, at minimum. + */ + withTimePoint(seconds: number): Command; } /** @@ -43,6 +50,10 @@ export abstract class CommandBase implements Command { return new SequentialCommandGroup([this, next]); } + public withTimePoint(seconds: number): Command { + return new ParallelCommandGroup([this, new WaitCommand(seconds)]); + } + public get requirements(): Set { return this._requirements; } @@ -68,6 +79,18 @@ export abstract class RobotCommand extends CommandBase { } } +/** + * A command that waits for a given number of seconds. + */ +export class WaitCommand extends CommandBase { + constructor(public readonly durationSec: number) { + super(); + } + public async execute(): Promise { + return new Promise(resolve => setTimeout(resolve, this.durationSec * 1000)); + } +} + /** * A type of command which groups other commands and runs them together. */ From a4aece95b77ccfba9a62041e99e51018874bc976 Mon Sep 17 00:00:00 2001 From: Mason Thomas Date: Fri, 25 Apr 2025 03:42:18 -0500 Subject: [PATCH 65/92] add spin schema and move. --- src/server/api/api.ts | 4 ++-- src/server/command/command.ts | 4 +++- src/server/command/move-command.ts | 14 ++++++++++++++ src/server/robot/robot.ts | 12 ++++++++++++ src/server/utils/tcp-packet.ts | 8 ++++++++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/server/api/api.ts b/src/server/api/api.ts index aac19d93..2daf44a2 100644 --- a/src/server/api/api.ts +++ b/src/server/api/api.ts @@ -33,7 +33,7 @@ import { Position } from "../robot/position"; import { DEGREE } from "../../common/units"; import { PacketType } from "../utils/tcp-packet"; import { DriveTicksCommand } from "../command/move-command"; -import { ParallelCommandGroup } from "../command/command"; +import { Command, ParallelCommandGroup } from "../command/command"; export const executor = new CommandExecutor(); @@ -210,7 +210,7 @@ apiRouter.get("/do-parallel", async (_, res) => { console.log("Starting parallel command group"); const robotsEntries = Array.from(robotManager.idsToRobots.entries()); console.log(robotsEntries); - const commands: any[] = []; + const commands: Command[] = []; for (const [, robot] of robotsEntries) { console.log("Moving robot " + robot.id); // await robot.sendDrivePacket(1); diff --git a/src/server/command/command.ts b/src/server/command/command.ts index 8ab4a60b..488780bd 100644 --- a/src/server/command/command.ts +++ b/src/server/command/command.ts @@ -87,7 +87,9 @@ export class WaitCommand extends CommandBase { super(); } public async execute(): Promise { - return new Promise(resolve => setTimeout(resolve, this.durationSec * 1000)); + return new Promise((resolve) => + setTimeout(resolve, this.durationSec * 1000), + ); } } diff --git a/src/server/command/move-command.ts b/src/server/command/move-command.ts index bb3a1dea..d27c963b 100644 --- a/src/server/command/move-command.ts +++ b/src/server/command/move-command.ts @@ -105,6 +105,20 @@ export class DriveCubicSplineCommand extends RobotCommand { } } +export class SpinRadiansCommand extends RobotCommand { + constructor( + robotId: string, + public radians: number, + public timeDeltaMs: number, + ) { + super(robotId); + } + public async execute(): Promise { + const robot = robotManager.getRobot(this.robotId); + return robot.sendSpinPacket(this.radians, this.timeDeltaMs); + } +} + export class DriveQuadraticSplineCommand extends RobotCommand { constructor( robotId: string, diff --git a/src/server/robot/robot.ts b/src/server/robot/robot.ts index 09546100..25c637df 100644 --- a/src/server/robot/robot.ts +++ b/src/server/robot/robot.ts @@ -159,4 +159,16 @@ export class Robot { timeDeltaMs: timeDeltaMs, }); } + + public async sendSpinPacket( + radians: number, + timeDeltaMs: number, + ): Promise { + const tunnel = this.getTunnel(); + await tunnel.send({ + type: PacketType.SPIN_RADIANS, + radians: radians, + timeDeltaMs: timeDeltaMs, + }); + } } diff --git a/src/server/utils/tcp-packet.ts b/src/server/utils/tcp-packet.ts index 8ac564a8..11e6b5dd 100644 --- a/src/server/utils/tcp-packet.ts +++ b/src/server/utils/tcp-packet.ts @@ -25,6 +25,7 @@ export enum PacketType { ESTOP = "ESTOP", DRIVE_CUBIC_SPLINE = "DRIVE_CUBIC_SPLINE", DRIVE_QUADRATIC_SPLINE = "DRIVE_QUADRATIC_SPLINE", + SPIN_RADIANS = "SPIN_RADIANS", } const Float = NumberType.withConstraint((n) => Number.isFinite(n), { @@ -175,6 +176,12 @@ export const DRIVE_QUADRATIC_SPLINE_SCHEMA = Record({ timeDeltaMs: Int32, }); +export const SPIN_RADIANS_SCHEMA = Record({ + type: Literal(PacketType.SPIN_RADIANS), + radians: Float, + timeDeltaMs: Int32, +}); + export const ESTOP_SCHEMA = Record({ type: Literal(PacketType.ESTOP) }); export const Packet = Union( @@ -194,6 +201,7 @@ export const Packet = Union( ESTOP_SCHEMA, DRIVE_CUBIC_SPLINE_SCHEMA, DRIVE_QUADRATIC_SPLINE_SCHEMA, + SPIN_RADIANS_SCHEMA, ); export type Packet = Static; From a83479b59b0ac028f71220428e1170ebd6cfb5c7 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Fri, 25 Apr 2025 03:42:45 -0500 Subject: [PATCH 66/92] Format --- src/client/editor/showfile-state.ts | 165 ++++++++++++++-------------- 1 file changed, 84 insertions(+), 81 deletions(-) diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts index 0ed12b72..88cfbf3d 100644 --- a/src/client/editor/showfile-state.ts +++ b/src/client/editor/showfile-state.ts @@ -599,87 +599,90 @@ export function useShowfile() { [show, defaultEventDurationMs, setShow], ); - const showfileApi = useMemo(() => ({ - updateTimelineEventOrders, - show, - unsavedChanges, - loadAudioFromFile, - handleStartPointMove, - handlePointMove, - handleControlPointMove, - handleDeleteStartPoint, - handleDeletePoint, - handleSwitchPointType, - saveShowfile, - openShowfile, - editName, - undo, - redo, - addRobot, - currentTimestamp, - playing, - togglePlaying, - deleteLayer, - canRedo, - canUndo, - sequenceLengthMs, - timelineDurationUpdateMode, - setTimelineDurationUpdateMode, - updateTimelineEventDurations, - gridCursorMode, - setGridCursorMode, - defaultPointType, - setDefaultPointType, - setDefaultEventDurationMs, - defaultEventDurationMs, - addPointToSelectedLayer, - setSelectedLayerIndex, - selectedLayerIndex, - removeAudio, - deleteTimelineEvent, - setTimestamp, - addWaitEventAtIndex, - }), [ - updateTimelineEventOrders, - show, - unsavedChanges, - loadAudioFromFile, - handleStartPointMove, - handlePointMove, - handleControlPointMove, - handleDeleteStartPoint, - handleDeletePoint, - handleSwitchPointType, - saveShowfile, - openShowfile, - editName, - undo, - redo, - addRobot, - currentTimestamp, - playing, - togglePlaying, - deleteLayer, - canRedo, - canUndo, - sequenceLengthMs, - timelineDurationUpdateMode, - setTimelineDurationUpdateMode, - updateTimelineEventDurations, - gridCursorMode, - setGridCursorMode, - defaultPointType, - setDefaultPointType, - setDefaultEventDurationMs, - defaultEventDurationMs, - addPointToSelectedLayer, - setSelectedLayerIndex, - selectedLayerIndex, - removeAudio, - deleteTimelineEvent, - setTimestamp, - addWaitEventAtIndex, - ]); + const showfileApi = useMemo( + () => ({ + updateTimelineEventOrders, + show, + unsavedChanges, + loadAudioFromFile, + handleStartPointMove, + handlePointMove, + handleControlPointMove, + handleDeleteStartPoint, + handleDeletePoint, + handleSwitchPointType, + saveShowfile, + openShowfile, + editName, + undo, + redo, + addRobot, + currentTimestamp, + playing, + togglePlaying, + deleteLayer, + canRedo, + canUndo, + sequenceLengthMs, + timelineDurationUpdateMode, + setTimelineDurationUpdateMode, + updateTimelineEventDurations, + gridCursorMode, + setGridCursorMode, + defaultPointType, + setDefaultPointType, + setDefaultEventDurationMs, + defaultEventDurationMs, + addPointToSelectedLayer, + setSelectedLayerIndex, + selectedLayerIndex, + removeAudio, + deleteTimelineEvent, + setTimestamp, + addWaitEventAtIndex, + }), + [ + updateTimelineEventOrders, + show, + unsavedChanges, + loadAudioFromFile, + handleStartPointMove, + handlePointMove, + handleControlPointMove, + handleDeleteStartPoint, + handleDeletePoint, + handleSwitchPointType, + saveShowfile, + openShowfile, + editName, + undo, + redo, + addRobot, + currentTimestamp, + playing, + togglePlaying, + deleteLayer, + canRedo, + canUndo, + sequenceLengthMs, + timelineDurationUpdateMode, + setTimelineDurationUpdateMode, + updateTimelineEventDurations, + gridCursorMode, + setGridCursorMode, + defaultPointType, + setDefaultPointType, + setDefaultEventDurationMs, + defaultEventDurationMs, + addPointToSelectedLayer, + setSelectedLayerIndex, + selectedLayerIndex, + removeAudio, + deleteTimelineEvent, + setTimestamp, + addWaitEventAtIndex, + ], + ); return showfileApi; } From 146c468794a03516f3fa86114ef884d36dc48c6e Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Fri, 25 Apr 2025 03:42:56 -0500 Subject: [PATCH 67/92] Add turn point schema --- src/common/show.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/common/show.ts b/src/common/show.ts index f8e9fdec..d7bff1af 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -21,6 +21,7 @@ export const TimelineEventTypes = { GoToPointEvent: "goto_point", WaitEvent: "wait", StartPointEvent: "start_point", + TurnEvent: "turn", } as const; export const GoToPointEventSchema = RuntypesRecord({ @@ -46,6 +47,14 @@ const StartPointEventSchema = RuntypesRecord({ }); export type StartPointEvent = Static; +const TurnEventSchema = RuntypesRecord({ + type: Literal(TimelineEventTypes.TurnEvent), + radians: Number, + durationMs: Number, + id: String, +}); +export type TurnEvent = Static; + const NonStartPointEventSchema = Union(GoToPointEventSchema, WaitEventSchema); export type NonStartPointEvent = Static; @@ -54,7 +63,11 @@ const TimelineLayerSchema = RuntypesRecord({ remainingEvents: Array(NonStartPointEventSchema), }); export type TimelineLayerType = Static; -export type TimelineEvents = GoToPointEvent | WaitEvent | StartPointEvent; +export type TimelineEvents = + | GoToPointEvent + | WaitEvent + | StartPointEvent + | TurnEvent; /** * The showfile schema. @@ -169,6 +182,7 @@ export const EVENT_TYPE_TO_COLOR: Record< [TimelineEventTypes.GoToPointEvent]: Colors.BLUE2, [TimelineEventTypes.WaitEvent]: Colors.GRAY2, [TimelineEventTypes.StartPointEvent]: Colors.GREEN2, + [TimelineEventTypes.TurnEvent]: Colors.ORANGE2, }; /* From 1232f203c613b337de29b8ef5f63bc46a478b821 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Fri, 25 Apr 2025 04:02:10 -0500 Subject: [PATCH 68/92] Add jump to point utility --- src/client/editor/editor.tsx | 2 +- src/client/editor/points.tsx | 6 ++++++ src/client/editor/spline-editor.tsx | 23 +++++++++++++++++++++++ src/client/editor/timeline.tsx | 1 + 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 6730f88b..fd919d6d 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -411,7 +411,7 @@ export function Editor() {
void; onSwitchToQuadratic?: () => void; onSwitchToCubic?: () => void; + onJumpToPoint: () => void; }) { // TODO: fix context menu positioning const mainPointRef = useRef(null); @@ -46,6 +48,10 @@ export function SplinePoint({ onClick={onSwitchToCubic} /> )} + 0 ? onDeleteStartPoint : undefined } onMove={(x, y) => onStartPointMove({ x, y })} + onJumpToPoint={() => { + // TODO: i don't like that we're touching elements by ids and mucking w dom outside of react - see if theres a way to refactor this in a more idiomatic react way + const el = document.getElementById(`timeline-event-${layer.startPoint.id}`) + navigateAndIdentifyTimelineElement(el as HTMLElement); + }} /> {spline.points.map((point, index) => ( @@ -100,6 +105,12 @@ export function SplineEditor({ SplinePointType.CubicBezier, ) } + onJumpToPoint={() => { + const originalIndex = getOriginalEventIndex(index); + const el = document.getElementById(`timeline-event-${layer.remainingEvents[originalIndex].id}`) + navigateAndIdentifyTimelineElement(el as HTMLElement); + + }} /> {/* TODO: add line between control point and end point */} {point.type === SplinePointType.CubicBezier && ( @@ -118,3 +129,15 @@ export function SplineEditor({ ); } + +function navigateAndIdentifyTimelineElement(element: HTMLElement) { + element?.scrollIntoView({ + inline: "start", + block: "end", + }); + const originalBackgroundColor = element.style.backgroundColor; + element.style.backgroundColor = "black"; + setTimeout(() => { + element.style.backgroundColor = originalBackgroundColor; + }, 500); +} \ No newline at end of file diff --git a/src/client/editor/timeline.tsx b/src/client/editor/timeline.tsx index 5cd19814..aa29a7f2 100644 --- a/src/client/editor/timeline.tsx +++ b/src/client/editor/timeline.tsx @@ -121,6 +121,7 @@ export function TimelineEvent(props: { > Date: Fri, 25 Apr 2025 04:43:13 -0500 Subject: [PATCH 69/92] Jump to beginning on playback if last playback finished at end of timeline --- src/client/editor/hooks.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/client/editor/hooks.ts b/src/client/editor/hooks.ts index ab5e1e65..80b39110 100644 --- a/src/client/editor/hooks.ts +++ b/src/client/editor/hooks.ts @@ -179,6 +179,7 @@ export function useStateWithTrackedHistory(initialValue: T) { export function usePlayHead(endDurationMs: number, startMs = 0) { const [playing, setPlaying] = useState(false); + const [stoppedAtEnd, setStoppedAtEnd] = useState(false); const currentTimestamp = useMotionValue(startMs); const time = useTime(); const lastAccessedTime = useMotionValue(0); @@ -191,12 +192,16 @@ export function usePlayHead(endDurationMs: number, startMs = 0) { setPlaying((p) => { const newPlaying = !p; if (newPlaying) { + if (stoppedAtEnd) { + currentTimestamp.set(0); + } + setStoppedAtEnd(false); // When starting playback, initialize the lastAccessedTime lastAccessedTime.set(time.get()); } return newPlaying; }); - }, [setPlaying, time, lastAccessedTime]); + }, [stoppedAtEnd, lastAccessedTime, time, currentTimestamp]); useMotionValueEvent(time, "change", (currentTime) => { if (!playing) { @@ -210,7 +215,7 @@ export function usePlayHead(endDurationMs: number, startMs = 0) { if (newTimestamp >= endDurationMs) { setPlaying(false); - currentTimestamp.set(0); + setStoppedAtEnd(true); } else { currentTimestamp.set(newTimestamp); } From 004637a727a2f4da104bfadcb97f03b127734944 Mon Sep 17 00:00:00 2001 From: Mason Thomas Date: Fri, 25 Apr 2025 05:13:33 -0500 Subject: [PATCH 70/92] update quadratic and cubic to have multiple control points --- src/server/command/move-command.ts | 8 ++++++-- src/server/robot/robot.ts | 8 ++++++-- src/server/utils/tcp-packet.ts | 4 +++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/server/command/move-command.ts b/src/server/command/move-command.ts index d27c963b..f7101869 100644 --- a/src/server/command/move-command.ts +++ b/src/server/command/move-command.ts @@ -88,7 +88,8 @@ export class DriveCubicSplineCommand extends RobotCommand { robotId: string, public startPosition: Position, public endPosition: Position, - public controlPosition: Position, + public controlPositionA: Position, + public controlPositionB: Position, public timeDeltaMs: number, ) { super(robotId); @@ -99,7 +100,8 @@ export class DriveCubicSplineCommand extends RobotCommand { return robot.sendDriveCubicPacket( this.startPosition, this.endPosition, - this.controlPosition, + this.controlPositionA, + this.controlPositionB, this.timeDeltaMs, ); } @@ -124,6 +126,7 @@ export class DriveQuadraticSplineCommand extends RobotCommand { robotId: string, public startPosition: Position, public endPosition: Position, + public controlPosition: Position, public timeDeltaMs: number, ) { super(robotId); @@ -133,6 +136,7 @@ export class DriveQuadraticSplineCommand extends RobotCommand { return robot.sendDriveQuadraticPacket( this.startPosition, this.endPosition, + this.controlPosition, this.timeDeltaMs, ); } diff --git a/src/server/robot/robot.ts b/src/server/robot/robot.ts index 25c637df..20dd34f8 100644 --- a/src/server/robot/robot.ts +++ b/src/server/robot/robot.ts @@ -133,7 +133,8 @@ export class Robot { public async sendDriveCubicPacket( startPosition: Position, endPosition: Position, - controlPosition: Position, + controlPositionA: Position, + controlPositionB: Position, timeDeltaMs: number, ): Promise { const tunnel = this.getTunnel(); @@ -141,7 +142,8 @@ export class Robot { type: PacketType.DRIVE_CUBIC_SPLINE, startPosition: startPosition, endPosition: endPosition, - controlPosition: controlPosition, + controlPositionA: controlPositionA, + controlPositionB: controlPositionB, timeDeltaMs: timeDeltaMs, }); } @@ -149,12 +151,14 @@ export class Robot { public async sendDriveQuadraticPacket( startPosition: Position, endPosition: Position, + controlPosition: Position, timeDeltaMs: number, ): Promise { const tunnel = this.getTunnel(); await tunnel.send({ type: PacketType.DRIVE_QUADRATIC_SPLINE, startPosition: startPosition, + controlPosition: controlPosition, endPosition: endPosition, timeDeltaMs: timeDeltaMs, }); diff --git a/src/server/utils/tcp-packet.ts b/src/server/utils/tcp-packet.ts index 11e6b5dd..a7e3f37a 100644 --- a/src/server/utils/tcp-packet.ts +++ b/src/server/utils/tcp-packet.ts @@ -165,7 +165,8 @@ export const DRIVE_CUBIC_SPLINE_SCHEMA = Record({ type: Literal(PacketType.DRIVE_CUBIC_SPLINE), startPosition: Position, endPosition: Position, - controlPosition: Position, + controlPositionA: Position, + controlPositionB: Position, timeDeltaMs: Int32, }); @@ -173,6 +174,7 @@ export const DRIVE_QUADRATIC_SPLINE_SCHEMA = Record({ type: Literal(PacketType.DRIVE_QUADRATIC_SPLINE), startPosition: Position, endPosition: Position, + controlPosition: Position, timeDeltaMs: Int32, }); From feec1fa97edf2181af3c016547c12aac79cc91a7 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Fri, 25 Apr 2025 05:20:28 -0500 Subject: [PATCH 71/92] Support jumping to timestamp on timeline --- src/client/editor/editor.tsx | 38 +++++++++++++++++++++-------- src/client/editor/points.tsx | 4 +-- src/client/editor/spline-editor.tsx | 15 ++++++++---- src/client/editor/timeline.tsx | 1 - 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index fd919d6d..cb8d17c9 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { Section, EditableText, @@ -225,6 +225,18 @@ export function Editor() { }, }); }, [setTimestamp]); + + const handleSeekBarClick = useCallback( + (e: React.MouseEvent) => { + const { x: mouseX } = e.nativeEvent; + const seekBar = seekBarRef.current; + if (!seekBar) return; + const { x: seekBarStartX } = seekBar.getBoundingClientRect(); + const newTimestamp = pixelsToMillis(mouseX - seekBarStartX); + setTimestamp(newTimestamp); + }, + [setTimestamp], + ); return (
- +
+ +
{show.timeline.map( ({ startPoint, remainingEvents }, layerIndex) => { diff --git a/src/client/editor/points.tsx b/src/client/editor/points.tsx index 27c916e5..19c8245b 100644 --- a/src/client/editor/points.tsx +++ b/src/client/editor/points.tsx @@ -49,8 +49,8 @@ export function SplinePoint({ /> )} onStartPointMove({ x, y })} onJumpToPoint={() => { // TODO: i don't like that we're touching elements by ids and mucking w dom outside of react - see if theres a way to refactor this in a more idiomatic react way - const el = document.getElementById(`timeline-event-${layer.startPoint.id}`) + const el = document.getElementById( + `timeline-event-${layer.startPoint.id}`, + ); navigateAndIdentifyTimelineElement(el as HTMLElement); }} /> @@ -107,9 +109,12 @@ export function SplineEditor({ } onJumpToPoint={() => { const originalIndex = getOriginalEventIndex(index); - const el = document.getElementById(`timeline-event-${layer.remainingEvents[originalIndex].id}`) - navigateAndIdentifyTimelineElement(el as HTMLElement); - + const el = document.getElementById( + `timeline-event-${layer.remainingEvents[originalIndex].id}`, + ); + navigateAndIdentifyTimelineElement( + el as HTMLElement, + ); }} /> {/* TODO: add line between control point and end point */} @@ -140,4 +145,4 @@ function navigateAndIdentifyTimelineElement(element: HTMLElement) { setTimeout(() => { element.style.backgroundColor = originalBackgroundColor; }, 500); -} \ No newline at end of file +} diff --git a/src/client/editor/timeline.tsx b/src/client/editor/timeline.tsx index aa29a7f2..5775552d 100644 --- a/src/client/editor/timeline.tsx +++ b/src/client/editor/timeline.tsx @@ -201,7 +201,6 @@ export const TimelineLayer = forwardRef< /> )} - {} {onDelete && (
+
{ await fetch(`/__open-in-editor?${params.toString()}`); }; -// TODO: refactor out of debug since we use it in more than just simulator? -export function RobotGrid({ - robotState, - children, -}: PropsWithChildren<{ robotState: RobotState }>) { - return ( -
- {new Array(cellCount * cellCount).fill(undefined).map((_, i) => { +export function RobotGrid({robotState}: {robotState: RobotState}) { + return
+ {new Array(cellCount * cellCount) + .fill(undefined) + .map((_, i) => { const row = Math.floor(i / cellCount); const col = i % cellCount; - const isCenterCell = - row >= 2 && row < 10 && col >= 2 && col < 10; + const isCenterCell = row >= 2 && row < 10 && col >= 2 && col < 10; return (
+ border: "1px solid black", + backgroundColor: !isCenterCell ? "lightgray" : ( + "transparent" + ), + }} /> ); })} - {/* TODO: implement onTopOfRobots */} - {Object.entries(robotState).map(([robotId, pos]) => ( - - ))} - {children} -
- ); + {/* TODO: implement onTopOfRobots */} + {Object.entries(robotState).map(([robotId, pos]) => ( + + ))} +
; } /** diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 623c35f7..7d0b88cc 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -46,6 +46,7 @@ import { TimelineLayerType } from "../../common/show"; import { SplineEditor } from "./spline-editor"; import interact from "interactjs"; import { Array as RArray } from "runtypes"; +import { post } from "../api"; // Helper component to manage animation for a single robot function AnimatedRobotRenderer({ @@ -637,6 +638,9 @@ export function Editor() { variant="outlined" /> +
-
{ await fetch(`/__open-in-editor?${params.toString()}`); }; -export function RobotGrid({robotState}: {robotState: RobotState}) { - return
- {new Array(cellCount * cellCount) - .fill(undefined) - .map((_, i) => { +// TODO: refactor out of debug since we use it in more than just simulator? +export function RobotGrid({ + robotState, + children, +}: PropsWithChildren<{ robotState: RobotState }>) { + return ( +
+ {new Array(cellCount * cellCount).fill(undefined).map((_, i) => { const row = Math.floor(i / cellCount); const col = i % cellCount; - const isCenterCell = row >= 2 && row < 10 && col >= 2 && col < 10; + const isCenterCell = + row >= 2 && row < 10 && col >= 2 && col < 10; return (
+ borderWidth: "1px", + borderStyle: "solid", + borderColor: simBorderColor(), + backgroundColor: + !isCenterCell ? simRingCellColor() : ( + "transparent" + ), + }} + /> ); })} - {/* TODO: implement onTopOfRobots */} - {Object.entries(robotState).map(([robotId, pos]) => ( - - ))} -
; + {/* TODO: implement onTopOfRobots */} + {Object.entries(robotState).map(([robotId, pos]) => ( + + ))} + {children} +
+ ); } /** diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 7d0b88cc..785d9155 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -638,8 +638,10 @@ export function Editor() { variant="outlined" /> -
-
-                        {JSON.stringify(
-                            {
-                                ...show,
-                                audio:
-                                    show.audio ?
-                                        {
-                                            data: "[binary data]",
-                                            mimeType: show.audio.mimeType,
-                                        }
-                                    :   undefined,
-                            },
-                            null,
-                            2,
-                        )}
-                    
+ +
+                            {JSON.stringify(
+                                {
+                                    ...show,
+                                    audio:
+                                        show.audio ?
+                                            {
+                                                data: "[binary data]",
+                                                mimeType: show.audio.mimeType,
+                                            }
+                                        :   undefined,
+                                },
+                                null,
+                                2,
+                            )}
+                        
+
+
+
+ + {selectedTimelineEventIndex !== null ? +

+ Coming soon: event editor. +

+ : + } +
{ + setSelectedTimelineEventIndex({ + layerIndex, + eventId: startPoint.id, + }); + }} /> { + setSelectedTimelineEventIndex( + { + layerIndex, + eventId: + event.id, + }, + ); + }} /> ); }, diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts index 0646ab6a..45a87e1e 100644 --- a/src/client/editor/showfile-state.ts +++ b/src/client/editor/showfile-state.ts @@ -57,6 +57,8 @@ export function useShowfile() { const [defaultEventDurationMs, setDefaultEventDurationMs] = useState(3750); const [selectedLayerIndex, setSelectedLayerIndex] = useState(0); + const [selectedTimelineEventIndex, setSelectedTimelineEventIndex] = + useState<{ layerIndex: number; eventId: string } | null>(null); // used to store the handle to the file system file that the showfile is currently loaded from, // so we can save to the same file we've already opened. not all the browsers' file system API @@ -724,6 +726,8 @@ export function useShowfile() { addTurnEventAtIndex, getLayerIndexFromEventId, addBulkEventsToSelectedLayer, + selectedTimelineEventIndex, + setSelectedTimelineEventIndex }), [ updateTimelineEventOrders, @@ -770,6 +774,8 @@ export function useShowfile() { addTurnEventAtIndex, getLayerIndexFromEventId, addBulkEventsToSelectedLayer, + selectedTimelineEventIndex, + setSelectedTimelineEventIndex ], ); diff --git a/src/client/editor/timeline.tsx b/src/client/editor/timeline.tsx index 92e56f58..b4c7cee2 100644 --- a/src/client/editor/timeline.tsx +++ b/src/client/editor/timeline.tsx @@ -26,12 +26,16 @@ export function ReorderableTimelineEvent({ onDelete, onAddWaitEvent, onAddTurnEvent, + onSelect, + selected }: PropsWithChildren<{ event: TimelineEvents; onDurationChange: (ms: number) => void; onDelete?: () => void; onAddWaitEvent?: (position: "before" | "after") => void; onAddTurnEvent?: (position: "before" | "after") => void; + onSelect: () => void; + selected: boolean }>) { const controls = useDragControls(); @@ -48,6 +52,8 @@ export function ReorderableTimelineEvent({ onDelete={onDelete} onAddWaitEvent={onAddWaitEvent} onAddTurnEvent={onAddTurnEvent} + onSelect={onSelect} + selected={selected} /> ); @@ -62,6 +68,8 @@ export function TimelineEvent(props: { onDelete?: () => void; onAddWaitEvent?: (position: "before" | "after") => void; onAddTurnEvent?: (position: "before" | "after") => void; + selected: boolean + onSelect: () => void }) { const ref = useRef(null); const { @@ -71,6 +79,8 @@ export function TimelineEvent(props: { onDelete, onAddWaitEvent, onAddTurnEvent, + onSelect, + selected } = props; useEffect(() => { if (!onDurationChange) return; @@ -142,8 +152,10 @@ export function TimelineEvent(props: { justifyContent: "space-between", touchAction: "none", overflow: "hidden", + ...(selected ? { border: "1px solid red" } : {}) }} compact + onClick={onSelect} > {event.type} From 7b57010a54903fafc92403ad1c9783c314a1e816 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Mon, 28 Apr 2025 19:30:43 -0500 Subject: [PATCH 89/92] Format --- src/client/editor/editor.tsx | 4 +--- src/client/editor/showfile-state.ts | 4 ++-- src/client/editor/timeline.tsx | 12 ++++++------ 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 867aa426..766d9481 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -570,9 +570,7 @@ export function Editor() {
{selectedTimelineEventIndex !== null ? -

- Coming soon: event editor. -

+

Coming soon: event editor.

: void; @@ -35,7 +35,7 @@ export function ReorderableTimelineEvent({ onAddWaitEvent?: (position: "before" | "after") => void; onAddTurnEvent?: (position: "before" | "after") => void; onSelect: () => void; - selected: boolean + selected: boolean; }>) { const controls = useDragControls(); @@ -68,8 +68,8 @@ export function TimelineEvent(props: { onDelete?: () => void; onAddWaitEvent?: (position: "before" | "after") => void; onAddTurnEvent?: (position: "before" | "after") => void; - selected: boolean - onSelect: () => void + selected: boolean; + onSelect: () => void; }) { const ref = useRef(null); const { @@ -80,7 +80,7 @@ export function TimelineEvent(props: { onAddWaitEvent, onAddTurnEvent, onSelect, - selected + selected, } = props; useEffect(() => { if (!onDurationChange) return; @@ -152,7 +152,7 @@ export function TimelineEvent(props: { justifyContent: "space-between", touchAction: "none", overflow: "hidden", - ...(selected ? { border: "1px solid red" } : {}) + ...(selected ? { border: "1px solid red" } : {}), }} compact onClick={onSelect} From bae8bf71471bf5726eba930b1fc9ee7bfb89b1a2 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Mon, 28 Apr 2025 23:08:42 -0500 Subject: [PATCH 90/92] Force durations to ints --- src/client/editor/points.tsx | 2 +- src/client/editor/spline-editor.tsx | 5 ++--- src/client/editor/timeline.tsx | 7 ++++--- src/common/show.ts | 9 +++++---- src/server/utils/tcp-packet.ts | 8 ++++---- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/client/editor/points.tsx b/src/client/editor/points.tsx index 19c8245b..1f97fda6 100644 --- a/src/client/editor/points.tsx +++ b/src/client/editor/points.tsx @@ -53,7 +53,7 @@ export function SplinePoint({ onClick={onJumpToPoint} /> {/* TODO: add line between control point and end point */} diff --git a/src/client/editor/timeline.tsx b/src/client/editor/timeline.tsx index caf5a555..e5571698 100644 --- a/src/client/editor/timeline.tsx +++ b/src/client/editor/timeline.tsx @@ -95,7 +95,9 @@ export function TimelineEvent(props: { move: function (event) { event.preventDefault(); event.stopPropagation(); - onDurationChange(pixelsToMillis(event.deltaRect.width)); + onDurationChange( + Math.floor(pixelsToMillis(event.deltaRect.width)), + ); }, }, }); @@ -105,7 +107,6 @@ export function TimelineEvent(props: { - {/* TODO: add spin before/after current event */} {onDelete && ( void; active?: boolean; }> ->(function TimelineCard({ title, children, onDelete, onActive, active }, ref) { +>(function TimelineLayer({ title, children, onDelete, onActive, active }, ref) { // TODO: add borders between columns. v low priority // https://codepen.io/Kevin-Geary/pen/BavwqYX // https://www.youtube.com/watch?v=EQYft7JPKto diff --git a/src/common/show.ts b/src/common/show.ts index d5011a61..66cc3cff 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -18,6 +18,7 @@ import { SplinePointType, StartPointSchema, } from "./spline"; +import { Uint32 } from "../server/utils/tcp-packet"; export const TimelineEventTypes = { @@ -28,7 +29,7 @@ export const TimelineEventTypes = { } as const; export const GoToPointEventSchema = RuntypesRecord({ - durationMs: Number, + durationMs: Uint32, target: MidpointSchema, type: Literal(TimelineEventTypes.GoToPointEvent), id: String, @@ -36,7 +37,7 @@ export const GoToPointEventSchema = RuntypesRecord({ export type GoToPointEvent = Static; const WaitEventSchema = RuntypesRecord({ - durationMs: Number, + durationMs: Uint32, type: Literal(TimelineEventTypes.WaitEvent), id: String, }); @@ -45,7 +46,7 @@ export type WaitEvent = Static; const StartPointEventSchema = RuntypesRecord({ type: Literal(TimelineEventTypes.StartPointEvent), target: StartPointSchema, - durationMs: Number, + durationMs: Uint32, id: String, }); export type StartPointEvent = Static; @@ -53,7 +54,7 @@ export type StartPointEvent = Static; const TurnEventSchema = RuntypesRecord({ type: Literal(TimelineEventTypes.TurnEvent), radians: Number, - durationMs: Number, + durationMs: Uint32, id: String, }); export type TurnEvent = Static; diff --git a/src/server/utils/tcp-packet.ts b/src/server/utils/tcp-packet.ts index a7e3f37a..017a0186 100644 --- a/src/server/utils/tcp-packet.ts +++ b/src/server/utils/tcp-packet.ts @@ -34,7 +34,7 @@ const Float = NumberType.withConstraint((n) => Number.isFinite(n), { const Int32 = Float.withConstraint((n) => Number.isSafeInteger(n), { name: "int32", }); -const Uint32 = Int32.withConstraint((n) => n >= 0, { name: "uint32" }); +export const Uint32 = Int32.withConstraint((n) => n >= 0, { name: "uint32" }); const VarId = Uint32; const MotorPower = Float.withConstraint((n) => -1 <= n && n <= 1, { name: "motor_power", @@ -167,7 +167,7 @@ export const DRIVE_CUBIC_SPLINE_SCHEMA = Record({ endPosition: Position, controlPositionA: Position, controlPositionB: Position, - timeDeltaMs: Int32, + timeDeltaMs: Uint32, }); export const DRIVE_QUADRATIC_SPLINE_SCHEMA = Record({ @@ -175,13 +175,13 @@ export const DRIVE_QUADRATIC_SPLINE_SCHEMA = Record({ startPosition: Position, endPosition: Position, controlPosition: Position, - timeDeltaMs: Int32, + timeDeltaMs: Uint32, }); export const SPIN_RADIANS_SCHEMA = Record({ type: Literal(PacketType.SPIN_RADIANS), radians: Float, - timeDeltaMs: Int32, + timeDeltaMs: Uint32, }); export const ESTOP_SCHEMA = Record({ type: Literal(PacketType.ESTOP) }); From d2aeec184088d077586ec4b55e06f6eab85d8052 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Mon, 28 Apr 2025 23:16:19 -0500 Subject: [PATCH 91/92] Begin inspector panel --- src/client/editor/editor.tsx | 166 ++++++++++++++++------------------- 1 file changed, 75 insertions(+), 91 deletions(-) diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx index 766d9481..df5e0dc0 100644 --- a/src/client/editor/editor.tsx +++ b/src/client/editor/editor.tsx @@ -472,71 +472,51 @@ export function Editor() { > )} {show.timeline.map((layer, index) => ( - <> - {/* TODO: render bots */} - -
- - handleStartPointMove(index, coords) - } - onPointMove={(pointIdx, coords) => - handlePointMove( - index, - pointIdx, - coords, - ) - } - onControlPointMove={( +
+ + handleStartPointMove(index, coords) + } + onPointMove={(pointIdx, coords) => + handlePointMove(index, pointIdx, coords) + } + onControlPointMove={(pointIdx, coords) => + handleControlPointMove( + index, pointIdx, coords, - ) => - handleControlPointMove( - index, - pointIdx, - coords, - ) - } - onControlPoint2Move={( + ) + } + onControlPoint2Move={(pointIdx, coords) => + handleControlPoint2Move( + index, pointIdx, coords, - ) => - handleControlPoint2Move( - index, - pointIdx, - coords, - ) - } - onDeleteStartPoint={() => - handleDeleteStartPoint(index) - } - onDeletePoint={(pointIdx) => - handleDeletePoint(index, pointIdx) - } - onSwitchPointType={( + ) + } + onDeleteStartPoint={() => + handleDeleteStartPoint(index) + } + onDeletePoint={(pointIdx) => + handleDeletePoint(index, pointIdx) + } + onSwitchPointType={(pointIdx, newType) => + handleSwitchPointType( + index, pointIdx, newType, - ) => - handleSwitchPointType( - index, - pointIdx, - newType, - ) - } - /> -
- + ) + } + /> +
))} - {/* Render Animated Robots separately */} {show.timeline.map((layer, index) => (
-
- -
-                            {JSON.stringify(
-                                {
-                                    ...show,
-                                    audio:
-                                        show.audio ?
-                                            {
-                                                data: "[binary data]",
-                                                mimeType: show.audio.mimeType,
-                                            }
-                                        :   undefined,
-                                },
-                                null,
-                                2,
-                            )}
-                        
-
-
-
- - {selectedTimelineEventIndex !== null ? -

Coming soon: event editor.

- : - } -
-
+
+
+ +
+                                {JSON.stringify(
+                                    {
+                                        ...show,
+                                        audio:
+                                            show.audio ?
+                                                {
+                                                    data: "[binary data]",
+                                                    mimeType:
+                                                        show.audio.mimeType,
+                                                }
+                                            :   undefined,
+                                    },
+                                    null,
+                                    2,
+                                )}
+                            
+
+
+
+ + {selectedTimelineEventIndex !== null ? +

Coming soon: event editor.

+ : + } +
+
+
- {/* TODO: add ability to add events */} ); }, From f6acdc711873caa6aa601a4dc621c96ca6dccc42 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Mon, 5 May 2025 04:46:30 -0500 Subject: [PATCH 92/92] Fix typo + make unique IDs for default events --- src/common/show.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/common/show.ts b/src/common/show.ts index 66cc3cff..4e59c5af 100644 --- a/src/common/show.ts +++ b/src/common/show.ts @@ -20,7 +20,6 @@ import { } from "./spline"; import { Uint32 } from "../server/utils/tcp-packet"; - export const TimelineEventTypes = { GoToPointEvent: "goto_point", WaitEvent: "wait", @@ -77,7 +76,6 @@ export type TimelineEvents = | StartPointEvent | TurnEvent; - /** * The showfile schema. * @@ -138,29 +136,29 @@ export function createNewShowfile(): Showfile { }, }, durationMs: 3000, - id: "4f21401d-07cf-434f-a73c-6482ab82f210", + id: crypto.randomUUID(), }, remainingEvents: [ { - id: "a8e97232-8f22-4cc2-bcc4-ab523eeb801d", + id: crypto.randomUUID(), type: TimelineEventTypes.TurnEvent, durationMs: 1750, radians: 2 * Math.PI, }, + { type: TimelineEventTypes.GoToPointEvent, durationMs: 1000, target: { type: SplinePointType.QuadraticBezier, endPoint: { x: 100, y: 100 }, controlPoint: { x: 60, y: 120 }, - }, - id: "4f21401d-07cf-434f-a73c-6482ab82f211", + id: crypto.randomUUID(), }, { type: TimelineEventTypes.WaitEvent, durationMs: 5000, - id: "4f21401d-07cf-434f-a73c-6482ab82f212", + id: crypto.randomUUID(), }, { type: TimelineEventTypes.GoToPointEvent, @@ -177,7 +175,7 @@ export function createNewShowfile(): Showfile { y: 60, }, }, - id: "4f21401d-07cf-434f-a73c-6482ab82f213", + id: crypto.randomUUID(), }, { type: TimelineEventTypes.GoToPointEvent, @@ -187,7 +185,7 @@ export function createNewShowfile(): Showfile { endPoint: { x: 70, y: 70 }, controlPoint: { x: 85, y: 90 }, }, - id: "4f21401d-07cf-434f-a73c-6482ab82f214", + id: crypto.randomUUID(), }, ], }, @@ -215,4 +213,4 @@ export const loadShowfileFromBinary = (binary: Buffer | Uint8Array) => { } return null; -}; \ No newline at end of file +};