|
107 | 107 | lineId: ln.id!, |
108 | 108 | })); |
109 | 109 | let shapes: Shape[] = getDefaultShapes(); |
| 110 | + let optimizingLineIds: Record<string, boolean> = {}; |
| 111 | + let optimizingAll = false; |
110 | 112 |
|
111 | 113 | const history = createHistory(); |
112 | 114 | const { canUndoStore, canRedoStore } = history; |
| 115 | + const OPTIMIZER_BASE_URL = "https://fpa.pedropathing.com"; |
113 | 116 |
|
114 | 117 | function getAppState(): AppState { |
115 | 118 | return { |
|
954 | 957 | let isDown = false; |
955 | 958 | let dragOffset = { x: 0, y: 0 }; // Store offset to prevent snapping to center |
956 | 959 |
|
| 960 | + const isLockedPathElem = (id: string | null): boolean => { |
| 961 | + if (!id || !id.startsWith("point")) return false; |
| 962 | + const parts = id.split("-"); |
| 963 | + const lineIdx = Number(parts[1]) - 1; |
| 964 | + if (Number.isNaN(lineIdx)) return false; |
| 965 | + if (lineIdx < 0) return false; // startPoint currently not lockable |
| 966 | + return !!lines[lineIdx]?.locked; |
| 967 | + }; |
| 968 | +
|
957 | 969 | two.renderer.domElement.addEventListener("mousemove", (evt: MouseEvent) => { |
958 | 970 | const elem = document.elementFromPoint(evt.clientX, evt.clientY); |
959 | 971 |
|
|
1028 | 1040 | } |
1029 | 1041 | } |
1030 | 1042 | } else { |
1031 | | - if (elem?.id.startsWith("point") || elem?.id.startsWith("obstacle")) { |
| 1043 | + if ( |
| 1044 | + (elem?.id.startsWith("point") && !isLockedPathElem(elem.id)) || |
| 1045 | + elem?.id.startsWith("obstacle") |
| 1046 | + ) { |
1032 | 1047 | two.renderer.domElement.style.cursor = "pointer"; |
1033 | 1048 | currentElem = elem.id; |
1034 | 1049 | } else { |
|
1039 | 1054 | }); |
1040 | 1055 |
|
1041 | 1056 | two.renderer.domElement.addEventListener("mousedown", (evt: MouseEvent) => { |
| 1057 | + if (currentElem && isLockedPathElem(currentElem)) { |
| 1058 | + isDown = false; |
| 1059 | + return; |
| 1060 | + } |
| 1061 | +
|
1042 | 1062 | isDown = true; |
1043 | 1063 |
|
1044 | 1064 | if (currentElem) { |
|
1295 | 1315 | recordChange(); |
1296 | 1316 | } |
1297 | 1317 |
|
| 1318 | + function toHeadingDegrees(point: Point, position: "start" | "end"): number { |
| 1319 | + if (!point) return 0; |
| 1320 | + if (point.heading === "linear") { |
| 1321 | + return position === "start" ? point.startDeg ?? 0 : point.endDeg ?? 0; |
| 1322 | + } |
| 1323 | + if (point.heading === "constant") { |
| 1324 | + return point.degrees ?? 0; |
| 1325 | + } |
| 1326 | + return 0; |
| 1327 | + } |
| 1328 | +
|
| 1329 | + function buildOptimizationPayload(lineIndex: number) { |
| 1330 | + const line = lines[lineIndex]; |
| 1331 | + if (!line) throw new Error("Line not found"); |
| 1332 | +
|
| 1333 | + const startPt = lineIndex === 0 ? startPoint : lines[lineIndex - 1]?.endPoint; |
| 1334 | + if (!startPt) throw new Error("Missing start point for optimization"); |
| 1335 | +
|
| 1336 | + const waypoints = [startPt, ...line.controlPoints, line.endPoint].map((p) => [p.x, p.y]); |
| 1337 | +
|
| 1338 | + return { |
| 1339 | + waypoints, |
| 1340 | + start_heading_degrees: toHeadingDegrees(startPt, "start"), |
| 1341 | + end_heading_degrees: toHeadingDegrees(line.endPoint, "end"), |
| 1342 | + x_velocity: settings.xVelocity, |
| 1343 | + y_velocity: settings.yVelocity, |
| 1344 | + angular_velocity: settings.aVelocity, |
| 1345 | + friction_coefficient: settings.kFriction, |
| 1346 | + robot_width: settings.rWidth, |
| 1347 | + robot_height: settings.rHeight, |
| 1348 | + min_coord_field: 0, |
| 1349 | + max_coord_field: FIELD_SIZE, |
| 1350 | + interpolation: |
| 1351 | + line.endPoint.heading === "tangential" |
| 1352 | + ? "tangent" |
| 1353 | + : line.endPoint.heading === "constant" |
| 1354 | + ? "constant" |
| 1355 | + : "linear", |
| 1356 | + }; |
| 1357 | + } |
| 1358 | +
|
| 1359 | + function sleep(ms: number) { |
| 1360 | + return new Promise((res) => setTimeout(res, ms)); |
| 1361 | + } |
| 1362 | +
|
| 1363 | + async function createOptimizationTask(payload: any) { |
| 1364 | + const response = await fetch(`${OPTIMIZER_BASE_URL}/optimize`, { |
| 1365 | + method: "POST", |
| 1366 | + headers: { "Content-Type": "application/json" }, |
| 1367 | + body: JSON.stringify(payload), |
| 1368 | + }); |
| 1369 | +
|
| 1370 | + if (response.status === 503) { |
| 1371 | + const errorData = await response.json().catch(() => ({})); |
| 1372 | + if ((errorData as any).error === "offline") { |
| 1373 | + throw new Error(`OFFLINE: ${(errorData as any).message || "Service unavailable"}`); |
| 1374 | + } |
| 1375 | + } |
| 1376 | +
|
| 1377 | + if (!response.ok) { |
| 1378 | + const errorText = await response.text().catch(() => ""); |
| 1379 | + throw new Error(`Optimizer request failed (${response.status}): ${errorText || response.statusText}`); |
| 1380 | + } |
| 1381 | +
|
| 1382 | + const data = await response.json(); |
| 1383 | + if (!data?.job_id) throw new Error("Optimizer did not return a job id"); |
| 1384 | + return data.job_id as string; |
| 1385 | + } |
| 1386 | +
|
| 1387 | + async function pollOptimizationResult(jobId: string, pollInterval = 1000, maxTries = 60) { |
| 1388 | + for (let i = 0; i < maxTries; i++) { |
| 1389 | + const response = await fetch(`${OPTIMIZER_BASE_URL}/job/${jobId}`); |
| 1390 | +
|
| 1391 | + if (response.status === 503) { |
| 1392 | + const errorData = await response.json().catch(() => ({})); |
| 1393 | + if ((errorData as any).error === "offline") { |
| 1394 | + throw new Error(`OFFLINE: ${(errorData as any).message || "Service unavailable"}`); |
| 1395 | + } |
| 1396 | + } |
| 1397 | +
|
| 1398 | + if (!response.ok) { |
| 1399 | + const errorText = await response.text().catch(() => ""); |
| 1400 | + throw new Error(`Optimizer status failed (${response.status}): ${errorText || response.statusText}`); |
| 1401 | + } |
| 1402 | +
|
| 1403 | + const data = await response.json(); |
| 1404 | + if (data?.status === "completed" && data.result) { |
| 1405 | + return data.result; |
| 1406 | + } |
| 1407 | + if (data?.status === "error") { |
| 1408 | + throw new Error("Optimization failed with server error."); |
| 1409 | + } |
| 1410 | +
|
| 1411 | + await sleep(pollInterval); |
| 1412 | + } |
| 1413 | +
|
| 1414 | + throw new Error("Timed out waiting for optimization result."); |
| 1415 | + } |
| 1416 | +
|
| 1417 | + async function runOptimization(payload: any, pollInterval = 1000, maxTries = 60) { |
| 1418 | + const jobId = await createOptimizationTask(payload); |
| 1419 | + return pollOptimizationResult(jobId, pollInterval, maxTries); |
| 1420 | + } |
| 1421 | +
|
| 1422 | + async function optimizeLine(lineId: string, targetControlPointIndex?: number) { |
| 1423 | + const lineIndex = lines.findIndex((l) => l.id === lineId); |
| 1424 | + if (lineIndex === -1) { |
| 1425 | + alert("Could not find line to optimize."); |
| 1426 | + return; |
| 1427 | + } |
| 1428 | +
|
| 1429 | + if (optimizingLineIds[lineId]) return; |
| 1430 | + optimizingLineIds = { ...optimizingLineIds, [lineId]: true }; |
| 1431 | +
|
| 1432 | + try { |
| 1433 | + const payload = buildOptimizationPayload(lineIndex); |
| 1434 | + const result = await runOptimization(payload); |
| 1435 | +
|
| 1436 | + const optimizedWaypoints = Array.isArray(result?.optimized_waypoints) |
| 1437 | + ? result.optimized_waypoints |
| 1438 | + : Array.isArray(result) |
| 1439 | + ? result |
| 1440 | + : null; |
| 1441 | +
|
| 1442 | + if (!optimizedWaypoints || optimizedWaypoints.length < 2) { |
| 1443 | + throw new Error("Unexpected optimizer response format."); |
| 1444 | + } |
| 1445 | +
|
| 1446 | + const interior = optimizedWaypoints |
| 1447 | + .slice(1, optimizedWaypoints.length - 1) |
| 1448 | + .map((p: number[]) => ({ x: p[0], y: p[1] })); |
| 1449 | +
|
| 1450 | + const newLines = [...lines]; |
| 1451 | + const current = newLines[lineIndex]; |
| 1452 | +
|
| 1453 | + if (typeof targetControlPointIndex === "number") { |
| 1454 | + // Only replace the targeted control point; keep others and endpoint untouched |
| 1455 | + const replacement = |
| 1456 | + interior[targetControlPointIndex] ?? interior[interior.length - 1]; |
| 1457 | + if (replacement) { |
| 1458 | + const cps = [...current.controlPoints]; |
| 1459 | + if (cps[targetControlPointIndex]) { |
| 1460 | + cps[targetControlPointIndex] = replacement; |
| 1461 | + newLines[lineIndex] = { |
| 1462 | + ...current, |
| 1463 | + controlPoints: cps, |
| 1464 | + }; |
| 1465 | + lines = normalizeLines(newLines); |
| 1466 | + recordChange(); |
| 1467 | + } |
| 1468 | + } |
| 1469 | + } else { |
| 1470 | + // Replace entire line (control points and endpoint) |
| 1471 | + newLines[lineIndex] = { |
| 1472 | + ...current, |
| 1473 | + endPoint: { |
| 1474 | + ...current.endPoint, |
| 1475 | + x: optimizedWaypoints[optimizedWaypoints.length - 1][0], |
| 1476 | + y: optimizedWaypoints[optimizedWaypoints.length - 1][1], |
| 1477 | + }, |
| 1478 | + controlPoints: interior, |
| 1479 | + }; |
| 1480 | + lines = normalizeLines(newLines); |
| 1481 | + recordChange(); |
| 1482 | + } |
| 1483 | + } catch (err) { |
| 1484 | + console.error(err); |
| 1485 | + alert((err as Error).message || "Optimization failed."); |
| 1486 | + } finally { |
| 1487 | + optimizingLineIds = { ...optimizingLineIds, [lineId]: false }; |
| 1488 | + } |
| 1489 | + } |
| 1490 | +
|
| 1491 | + async function optimizeAllLines() { |
| 1492 | + if (optimizingAll) return; |
| 1493 | + optimizingAll = true; |
| 1494 | + try { |
| 1495 | + for (const ln of lines) { |
| 1496 | + if (!ln?.id) continue; |
| 1497 | + await optimizeLine(ln.id); |
| 1498 | + } |
| 1499 | + } finally { |
| 1500 | + optimizingAll = false; |
| 1501 | + } |
| 1502 | + } |
| 1503 | +
|
1298 | 1504 | function loadRobot(evt: Event) { |
1299 | 1505 | loadRobotImage(evt, () => updateRobotImageDisplay()); |
1300 | 1506 | } |
|
1452 | 1658 | {recordChange} |
1453 | 1659 | {canUndo} |
1454 | 1660 | {canRedo} |
| 1661 | + {optimizeAllLines} |
| 1662 | + {optimizingAll} |
1455 | 1663 | /> |
1456 | 1664 | <!-- {saveFile} --> |
1457 | 1665 | <div |
@@ -1550,5 +1758,9 @@ pointer-events: none;`} |
1550 | 1758 | bind:loopAnimation |
1551 | 1759 | {resetAnimation} |
1552 | 1760 | {recordChange} |
| 1761 | + {optimizeLine} |
| 1762 | + {optimizingLineIds} |
| 1763 | + {optimizeAllLines} |
| 1764 | + {optimizingAll} |
1553 | 1765 | /> |
1554 | 1766 | </div> |
0 commit comments