jurmy24's picture
feat: add viewer code
72f0edb
raw
history blame
15.1 kB
// @deno-types="npm:@types/node"
import { XMLParser } from "npm:[email protected]";
import { serve } from "https://deno.land/[email protected]/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/[email protected]";
// Deno environment
declare const Deno: {
env: {
get(key: string): string | undefined;
};
};
// OpenAI client
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
if (!OPENAI_API_KEY) {
console.error("Missing OPENAI_API_KEY environment variable");
}
// URDF Joint type definition
interface URDFJoint {
name: string;
type: string;
parent: string;
child: string;
axis?: {
xyz: string;
};
origin?: {
xyz: string;
rpy: string;
};
limits?: {
lower?: number;
upper?: number;
effort?: number;
velocity?: number;
};
}
// URDF Element interface to avoid 'any' type
interface URDFElement {
name?: string;
type?: string;
parent?: { link?: string };
child?: { link?: string };
axis?: { xyz?: string };
origin?: { xyz?: string; rpy?: string };
limit?: {
lower?: number;
upper?: number;
effort?: number;
velocity?: number;
};
}
// Animation request definition
interface AnimationRequest {
robotName: string;
urdfContent: string;
description: string;
}
// Joint animation configuration
interface JointAnimationConfig {
name: string;
type: "sine" | "linear" | "constant";
min: number;
max: number;
speed: number;
offset: number;
isDegrees?: boolean;
}
// Robot animation configuration
interface RobotAnimationConfig {
joints: JointAnimationConfig[];
speedMultiplier?: number;
}
// CORS headers for cross-origin requests
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// Generate animation configuration using OpenAI
async function generateAnimationConfig(
joints: URDFJoint[],
description: string,
robotName: string
): Promise<RobotAnimationConfig> {
// Filter out fixed joints as they can't be animated
const movableJoints = joints.filter(
(joint) =>
joint.type !== "fixed" &&
joint.name &&
(joint.type === "revolute" ||
joint.type === "continuous" ||
joint.type === "prismatic")
);
// Create a description of available joints for the AI
const jointsDescription = movableJoints
.map((joint) => {
const limits = joint.limits
? `(limits: ${
joint.limits.lower !== undefined ? joint.limits.lower : "none"
} to ${
joint.limits.upper !== undefined ? joint.limits.upper : "none"
})`
: "(no limits)";
const axisInfo = joint.axis ? `axis: ${joint.axis.xyz}` : "no axis info";
return `- ${joint.name}: type=${joint.type}, connects ${joint.parent} to ${joint.child}, ${axisInfo} ${limits}`;
})
.join("\n");
// Create prompt for OpenAI
const prompt = `
You are a robotics animation expert. I need you to create animation configurations for a robot named "${robotName}".
The user wants the following animation: "${description}"
Here are the available movable joints in the robot:
${jointsDescription}
Based on these joints and the user's description, create a valid animation configuration in JSON format.
The animation configuration should include:
1. A list of joint animations (only include joints that should move)
2. For each joint, specify:
- name: The exact joint name from the list above (string)
- type: Must be exactly one of "sine", "linear", or "constant" (string)
- min: Minimum position value (number, respect joint limits if available)
- max: Maximum position value (number, respect joint limits if available)
- speed: Speed multiplier (number, 0.5-2.0 is a reasonable range, lower is slower)
- offset: Phase offset in radians (number, 0 to 2π, useful for coordinating multiple joints)
- isDegrees: Optional boolean, set to true if the min/max values are in degrees rather than radians
3. An optional global speedMultiplier (number, default: 1.0)
You must return ONLY valid JSON matching this EXACT schema:
{
"joints": [
{
"name": string,
"type": "sine" | "linear" | "constant",
"min": number,
"max": number,
"speed": number,
"offset": number,
"isDegrees"?: boolean
}
],
"speedMultiplier"?: number
}
Important rules:
- Only include movable joints from the list provided
- Respect joint limits when available
- Create natural, coordinated movements that match the user's description
- If the description mentions specific joints, prioritize animating those
- For walking animations, coordinate leg joints with appropriate phase offsets
- For realistic motion, use sine waves with different offsets for natural movement
- DO NOT ADD ANY PROPERTIES that aren't in the schema above
- DO NOT INCLUDE customEasing or any other properties not in the schema
- Return ONLY valid JSON without comments or explanations
`;
try {
// Call OpenAI API
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content:
"You are a robotics animation expert that produces only valid JSON as output.",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
max_tokens: 1000,
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("OpenAI API error:", errorData);
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
const animationText = data.choices[0].message.content.trim();
// Log the raw response for debugging
console.log(
"Raw OpenAI response:",
animationText.substring(0, 200) +
(animationText.length > 200 ? "..." : "")
);
// Parse JSON from the response
let animationConfig: RobotAnimationConfig;
try {
// Since we're using response_format: { type: "json_object" }, this should always be valid JSON
animationConfig = JSON.parse(animationText);
} catch (e) {
console.error("Failed to parse JSON response:", e);
throw new Error(
"Could not parse animation configuration from AI response"
);
}
// Validate the animation config against our RobotAnimationConfig type
if (!animationConfig.joints || !Array.isArray(animationConfig.joints)) {
throw new Error(
"Invalid animation configuration: missing or invalid joints array"
);
}
// Default speedMultiplier if not provided
if (animationConfig.speedMultiplier === undefined) {
animationConfig.speedMultiplier = 1.0;
} else if (typeof animationConfig.speedMultiplier !== "number") {
throw new Error("Invalid speedMultiplier: must be a number");
}
// Validate all joints have required properties and correct types
for (const joint of animationConfig.joints) {
// Check required properties
if (!joint.name || typeof joint.name !== "string") {
throw new Error("Invalid joint: name is required and must be a string");
}
if (!joint.type || !["sine", "linear", "constant"].includes(joint.type)) {
throw new Error(
`Invalid joint type: ${joint.type} for joint ${joint.name}. Must be "sine", "linear", or "constant"`
);
}
if (joint.min === undefined || typeof joint.min !== "number") {
throw new Error(
`Invalid min value for joint ${joint.name}: must be a number`
);
}
if (joint.max === undefined || typeof joint.max !== "number") {
throw new Error(
`Invalid max value for joint ${joint.name}: must be a number`
);
}
if (joint.speed === undefined || typeof joint.speed !== "number") {
throw new Error(
`Invalid speed value for joint ${joint.name}: must be a number`
);
}
if (joint.offset === undefined || typeof joint.offset !== "number") {
throw new Error(
`Invalid offset value for joint ${joint.name}: must be a number`
);
}
// Check optional properties
if (
joint.isDegrees !== undefined &&
typeof joint.isDegrees !== "boolean"
) {
throw new Error(
`Invalid isDegrees value for joint ${joint.name}: must be a boolean`
);
}
// Ensure no custom properties that aren't in our type
const allowedProperties = [
"name",
"type",
"min",
"max",
"speed",
"offset",
"isDegrees",
];
const extraProperties = Object.keys(joint).filter(
(key) => !allowedProperties.includes(key)
);
if (extraProperties.length > 0) {
console.warn(
`Warning: Joint ${
joint.name
} has extra properties not in JointAnimationConfig: ${extraProperties.join(
", "
)}`
);
// Remove extra properties to ensure exact type match
extraProperties.forEach((prop) => {
delete joint[prop];
});
}
}
// Clean the final object to ensure it matches our type exactly
const cleanedConfig: RobotAnimationConfig = {
joints: animationConfig.joints.map((joint) => ({
name: joint.name,
type: joint.type,
min: joint.min,
max: joint.max,
speed: joint.speed,
offset: joint.offset,
...(joint.isDegrees !== undefined && { isDegrees: joint.isDegrees }),
})),
speedMultiplier: animationConfig.speedMultiplier,
};
// Log the cleaned config for debugging
console.log(
`Cleaned config: ${cleanedConfig.joints.length} joints, speedMultiplier: ${cleanedConfig.speedMultiplier}`
);
return cleanedConfig;
} catch (error) {
console.error("Error generating animation:", error);
// Return a simple default animation if generation fails
// Get at most 2 movable joints to animate
const jointsToAnimate = movableJoints.slice(0, 2);
if (jointsToAnimate.length === 0) {
// If no movable joints found, create a message
throw new Error(
"Cannot generate animation: No movable joints found in the robot model"
);
}
// Create a simple, safe animation for the available joints
const fallbackConfig: RobotAnimationConfig = {
joints: jointsToAnimate.map((joint) => ({
name: joint.name,
type: "sine" as const,
min: joint.limits?.lower !== undefined ? joint.limits.lower : -0.5,
max: joint.limits?.upper !== undefined ? joint.limits.upper : 0.5,
speed: 1.0,
offset: 0,
isDegrees: false,
})),
speedMultiplier: 1.0,
};
console.log("Using fallback animation config:", fallbackConfig);
return fallbackConfig;
}
}
// Parse URDF XML and extract joint information
function parseUrdfForJoints(urdfContent: string): URDFJoint[] {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "",
parseAttributeValue: true,
});
try {
const doc = parser.parse(urdfContent);
if (!doc || !doc.robot) {
throw new Error("No robot element found in URDF");
}
// Extract joints
const joints: URDFJoint[] = [];
const robotElement = doc.robot;
if (robotElement.joint) {
const jointElements = Array.isArray(robotElement.joint)
? robotElement.joint
: [robotElement.joint];
jointElements.forEach((joint: URDFElement) => {
const jointData: URDFJoint = {
name: joint.name || "",
type: joint.type || "",
parent: joint.parent?.link || "",
child: joint.child?.link || "",
};
// Parse axis
if (joint.axis) {
jointData.axis = {
xyz: joint.axis.xyz || "0 0 0",
};
}
// Parse origin
if (joint.origin) {
jointData.origin = {
xyz: joint.origin.xyz || "0 0 0",
rpy: joint.origin.rpy || "0 0 0",
};
}
// Parse limits
if (joint.limit) {
jointData.limits = {
lower:
joint.limit.lower !== undefined ? joint.limit.lower : undefined,
upper:
joint.limit.upper !== undefined ? joint.limit.upper : undefined,
effort:
joint.limit.effort !== undefined ? joint.limit.effort : undefined,
velocity:
joint.limit.velocity !== undefined
? joint.limit.velocity
: undefined,
};
}
joints.push(jointData);
});
}
return joints;
} catch (error) {
console.error("Error parsing URDF XML:", error);
throw new Error(
`Could not parse URDF: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// Main server function
serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
if (req.method !== "POST") {
throw new Error("Method not allowed");
}
// Parse request
const requestData: AnimationRequest = await req.json();
const { robotName, urdfContent, description } = requestData;
if (!urdfContent) {
throw new Error("No URDF content provided");
}
if (!description) {
throw new Error("No animation description provided");
}
console.log(
`Generating animation for ${robotName}: "${description.substring(
0,
100
)}..."`
);
// Parse URDF to extract joint information
const joints = parseUrdfForJoints(urdfContent);
console.log(`Extracted ${joints.length} joints from URDF`);
// Generate animation configuration
const animationConfig = await generateAnimationConfig(
joints,
description,
robotName
);
console.log(
`Generated animation with ${animationConfig.joints.length} animated joints`
);
// Return the animation configuration
return new Response(JSON.stringify(animationConfig), {
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
});
} catch (error) {
console.error("Error processing animation request:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "Unknown error occurred",
}),
{
status: 500,
headers: {
...corsHeaders,
"Content-Type": "application/json",
},
}
);
}
});