diff --git a/frontend/src/api_helper/TreeWrapper.ts b/frontend/src/api_helper/TreeWrapper.ts index 12eae6933..a36801fe3 100755 --- a/frontend/src/api_helper/TreeWrapper.ts +++ b/frontend/src/api_helper/TreeWrapper.ts @@ -319,6 +319,99 @@ const listDockerWorlds = async () => { } }; +const listDockerRobots = async () => { + const apiUrl = "/bt_studio/list_docker_robots/"; + try { + const response = await axios.get(apiUrl); + return response.data.robots; + } catch (e: unknown) { + const error = e as ApiError; + throw Error(error.response?.data.message); + } +}; + +const createCombinedUniverse = async ( + projectId: string, + universeName: string, + worldId: string, + robotId: string, + startPose: number[] | null = null +) => { + if (!projectId) throw new Error("The project name is not set"); + if (!universeName) throw new Error("The universe name is not set"); + if (!worldId) throw new Error("The world is not set"); + + const apiUrl = "/bt_studio/create_combined_universe/"; + try { + const response = await axios.post( + apiUrl, + { + project_id: projectId, + universe_name: universeName, + world_id: worldId, + robot_id: robotId, + start_pose: startPose, + }, + axiosExtra() + ); + return response.data; + } catch (e: unknown) { + const error = e as ApiError; + throw Error(error.response?.data.message); + } +}; + +const getCombinedUniverseData = async (worldId: string, robotId: string) => { + const apiUrl = `/bt_studio/get_combined_universe_data/?world_id=${encodeURIComponent( + worldId + )}&robot_id=${encodeURIComponent(robotId || "None")}`; + try { + const response = await axios.get(apiUrl); + return { + world: response.data.universe.world, + robot: response.data.universe.robot, + tools: response.data.universe.tools, + tools_config: response.data.universe.tools_config, + }; + } catch (e: unknown) { + const error = e as ApiError; + throw Error(error.response?.data.message); + } +}; + +const captureRobotPose = async () => { + const apiUrl = "/bt_studio/capture_robot_pose/"; + try { + const response = await axios.get(apiUrl); + return response.data.pose; + } catch (e: unknown) { + const error = e as ApiError; + throw Error(error.response?.data.message); + } +}; + +const saveCurrentPose = async (projectId: string, universeName: string) => { + if (!projectId) throw new Error("Project name is not set"); + if (!universeName) throw new Error("Universe name is not set"); + + const apiUrl = "/bt_studio/save_current_pose/"; + try { + const response = await axios.post( + apiUrl, + { + project_id: projectId, + universe_name: universeName, + }, + axiosExtra() + ); + return response.data.pose; + } catch (e: unknown) { + const error = e as ApiError; + throw Error(error.response?.data.message); + } +}; + + ////////////////////////////// App management ////////////////////////////////// const generateLocalApp = async (project: string, btOrder: string) => { @@ -998,7 +1091,13 @@ export { getWorldConfig, getWorldFile, listDockerWorlds, + listDockerRobots, + createCombinedUniverse, + getCombinedUniverseData, + captureRobotPose, + saveCurrentPose, listProjects, + listWorlds, renameFile, renameFolder, diff --git a/frontend/src/components/Buttons/SavePose.tsx b/frontend/src/components/Buttons/SavePose.tsx new file mode 100644 index 000000000..31a4f54f5 --- /dev/null +++ b/frontend/src/components/Buttons/SavePose.tsx @@ -0,0 +1,72 @@ +import React, { useState } from "react"; +import { StyledHeaderButton } from "BtStyles/Header/HeaderMenu.styles"; +import { useError } from "jderobot-ide-interface"; +import { CommsManager, states } from "jderobot-commsmanager"; +import { useBtTheme } from "BtContexts/BtThemeContext"; +import { LoadingIcon } from "BtIcons"; +import PinDropIcon from "@mui/icons-material/PinDropRounded"; +import { saveCurrentPose } from "BtApi/TreeWrapper"; + +interface SavePoseButtonProps { + project: string; +} + +const SavePoseButton = ({ project }: SavePoseButtonProps) => { + const theme = useBtTheme(); + const { warning, error, info } = useError() as any; + const [loading, setLoading] = useState(false); + + const onSavePose = async () => { + const manager = CommsManager.getInstance(); + const state = manager.getState(); + const activeUniverse = manager.getUniverse(); + + if (!activeUniverse || activeUniverse === "") { + warning("No active universe loaded. Please select and launch a combined universe first."); + return; + } + + if ( + state === states.CONNECTED || + state === states.IDLE || + state === states.WORLD_READY + ) { + warning("Simulation is not running. Please launch the simulation first."); + return; + } + + setLoading(true); + try { + const pose = await saveCurrentPose(project, activeUniverse); + const poseStr = `[x:${pose[0]}, y:${pose[1]}, z:${pose[2]}, yaw:${pose[5]}]`; + if (info) { + info(`Successfully saved robot's current pose ${poseStr} as the starting pose for universe '${activeUniverse}'!`); + } else { + alert(`Successfully saved robot's current pose ${poseStr} as the starting pose for universe '${activeUniverse}'!`); + } + } catch (e: any) { + error(e.response?.data?.message || e.message || "Failed to save the current robot pose. Make sure the robot is active in the simulator."); + } + setLoading(false); + }; + + return ( + + {loading ? ( + + ) : ( + + )} + + ); +}; + +export default SavePoseButton; diff --git a/frontend/src/components/Buttons/index.ts b/frontend/src/components/Buttons/index.ts index 48900385d..25432e6da 100644 --- a/frontend/src/components/Buttons/index.ts +++ b/frontend/src/components/Buttons/index.ts @@ -8,3 +8,5 @@ export { default as ThemeButton } from "./Theme"; export { default as SettingsButton } from "./Settings"; export { default as DocumentationButton } from "./Documentation"; export { default as ExportButton } from "./Export"; +export { default as SavePoseButton } from "./SavePose"; + diff --git a/frontend/src/components/CreateProject/CreateProject.tsx b/frontend/src/components/CreateProject/CreateProject.tsx index 9b963772b..1353ceaee 100644 --- a/frontend/src/components/CreateProject/CreateProject.tsx +++ b/frontend/src/components/CreateProject/CreateProject.tsx @@ -56,7 +56,7 @@ const Menu = ({ projId }: { projId?: string }) => { } setLoading(true); try { - await createProject(name); + await createProject(name, projId); navigate(".."); console.log("Project created successfully"); } catch (e: unknown) { diff --git a/frontend/src/components/HeaderMenu/HeaderMenu.tsx b/frontend/src/components/HeaderMenu/HeaderMenu.tsx index eea5effe4..2fc047625 100644 --- a/frontend/src/components/HeaderMenu/HeaderMenu.tsx +++ b/frontend/src/components/HeaderMenu/HeaderMenu.tsx @@ -16,6 +16,7 @@ import { TerminateWorldButton, ThemeButton, ExportButton, + SavePoseButton, } from "../Buttons"; import { StyledHeaderButtonContainer, @@ -92,6 +93,7 @@ const HeaderMenu = ({ + {/* */} { + const { error } = useError(); + const [capturing, setCapturing] = useState(false); + + const handleCapturePose = async () => { + setCapturing(true); + try { + const pose = await captureRobotPose(); + setFormState(prev => ({ + ...prev, + x: String(pose[0]), + y: String(pose[1]), + z: String(pose[2]), + yaw: String(pose[5]) + })); + } catch (e: any) { + error(e.response?.data?.message || e.message || "Failed to capture pose from simulator. Make sure the simulation is running."); + } finally { + setCapturing(false); + } + }; + + const focusInputRef = useRef(null); + const worldDropdownRef = useRef(null); + const robotDropdownRef = useRef(null); + + const [formState, setFormState] = useState(initialFormData); + const [worlds, setWorlds] = useState([]); + const [robots, setRobots] = useState([]); + + const [openWorldDropdown, setOpenWorldDropdown] = useState(false); + const [openRobotDropdown, setOpenRobotDropdown] = useState(false); + + const loadOptions = async () => { + try { + const worldsList = await listDockerWorlds(); + const robotsList = await listDockerRobots(); + setWorlds(worldsList); + setRobots(robotsList); + } catch (e) { + if (e instanceof Error) { + console.error("Error while fetching worlds/robots list: " + e.message); + error("Error while fetching worlds/robots list: " + e.message); + } + } + }; + + useEffect(() => { + if (visible && focusInputRef.current) { + setTimeout(() => { + focusInputRef.current.focus(); + }, 0); + } + loadOptions(); + }, [visible]); + + const handleInputChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setFormState((prevFormData) => { + const updated = { + ...prevFormData, + [name]: value, + }; + if (name === "worldName") { + const selectedWorld = worlds.find((w) => w.name === value); + if (selectedWorld && selectedWorld.type !== "gz") { + updated.robotName = "None"; + } + } + return updated; + }); + }; + + const handleCancel = () => { + if (currentProject !== "") { + onClose(); + } + }; + + const handleCreate = async () => { + if (formState.universeName === "" || formState.worldName === "") { + return; + } + + const selectedWorld = worlds.find((w) => w.name === formState.worldName); + if (!selectedWorld) { + error("Invalid world selected"); + return; + } + + let robotId = "None"; + if (formState.robotName !== "None" && formState.robotName !== "") { + const selectedRobot = robots.find((r) => r.name === formState.robotName); + if (!selectedRobot) { + error("Invalid robot selected"); + return; + } + robotId = selectedRobot.id; + } + + let customPose = null; + if (formState.useCustomPose) { + const x = parseFloat(formState.x); + const y = parseFloat(formState.y); + const z = parseFloat(formState.z); + const yaw = parseFloat(formState.yaw); + + if (isNaN(x) || isNaN(y) || isNaN(z) || isNaN(yaw)) { + error("Please enter valid numbers for the coordinates."); + return; + } + + if (x < -200 || x > 200 || y < -200 || y > 200) { + error("Coordinates X and Y must be between -200 and 200 meters to keep the robot inside the world bounds."); + return; + } + + if (z < -1 || z > 100) { + error("Coordinate Z must be between -1 and 100 meters."); + return; + } + + if (yaw < -3.15 || yaw > 3.15) { + error("Yaw must be in radians, between -3.14 and 3.14 (approximately -180 to 180 degrees)."); + return; + } + + customPose = [x, y, z, 0.0, 0.0, yaw]; + } + + try { + await createCombinedUniverse( + currentProject, + formState.universeName, + selectedWorld.id, + robotId, + customPose + ); + setVisible(false); + } catch (e) { + if (e instanceof Error) { + error("Failed to create combined universe: " + e.message); + } + } + }; + + const closeDropdowns = (e: any) => { + if (openWorldDropdown && !worldDropdownRef.current?.contains(e.target)) { + setOpenWorldDropdown(false); + } + if (openRobotDropdown && !robotDropdownRef.current?.contains(e.target)) { + setOpenRobotDropdown(false); + } + }; + + useEffect(() => { + document.addEventListener("mousedown", closeDropdowns); + return () => { + document.removeEventListener("mousedown", closeDropdowns); + }; + }, [openWorldDropdown, openRobotDropdown]); + + const worldNames = worlds + .filter((w) => w.type === "gz") + .map((w) => w.name); + const robotNames = ["None", ...robots.map((r) => r.name)]; + + const selectedWorld = worlds.find((w) => w.name === formState.worldName); + const isIgnitionGazebo = selectedWorld && selectedWorld.type === "gz"; + + return ( + <> + { + handleCancel(); + }} + handleBack={() => { + setVisible(false); + }} + /> + + + + + + + {isIgnitionGazebo ? ( + + + + ) : ( + formState.worldName !== "" && ( +
+ This classic/drone scene includes its own default robot. Custom robot selection is locked to 'None'. +
+ ) + )} + {formState.worldName !== "" && ( + <> +
+ { + setFormState(prev => ({ ...prev, useCustomPose: e.target.checked })); + }} + style={{ marginRight: "8px", cursor: "pointer", width: "16px", height: "16px" }} + /> + +
+ {formState.useCustomPose && ( +
+
+ ⚠️ Warning: Coordinates X/Y must be between -200m and 200m. Z must be between -1m and 100m. +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ )} + + )} + + + + + ); +}; + +export default CreateCombinedPage;