Skip to content

Commit 196159c

Browse files
committed
Work on Splan saving and loading logic
1 parent 7d860a6 commit 196159c

1 file changed

Lines changed: 173 additions & 31 deletions

File tree

src/pages/index.tsx

Lines changed: 173 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
/// <reference types="@types/web-bluetooth" />
22
import { Separator } from "@/components/ui/separator";
33
import { Card } from "@/components/ui/card";
4-
import { Button } from "@/components/ui/button"
5-
import { useState } from "react"
4+
import { Button } from "@/components/ui/button";
5+
import { useState, useEffect } from "react"
66
import { useTheme } from "next-themes";
7-
import PointPlotter from "@/components/spline_plotter";
87
import { Toaster, toast } from "sonner";
98
import { connectToSpike, sendCodeToSpike, readResponseFromSpike } from "@/components/pybricks/tools";
109
import {
@@ -20,6 +19,7 @@ from pybricks.parameters import Port
2019
motor = Motor(Port.A)
2120
motor.run_time(100, 2000)
2221
`
22+
const motorOptions = ["A", "B", "C", "D"]
2323

2424
class DriveBase {
2525
left_motor: "A" | "B" | "C" | "D";
@@ -40,10 +40,12 @@ class DriveBase {
4040
}
4141

4242
class Action {
43+
point: Point;
4344
function: string;
4445
args: any[];
45-
46-
constructor(function_name: string, args: any[] = []) {
46+
47+
constructor(point: Point, function_name: string, args: any[] = []) {
48+
this.point = point;
4749
this.function = function_name;
4850
this.args = args;
4951
}
@@ -52,26 +54,34 @@ class Action {
5254
class Point {
5355
x: number;
5456
y: number;
55-
actions: Action[];
56-
actions_are_blocking: boolean
5757

58-
constructor(x: number, y: number, actions: Action[] = [], actions_are_blocking: boolean = false) {
58+
constructor(x: number, y: number) {
5959
this.x = x;
6060
this.y = y;
61+
}
62+
}
63+
64+
class Run {
65+
name: string;
66+
points: Point[];
67+
actions: Action[];
68+
69+
constructor(name: string, points: Point[], actions: Action[] = []) {
70+
this.name = name;
71+
this.points = points;
6172
this.actions = actions;
62-
this.actions_are_blocking = actions_are_blocking;
6373
}
6474
}
6575

66-
class PySplanContent {
76+
class SplanContent {
6777
name: string;
6878
drive_base: DriveBase;
69-
runs: Point[][];
79+
runs: Run[];
7080

7181
constructor(data: any) {
7282
this.name = data.name;
7383
this.drive_base = new DriveBase(data.drive_base.left_motor, data.drive_base.right_motor, data.drive_base.wheel_diameter, data.drive_base.axle_track);
74-
this.runs = data.runs;
84+
this.runs = data.runs.map((run: any) => new Run(run.name, run.points, run.actions));
7585
}
7686

7787
save_file() {
@@ -93,22 +103,138 @@ class PySplanContent {
93103
}
94104
}
95105

106+
// Custom PySplanner B-Spline algorithm
107+
const getCurvePoints = (pts: number[], tension = 0.5, isClosed = false, numOfSegments = 16) => {
108+
let _pts = pts.slice(0); // Copy the array of points
109+
let res = [], x, y, t1x, t2x, t1y, t2y, c1, c2, c3, c4, st, t;
110+
111+
// Handle closed vs open curves by adding control points at the ends
112+
if (isClosed) {
113+
_pts.unshift(pts[pts.length - 2], pts[pts.length - 1]); // Repeat last point at the start
114+
_pts.push(pts[0], pts[1]); // Repeat first point at the end
115+
} else {
116+
// Add mirrored control points at start and end to prevent sharp edges
117+
_pts.unshift(2 * pts[0] - pts[2], 2 * pts[1] - pts[3]);
118+
_pts.push(2 * pts[pts.length - 2] - pts[pts.length - 4], 2 * pts[pts.length - 1] - pts[pts.length - 3]);
119+
}
120+
121+
// Loop through each segment of the points
122+
for (let i = 2; i < (_pts.length - 4); i += 2) {
123+
for (t = 0; t <= numOfSegments; t++) { // Interpolate points for each segment
124+
// Calculate tangents at the start and end of the segment
125+
t1x = (_pts[i + 2] - _pts[i - 2]) * tension;
126+
t2x = (_pts[i + 4] - _pts[i]) * tension;
127+
t1y = (_pts[i + 3] - _pts[i - 1]) * tension;
128+
t2y = (_pts[i + 5] - _pts[i + 1]) * tension;
129+
st = t / numOfSegments; // Parameter for interpolation between 0 and 1
130+
131+
// Catmull-Rom spline basis functions
132+
c1 = 2 * st ** 3 - 3 * st ** 2 + 1;
133+
c2 = -2 * st ** 3 + 3 * st ** 2;
134+
c3 = st ** 3 - 2 * st ** 2 + st;
135+
c4 = st ** 3 - st ** 2;
136+
137+
// Calculate the x and y coordinates using the basis functions
138+
x = c1 * _pts[i] + c2 * _pts[i + 2] + c3 * t1x + c4 * t2x;
139+
y = c1 * _pts[i + 1] + c2 * _pts[i + 3] + c3 * t1y + c4 * t2y;
140+
141+
res.push({ x, y }); // Store the interpolated point
142+
}
143+
}
144+
return res; // Return the array of curve points
145+
};
146+
96147
export default function App() {
97148
const { theme } = useTheme()
98149
const mat_img = `./game_board_${theme ? theme : "dark"}.png`
99150
const [settings_active, SetSettingsActive] = useState(false)
100151
const [spike_server, SetSpikeServer] = useState<BluetoothRemoteGATTServer | null>(null)
152+
const [pysplan_handloer, SetPySplanHandler] = useState<SplanContent | null>(null)
153+
const [points, setPoints] = useState<Point[]>([]);
154+
const [history, setHistory] = useState<Point[][]>([[]]);
155+
const [currentIndex, setCurrentIndex] = useState(0);
156+
157+
const HandleLoadSplan = () => {
158+
const input = document.createElement('input');
159+
input.type = 'file';
160+
input.accept = '.pysplan';
161+
input.onchange = async () => {
162+
const file = input.files?.[0];
163+
if (!file) return;
164+
const reader = new FileReader();
165+
reader.onload = async () => {
166+
const data = JSON.parse(reader.result as string);
167+
try { const splan = new SplanContent(data); SetPySplanHandler(splan); } catch (e) { toast.error("Failed to load Splan file, check the console for more info", {duration: 5000}); console.error(e); }
168+
}
169+
reader.readAsText(file);
170+
}
171+
input.click();
172+
}
173+
174+
const HandleSaveSplan = () => {
175+
if (!pysplan_handloer) { toast.error("No Splan to save", {duration: 5000}); return; }
176+
const data = JSON.stringify(pysplan_handloer);
177+
const blob = new Blob([data], { type: "application/json" });
178+
const url = URL.createObjectURL(blob);
179+
const link = document.createElement("a");
180+
link.href = url;
181+
link.download = `${pysplan_handloer.name}.pysplan`;
182+
link.click();
183+
URL.revokeObjectURL(url);
184+
}
185+
186+
const GenerateCode = async () => {
187+
const github_url = "https://raw.githubusercontent.com/PySplanner/PySplanner/refs/heads/main/pysplanner.py"
188+
const response = await fetch(github_url);
189+
const code = await response.text();
190+
// TODO: Add the stuff to the code
191+
}
192+
193+
const addPoint = (e: React.MouseEvent) => {
194+
const rect = e.currentTarget.getBoundingClientRect();
195+
const newPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
196+
const newPoints = [...points, newPoint];
197+
198+
if (newPoints.length === 25) {
199+
toast.warning("WARNING: Exceeding 25 points may cause lagging/crashing of the Spike or EV3 robot.", {duration: 10000});
200+
} else if (newPoints.length === 50) {
201+
toast.error("You have reached the maximum number of points, which is 50.", {duration: 10000});
202+
return
203+
}
204+
205+
setPoints(newPoints);
206+
setHistory(history.slice(0, currentIndex + 1).concat([newPoints]));
207+
setCurrentIndex(currentIndex + 1);
208+
};
209+
210+
const handleKeyDown = (e: KeyboardEvent) => {
211+
if (e.ctrlKey && e.key === 'z' && currentIndex > 0) {
212+
setCurrentIndex(currentIndex - 1);
213+
setPoints(history[currentIndex - 1]);
214+
} else if (e.ctrlKey && e.key === 'y' && currentIndex < history.length - 1) {
215+
setCurrentIndex(currentIndex + 1);
216+
setPoints(history[currentIndex + 1]);
217+
}
218+
};
219+
220+
useEffect(() => {
221+
window.addEventListener('keydown', handleKeyDown);
222+
return () => window.removeEventListener('keydown', handleKeyDown);
223+
}, [points, history, currentIndex]);
224+
225+
const flatPoints = points.flatMap(p => [p.x, p.y]);
226+
const splinePoints = getCurvePoints(flatPoints);
101227

102228
const GetSpikeServer = async () => {
103229
toast.promise(
104-
async () => { SetSpikeServer(await connectToSpike()) },
105-
{
106-
loading: "Connecting to Spike...",
107-
success: "Connected to Spike!",
108-
error: "Failed to connect to Spike."
109-
}
230+
async () => { SetSpikeServer(await connectToSpike()) },
231+
{
232+
loading: "Connecting to Spike...",
233+
success: "Connected to Spike!",
234+
error: "Failed to connect to Spike."
235+
}
110236
)
111-
};
237+
};
112238

113239
const Sidebar = () => {
114240
return (
@@ -141,13 +267,13 @@ export default function App() {
141267
EV3 is not yet supported.
142268
</AccordionContent>
143269
</AccordionItem>
144-
<AccordionItem value="Paths">
145-
<AccordionTrigger>Paths</AccordionTrigger>
270+
<AccordionItem value="Splans">
271+
<AccordionTrigger>Splans</AccordionTrigger>
146272
<AccordionContent>
147273
<div className="flex flex-col gap-2">
148-
<Button variant="secondary" className="w-full">Create Path</Button>
149-
<Button variant="secondary" className="w-full">Load Paths</Button>
150-
<Button variant="secondary" className="w-full">Save Paths</Button>
274+
<Button variant="secondary" className="w-full">Create Splan</Button>
275+
<Button variant="secondary" className="w-full" onClick={ () => HandleLoadSplan() }>Load Splan</Button>
276+
<Button variant="secondary" className="w-full" onClick={ () => HandleSaveSplan() }>Save Splan</Button>
151277
</div>
152278
</AccordionContent>
153279
</AccordionItem>
@@ -166,20 +292,36 @@ export default function App() {
166292

167293
const Home = () => {
168294
return (
169-
<div className="relative flex items-center justify-center w-full h-full border rounded-lg ml-4">
170-
<div className="relative">
171-
<img src={mat_img} className="w-auto h-auto max-h-[85vh] max-w-[85vw] object-contain"/>
172-
<div className="absolute inset-0 flex items-center justify-center">
173-
<PointPlotter />
295+
<div className="flex items-center justify-center w-full h-full">
296+
{pysplan_handloer ? (
297+
<div className="relative flex items-center justify-center w-full h-full border rounded-lg ml-4">
298+
<div className="relative">
299+
<img src={mat_img} className="w-auto h-auto max-h-[85vh] max-w-[85vw] object-contain"/>
300+
<div className="absolute inset-0 flex items-center justify-center">
301+
<div className="w-full h-full relative" onClick={addPoint}>
302+
{points.map((p, idx) => (
303+
<div key={idx} className="absolute bg-green-500 w-2 h-2 rounded-full" style={{ left: `${p.x}px`, top: `${p.y}px` }}/>
304+
))}
305+
{splinePoints.map((p, idx) => (
306+
<div key={idx} className="absolute bg-green-400 w-1 h-1 rounded-full" style={{ left: `${p.x}px`, top: `${p.y}px` }}/>
307+
))}
308+
</div>
309+
</div>
310+
</div>
174311
</div>
175-
</div>
312+
) : (
313+
<div className="flex flex-col items-center justify-center w-full h-full border rounded-lg ml-4">
314+
<p className="text-center font-bold text-xl">Create a new Splan or load an existing one on the sidebar under Splans</p>
315+
<p className="text-center mt-2 text-md text-zinc-500">This path planner is currently in development, please check back later</p>
316+
</div>
317+
)}
176318
</div>
177319
);
178320
};
179321

180322
const Settings = () => {
181323
return (
182-
<div className="relative flex w-full border rounded-lg ml-4 p-8">
324+
<div className="relative flex w-full border rounded-lg ml-4 p-8">
183325
<h1>Settings</h1>
184326
<Button variant={"secondary"} className="absolute top-2 right-2 m-2" onClick={ () => SetSettingsActive(false)}>
185327

0 commit comments

Comments
 (0)