同步KDL工程源码到云仓库

This commit is contained in:
wangdequan
2026-06-27 08:45:38 -04:00
parent 93d8ede54b
commit 95c684fc4d
93 changed files with 25712 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
kdl-wasm/build*/
kdl_install/
work/working1/
git.txt
*.log

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "orocos_kinematics_dynamics"]
path = orocos_kinematics_dynamics
url = https://github.com/orocos/orocos_kinematics_dynamics.git

76
kdl-wasm/CMakeLists.txt Normal file
View File

@@ -0,0 +1,76 @@
cmake_minimum_required(VERSION 3.16)
project(kdl_wasm_wrapper LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(KDL_SOURCE_DIR "" CACHE PATH "Path to the Orocos KDL source tree")
if(KDL_SOURCE_DIR AND NOT EXISTS "${KDL_SOURCE_DIR}")
message(FATAL_ERROR "KDL_SOURCE_DIR does not exist: ${KDL_SOURCE_DIR}")
endif()
set(EIGEN3_INCLUDE_DIR "" CACHE PATH "Path to the Eigen3 include directory")
if(KDL_SOURCE_DIR AND NOT EIGEN3_INCLUDE_DIR)
find_path(EIGEN3_INCLUDE_DIR Eigen/Core PATH_SUFFIXES eigen3)
endif()
if(KDL_SOURCE_DIR AND NOT EIGEN3_INCLUDE_DIR AND EXISTS "/usr/include/eigen3/Eigen/Core")
set(EIGEN3_INCLUDE_DIR "/usr/include/eigen3" CACHE PATH "Path to the Eigen3 include directory" FORCE)
endif()
if(KDL_SOURCE_DIR AND NOT EIGEN3_INCLUDE_DIR)
message(FATAL_ERROR "Eigen3 include directory was not found")
endif()
set(KDL_WASM_SOURCES
bindings/kdl_c_api.cpp
)
if(KDL_SOURCE_DIR)
list(APPEND KDL_WASM_SOURCES
"${KDL_SOURCE_DIR}/src/chain.cpp"
"${KDL_SOURCE_DIR}/src/chainfksolverpos_recursive.cpp"
"${KDL_SOURCE_DIR}/src/chainjnttojacsolver.cpp"
"${KDL_SOURCE_DIR}/src/frames.cpp"
"${KDL_SOURCE_DIR}/src/jacobian.cpp"
"${KDL_SOURCE_DIR}/src/jntarray.cpp"
"${KDL_SOURCE_DIR}/src/joint.cpp"
"${KDL_SOURCE_DIR}/src/rigidbodyinertia.cpp"
"${KDL_SOURCE_DIR}/src/rotationalinertia.cpp"
"${KDL_SOURCE_DIR}/src/segment.cpp"
"${KDL_SOURCE_DIR}/src/utilities/utility.cxx"
)
endif()
if(EMSCRIPTEN)
add_executable(kdl ${KDL_WASM_SOURCES})
target_compile_options(kdl PRIVATE "-fexceptions")
if(KDL_SOURCE_DIR)
target_include_directories(kdl PRIVATE "${KDL_SOURCE_DIR}/src" "${EIGEN3_INCLUDE_DIR}")
target_compile_definitions(kdl PRIVATE KDL_WASM_HAS_OROCOS_KDL=1)
endif()
set_target_properties(kdl PROPERTIES OUTPUT_NAME "kdl" SUFFIX ".js")
target_link_options(kdl PRIVATE
"--no-entry"
"-sMODULARIZE=1"
"-sEXPORT_ES6=1"
"-sEXPORT_NAME=createKdlModule"
"-sENVIRONMENT=web,worker,node"
"-sALLOW_MEMORY_GROWTH=1"
"-fexceptions"
"-sDISABLE_EXCEPTION_CATCHING=0"
"-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,UTF8ToString,stringToUTF8,lengthBytesUTF8,HEAPF64"
"-sEXPORTED_FUNCTIONS=['_malloc','_free','_kdl_init','_kdl_create_robot','_kdl_destroy_robot','_kdl_get_robot_info','_kdl_fk','_kdl_fk_all_links','_kdl_jacobian','_kdl_ik','_kdl_plan_movej','_kdl_plan_movel','_kdl_plan_movec','_kdl_plan_path','_kdl_sample_trap','_kdl_last_error']"
)
add_custom_command(TARGET kdl POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/bindings/kdl.d.ts"
"${CMAKE_CURRENT_BINARY_DIR}/kdl.d.ts"
)
else()
add_library(kdl SHARED ${KDL_WASM_SOURCES})
if(KDL_SOURCE_DIR)
target_include_directories(kdl PRIVATE "${KDL_SOURCE_DIR}/src" "${EIGEN3_INCLUDE_DIR}")
target_compile_definitions(kdl PRIVATE KDL_WASM_HAS_OROCOS_KDL=1)
endif()
endif()

26
kdl-wasm/bindings/kdl.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
export interface KdlModule {
ccall: (
ident: string,
returnType: string | null,
argTypes: Array<string | null>,
args: unknown[]
) => unknown;
cwrap: (
ident: string,
returnType: string | null,
argTypes: Array<string | null>
) => (...args: unknown[]) => unknown;
UTF8ToString: (ptr: number) => string;
stringToUTF8: (value: string, outPtr: number, maxBytesToWrite: number) => void;
lengthBytesUTF8: (value: string) => number;
_malloc: (size: number) => number;
_free: (ptr: number) => void;
}
export interface KdlModuleFactoryOptions {
locateFile?: (path: string, prefix: string) => string;
print?: (text: string) => void;
printErr?: (text: string) => void;
}
export default function createKdlModule(options?: KdlModuleFactoryOptions): Promise<KdlModule>;

View File

@@ -0,0 +1,731 @@
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
#include <map>
#include <memory>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#else
#define EMSCRIPTEN_KEEPALIVE
#endif
#ifdef KDL_WASM_HAS_OROCOS_KDL
#include "chain.hpp"
#include "chainfksolverpos_recursive.hpp"
#include "chainjnttojacsolver.hpp"
#include "jntarray.hpp"
#include "segment.hpp"
#endif
namespace {
std::string last_error =
R"({"code":"KDL_OK","message":"No error","diagnostics":[]})";
int next_robot_handle = 1;
struct JsonValue {
enum class Kind { Null, Bool, Number, String, Array, Object };
Kind kind = Kind::Null;
bool bool_value = false;
double number_value = 0.0;
std::string string_value;
std::vector<JsonValue> array_value;
std::map<std::string, JsonValue> object_value;
const JsonValue* get(const std::string& key) const {
if (kind != Kind::Object) {
return nullptr;
}
const auto it = object_value.find(key);
return it == object_value.end() ? nullptr : &it->second;
}
};
class JsonParser {
public:
explicit JsonParser(const std::string& input) : input_(input) {}
JsonValue parse() {
JsonValue value = parse_value();
skip_ws();
if (pos_ != input_.size()) {
throw std::runtime_error("Unexpected trailing JSON content");
}
return value;
}
private:
const std::string& input_;
std::size_t pos_ = 0;
JsonValue parse_value() {
skip_ws();
if (pos_ >= input_.size()) {
throw std::runtime_error("Unexpected end of JSON");
}
const char ch = input_[pos_];
if (ch == '{') {
return parse_object();
}
if (ch == '[') {
return parse_array();
}
if (ch == '"') {
JsonValue value;
value.kind = JsonValue::Kind::String;
value.string_value = parse_string();
return value;
}
if (ch == 't' || ch == 'f') {
return parse_bool();
}
if (ch == 'n') {
expect_literal("null");
return {};
}
return parse_number();
}
JsonValue parse_object() {
consume('{');
JsonValue value;
value.kind = JsonValue::Kind::Object;
skip_ws();
if (match('}')) {
return value;
}
while (true) {
skip_ws();
const std::string key = parse_string();
skip_ws();
consume(':');
value.object_value.emplace(key, parse_value());
skip_ws();
if (match('}')) {
return value;
}
consume(',');
}
}
JsonValue parse_array() {
consume('[');
JsonValue value;
value.kind = JsonValue::Kind::Array;
skip_ws();
if (match(']')) {
return value;
}
while (true) {
value.array_value.push_back(parse_value());
skip_ws();
if (match(']')) {
return value;
}
consume(',');
}
}
std::string parse_string() {
consume('"');
std::string out;
while (pos_ < input_.size()) {
const char ch = input_[pos_++];
if (ch == '"') {
return out;
}
if (ch != '\\') {
out.push_back(ch);
continue;
}
if (pos_ >= input_.size()) {
throw std::runtime_error("Invalid JSON string escape");
}
const char escaped = input_[pos_++];
switch (escaped) {
case '"':
case '\\':
case '/':
out.push_back(escaped);
break;
case 'b':
out.push_back('\b');
break;
case 'f':
out.push_back('\f');
break;
case 'n':
out.push_back('\n');
break;
case 'r':
out.push_back('\r');
break;
case 't':
out.push_back('\t');
break;
case 'u':
if (pos_ + 4 > input_.size()) {
throw std::runtime_error("Invalid JSON unicode escape");
}
out.push_back('?');
pos_ += 4;
break;
default:
throw std::runtime_error("Invalid JSON string escape");
}
}
throw std::runtime_error("Unterminated JSON string");
}
JsonValue parse_bool() {
JsonValue value;
value.kind = JsonValue::Kind::Bool;
if (starts_with("true")) {
value.bool_value = true;
pos_ += 4;
return value;
}
if (starts_with("false")) {
value.bool_value = false;
pos_ += 5;
return value;
}
throw std::runtime_error("Invalid JSON boolean");
}
JsonValue parse_number() {
const std::size_t start = pos_;
if (input_[pos_] == '-') {
pos_ += 1;
}
while (pos_ < input_.size() && std::isdigit(static_cast<unsigned char>(input_[pos_]))) {
pos_ += 1;
}
if (pos_ < input_.size() && input_[pos_] == '.') {
pos_ += 1;
while (pos_ < input_.size() && std::isdigit(static_cast<unsigned char>(input_[pos_]))) {
pos_ += 1;
}
}
if (pos_ < input_.size() && (input_[pos_] == 'e' || input_[pos_] == 'E')) {
pos_ += 1;
if (pos_ < input_.size() && (input_[pos_] == '+' || input_[pos_] == '-')) {
pos_ += 1;
}
while (pos_ < input_.size() && std::isdigit(static_cast<unsigned char>(input_[pos_]))) {
pos_ += 1;
}
}
if (start == pos_) {
throw std::runtime_error("Expected JSON number");
}
JsonValue value;
value.kind = JsonValue::Kind::Number;
value.number_value = std::stod(input_.substr(start, pos_ - start));
return value;
}
void expect_literal(const char* literal) {
if (!starts_with(literal)) {
throw std::runtime_error(std::string("Expected JSON literal ") + literal);
}
pos_ += std::strlen(literal);
}
bool starts_with(const char* literal) const {
const std::size_t len = std::strlen(literal);
return input_.compare(pos_, len, literal) == 0;
}
void skip_ws() {
while (pos_ < input_.size() && std::isspace(static_cast<unsigned char>(input_[pos_]))) {
pos_ += 1;
}
}
bool match(char expected) {
if (pos_ < input_.size() && input_[pos_] == expected) {
pos_ += 1;
return true;
}
return false;
}
void consume(char expected) {
if (!match(expected)) {
throw std::runtime_error(std::string("Expected JSON character ") + expected);
}
}
};
struct NativeJoint {
std::string name;
std::string type;
std::string child;
double xyz[3] = {0.0, 0.0, 0.0};
double rpy[3] = {0.0, 0.0, 0.0};
double axis[3] = {1.0, 0.0, 0.0};
bool active = false;
};
struct RobotRecord {
std::string model_json;
std::string robot_id;
std::string name;
std::string base_link;
std::string tip_link;
std::vector<std::string> active_joint_names;
std::vector<NativeJoint> joints;
#ifdef KDL_WASM_HAS_OROCOS_KDL
KDL::Chain chain;
std::unique_ptr<KDL::ChainFkSolverPos_recursive> fk_solver;
std::unique_ptr<KDL::ChainJntToJacSolver> jac_solver;
#endif
};
std::map<int, RobotRecord> robots;
void set_last_error(const char* code, const char* message) {
last_error = std::string("{\"code\":\"") + code + "\",\"message\":\"" +
message + "\",\"diagnostics\":[{\"severity\":\"error\",\"code\":\"" +
code + "\",\"message\":\"" + message + "\"}]}";
}
void clear_last_error() {
last_error = R"({"code":"KDL_OK","message":"No error","diagnostics":[]})";
}
int write_json(const std::string& json, char* out_json, int out_len) {
if (out_json == nullptr || out_len <= 0) {
set_last_error("KDL_BUFFER_TOO_SMALL", "Output buffer is not writable");
return -1;
}
const int required = static_cast<int>(json.size()) + 1;
if (out_len < required) {
set_last_error("KDL_BUFFER_TOO_SMALL", "Output buffer is too small");
return -1;
}
std::memcpy(out_json, json.c_str(), static_cast<std::size_t>(required));
return 0;
}
int not_implemented(const char* function_name) {
set_last_error("KDL_NOT_IMPLEMENTED", function_name);
return -1;
}
std::string json_escape(const std::string& value) {
std::string escaped;
for (const char ch : value) {
if (ch == '"' || ch == '\\') {
escaped.push_back('\\');
}
escaped.push_back(ch);
}
return escaped;
}
const JsonValue& required_object_property(const JsonValue& object, const std::string& key) {
const JsonValue* value = object.get(key);
if (!value) {
throw std::runtime_error("Missing model property: " + key);
}
return *value;
}
std::string string_property(const JsonValue& object, const std::string& key) {
const JsonValue& value = required_object_property(object, key);
if (value.kind != JsonValue::Kind::String) {
throw std::runtime_error("Expected string model property: " + key);
}
return value.string_value;
}
std::vector<std::string> string_array_property(const JsonValue& object, const std::string& key) {
const JsonValue& value = required_object_property(object, key);
if (value.kind != JsonValue::Kind::Array) {
throw std::runtime_error("Expected string array model property: " + key);
}
std::vector<std::string> out;
out.reserve(value.array_value.size());
for (const JsonValue& item : value.array_value) {
if (item.kind != JsonValue::Kind::String) {
throw std::runtime_error("Expected string item in model property: " + key);
}
out.push_back(item.string_value);
}
return out;
}
void copy_number_triple(const JsonValue& object, const std::string& key, double out[3]) {
const JsonValue& value = required_object_property(object, key);
if (value.kind != JsonValue::Kind::Array || value.array_value.size() != 3) {
throw std::runtime_error("Expected numeric triple model property: " + key);
}
for (std::size_t index = 0; index < 3; index += 1) {
if (value.array_value[index].kind != JsonValue::Kind::Number) {
throw std::runtime_error("Expected numeric triple model property: " + key);
}
out[index] = value.array_value[index].number_value;
}
}
std::vector<NativeJoint> parse_joints(const JsonValue& model,
const std::vector<std::string>& active_joint_names) {
const JsonValue& joints_value = required_object_property(model, "joints");
if (joints_value.kind != JsonValue::Kind::Array) {
throw std::runtime_error("Expected joints array");
}
std::vector<NativeJoint> joints;
joints.reserve(joints_value.array_value.size());
for (const JsonValue& joint_value : joints_value.array_value) {
if (joint_value.kind != JsonValue::Kind::Object) {
throw std::runtime_error("Expected joint object");
}
NativeJoint joint;
joint.name = string_property(joint_value, "name");
joint.type = string_property(joint_value, "type");
joint.child = string_property(joint_value, "child");
const JsonValue& origin = required_object_property(joint_value, "origin");
if (origin.kind != JsonValue::Kind::Object) {
throw std::runtime_error("Expected joint origin object");
}
copy_number_triple(origin, "xyz", joint.xyz);
copy_number_triple(origin, "rpy", joint.rpy);
copy_number_triple(joint_value, "axis", joint.axis);
joint.active = std::find(active_joint_names.begin(), active_joint_names.end(), joint.name) !=
active_joint_names.end();
joints.push_back(joint);
}
return joints;
}
#ifdef KDL_WASM_HAS_OROCOS_KDL
KDL::Frame frame_from_xyz_rpy(const double xyz[3], const double rpy[3]) {
return KDL::Frame(
KDL::Rotation::RPY(rpy[0], rpy[1], rpy[2]),
KDL::Vector(xyz[0], xyz[1], xyz[2]));
}
KDL::Joint kdl_joint_from_model(const NativeJoint& joint) {
if (joint.type == "fixed") {
return KDL::Joint(joint.name, KDL::Joint::Fixed);
}
const KDL::Vector axis(joint.axis[0], joint.axis[1], joint.axis[2]);
if (joint.type == "revolute" || joint.type == "continuous") {
return KDL::Joint(joint.name, KDL::Vector(0.0, 0.0, 0.0), axis, KDL::Joint::RotAxis);
}
if (joint.type == "prismatic") {
return KDL::Joint(joint.name, KDL::Vector(0.0, 0.0, 0.0), axis, KDL::Joint::TransAxis);
}
throw std::runtime_error("Unsupported KDL joint type: " + joint.type);
}
void build_kdl_chain(RobotRecord& record) {
record.chain = KDL::Chain();
for (const NativeJoint& joint : record.joints) {
const KDL::Frame origin = frame_from_xyz_rpy(joint.xyz, joint.rpy);
if (joint.xyz[0] != 0.0 || joint.xyz[1] != 0.0 || joint.xyz[2] != 0.0 ||
joint.rpy[0] != 0.0 || joint.rpy[1] != 0.0 || joint.rpy[2] != 0.0) {
record.chain.addSegment(KDL::Segment(joint.name + "_origin", KDL::Joint(KDL::Joint::Fixed), origin));
}
record.chain.addSegment(KDL::Segment(joint.child, kdl_joint_from_model(joint), KDL::Frame::Identity()));
}
record.fk_solver = std::make_unique<KDL::ChainFkSolverPos_recursive>(record.chain);
record.jac_solver = std::make_unique<KDL::ChainJntToJacSolver>(record.chain);
}
KDL::JntArray jnt_array_from_input(const RobotRecord& record, const double* joints, int n) {
if (joints == nullptr) {
throw std::runtime_error("Joint input is required");
}
if (n != static_cast<int>(record.active_joint_names.size())) {
throw std::runtime_error("Joint vector dimension does not match robot DOF");
}
KDL::JntArray q(static_cast<unsigned int>(n));
for (int index = 0; index < n; index += 1) {
q(static_cast<unsigned int>(index)) = joints[index];
}
return q;
}
void write_pose7(const KDL::Frame& frame, double* out_pose7) {
if (out_pose7 == nullptr) {
throw std::runtime_error("Output pose buffer is required");
}
out_pose7[0] = frame.p.x();
out_pose7[1] = frame.p.y();
out_pose7[2] = frame.p.z();
frame.M.GetQuaternion(out_pose7[3], out_pose7[4], out_pose7[5], out_pose7[6]);
}
#endif
RobotRecord parse_robot_record(const char* model_json) {
if (model_json == nullptr) {
throw std::runtime_error("Robot model JSON is required");
}
RobotRecord record;
record.model_json = model_json;
const JsonValue model = JsonParser(record.model_json).parse();
if (model.kind != JsonValue::Kind::Object) {
throw std::runtime_error("Robot model must be a JSON object");
}
record.robot_id = string_property(model, "robotId");
record.name = string_property(model, "name");
record.base_link = string_property(model, "baseLink");
record.tip_link = string_property(model, "tipLink");
record.active_joint_names = string_array_property(model, "activeJointNames");
record.joints = parse_joints(model, record.active_joint_names);
return record;
}
std::map<int, RobotRecord>::iterator find_robot(int robot_handle) {
const auto it = robots.find(robot_handle);
if (it == robots.end()) {
set_last_error("KDL_INVALID_HANDLE", "RobotHandle does not exist");
}
return it;
}
} // namespace
extern "C" {
EMSCRIPTEN_KEEPALIVE
int kdl_init(const char* options_json) {
(void)options_json;
robots.clear();
next_robot_handle = 1;
clear_last_error();
return 0;
}
EMSCRIPTEN_KEEPALIVE
int kdl_create_robot(const char* model_json) {
try {
RobotRecord record = parse_robot_record(model_json);
const int handle = next_robot_handle++;
auto [it, inserted] = robots.emplace(handle, std::move(record));
(void)inserted;
#ifdef KDL_WASM_HAS_OROCOS_KDL
build_kdl_chain(it->second);
#endif
clear_last_error();
return handle;
} catch (const std::exception& error) {
set_last_error("KDL_INVALID_MODEL", error.what());
return -1;
}
}
EMSCRIPTEN_KEEPALIVE
int kdl_destroy_robot(int robot_handle) {
if (robots.erase(robot_handle) == 0) {
set_last_error("KDL_INVALID_HANDLE", "RobotHandle does not exist");
return -1;
}
clear_last_error();
return 0;
}
EMSCRIPTEN_KEEPALIVE
int kdl_get_robot_info(int robot_handle, char* out_json, int out_len) {
const auto it = find_robot(robot_handle);
if (it == robots.end()) {
return -1;
}
const RobotRecord& record = it->second;
std::ostringstream json;
json << "{\"handle\":" << robot_handle << ",\"robotId\":\"" << json_escape(record.robot_id)
<< "\",\"name\":\"" << json_escape(record.name) << "\",\"baseLink\":\""
<< json_escape(record.base_link) << "\",\"tipLink\":\"" << json_escape(record.tip_link)
<< "\",\"dof\":" << record.active_joint_names.size() << ",\"jointNames\":[";
for (std::size_t index = 0; index < record.active_joint_names.size(); index += 1) {
if (index > 0) {
json << ",";
}
json << "\"" << json_escape(record.active_joint_names[index]) << "\"";
}
json << "],\"limits\":[],\"nativeState\":\""
#ifdef KDL_WASM_HAS_OROCOS_KDL
<< "kdl_chain"
#else
<< "model_cached"
#endif
<< "\"}";
const int result = write_json(json.str(), out_json, out_len);
if (result == 0) {
clear_last_error();
}
return result;
}
EMSCRIPTEN_KEEPALIVE
int kdl_fk(int robot_handle, const double* joints, int n, double* out_pose7) {
#ifdef KDL_WASM_HAS_OROCOS_KDL
try {
const auto it = find_robot(robot_handle);
if (it == robots.end()) {
return -1;
}
RobotRecord& record = it->second;
const KDL::JntArray q = jnt_array_from_input(record, joints, n);
KDL::Frame frame;
const int result = record.fk_solver->JntToCart(q, frame);
if (result != 0) {
set_last_error("KDL_FK_FAILED", "Native KDL FK solver failed");
return -1;
}
write_pose7(frame, out_pose7);
clear_last_error();
return 0;
} catch (const std::exception& error) {
set_last_error("KDL_FK_FAILED", error.what());
return -1;
}
#else
(void)robot_handle;
(void)joints;
(void)n;
(void)out_pose7;
return not_implemented("kdl_fk requires KDL_SOURCE_DIR");
#endif
}
EMSCRIPTEN_KEEPALIVE
int kdl_fk_all_links(int robot_handle, const double* joints, int n, char* out_json,
int out_len) {
(void)robot_handle;
(void)joints;
(void)n;
(void)out_json;
(void)out_len;
return not_implemented("kdl_fk_all_links is pending native link-pose serialization");
}
EMSCRIPTEN_KEEPALIVE
int kdl_jacobian(int robot_handle, const double* joints, int n, double* out_matrix) {
#ifdef KDL_WASM_HAS_OROCOS_KDL
try {
const auto it = find_robot(robot_handle);
if (it == robots.end()) {
return -1;
}
RobotRecord& record = it->second;
if (out_matrix == nullptr) {
throw std::runtime_error("Output Jacobian buffer is required");
}
const KDL::JntArray q = jnt_array_from_input(record, joints, n);
KDL::Jacobian jac(static_cast<unsigned int>(n));
const int result = record.jac_solver->JntToJac(q, jac);
if (result != 0) {
set_last_error("KDL_JACOBIAN_FAILED", "Native KDL Jacobian solver failed");
return -1;
}
for (int col = 0; col < n; col += 1) {
for (int row = 0; row < 6; row += 1) {
out_matrix[row * n + col] = jac(static_cast<unsigned int>(row), static_cast<unsigned int>(col));
}
}
clear_last_error();
return 0;
} catch (const std::exception& error) {
set_last_error("KDL_JACOBIAN_FAILED", error.what());
return -1;
}
#else
(void)robot_handle;
(void)joints;
(void)n;
(void)out_matrix;
return not_implemented("kdl_jacobian requires KDL_SOURCE_DIR");
#endif
}
EMSCRIPTEN_KEEPALIVE
int kdl_ik(int robot_handle, const double* seed, int n, const double* target_pose7,
const char* options_json, double* out_joints) {
(void)robot_handle;
(void)seed;
(void)n;
(void)target_pose7;
(void)options_json;
(void)out_joints;
return not_implemented("kdl_ik is pending native IK binding");
}
EMSCRIPTEN_KEEPALIVE
int kdl_plan_movej(int robot_handle, const char* request_json, char* out_json,
int out_len) {
(void)robot_handle;
(void)request_json;
(void)out_json;
(void)out_len;
return not_implemented("kdl_plan_movej is pending KW-008 native binding");
}
EMSCRIPTEN_KEEPALIVE
int kdl_plan_movel(int robot_handle, const char* request_json, char* out_json,
int out_len) {
(void)robot_handle;
(void)request_json;
(void)out_json;
(void)out_len;
return not_implemented("kdl_plan_movel is pending KW-009 native binding");
}
EMSCRIPTEN_KEEPALIVE
int kdl_plan_movec(int robot_handle, const char* request_json, char* out_json,
int out_len) {
(void)robot_handle;
(void)request_json;
(void)out_json;
(void)out_len;
return not_implemented("kdl_plan_movec is pending KW-010 native binding");
}
EMSCRIPTEN_KEEPALIVE
int kdl_plan_path(int robot_handle, const char* request_json, char* out_json,
int out_len) {
(void)robot_handle;
(void)request_json;
(void)out_json;
(void)out_len;
return not_implemented("kdl_plan_path is pending KW-011 native binding");
}
EMSCRIPTEN_KEEPALIVE
int kdl_sample_trap(double length, const char* options_json, char* out_json,
int out_len) {
(void)length;
(void)options_json;
(void)out_json;
(void)out_len;
return not_implemented("kdl_sample_trap is pending KW-007 native binding");
}
EMSCRIPTEN_KEEPALIVE
int kdl_last_error(char* out_json, int out_len) {
return write_json(last_error, out_json, out_len);
}
}

View File

@@ -0,0 +1,239 @@
import type { GrlSourceRange, GrlToken } from "../lexer/index.js";
export type GrlAstNodeKind =
| "Program"
| "LanguageDeclaration"
| "ModuleDeclaration"
| "ImportDeclaration"
| "DataDeclaration"
| "TargetDeclaration"
| "PathDeclaration"
| "PathDefaultsBlock"
| "PathSourceBlock"
| "PathPoint"
| "PathEvent"
| "OperationDeclaration"
| "OperationProcessBlock"
| "OperationActionBlock"
| "ProcedureDeclaration"
| "FunctionDeclaration"
| "RawTopLevelDeclaration"
| "IdentifierExpression"
| "NumberLiteral"
| "StringLiteral"
| "BooleanLiteral"
| "ArrayExpression"
| "CallExpression"
| "ObjectExpression"
| "OffsetExpression";
export interface GrlAstNode {
kind: GrlAstNodeKind;
range: GrlSourceRange;
}
export interface GrlLanguageDeclaration extends GrlAstNode {
kind: "LanguageDeclaration";
language: "grl";
version: string;
}
export interface GrlImportDeclaration extends GrlAstNode {
kind: "ImportDeclaration";
moduleName: string;
}
export type GrlDeclarationStorage = "persistent" | "const" | "var";
export interface GrlDataDeclaration extends GrlAstNode {
kind: "DataDeclaration";
storage: GrlDeclarationStorage;
typeName: string;
name: string;
initializer: GrlExpression;
}
export interface GrlTargetDeclaration extends GrlAstNode {
kind: "TargetDeclaration";
name: string;
target: GrlExpression;
}
export interface GrlPathProperty {
key: string;
value: GrlExpression;
range: GrlSourceRange;
}
export interface GrlPathDefaultsBlock extends GrlAstNode {
kind: "PathDefaultsBlock";
properties: GrlPathProperty[];
}
export interface GrlPathSourceBlock extends GrlAstNode {
kind: "PathSourceBlock";
properties: GrlPathProperty[];
}
export interface GrlPathPoint extends GrlAstNode {
kind: "PathPoint";
id: string;
motionTokens: GrlToken[];
}
export interface GrlPathEvent extends GrlAstNode {
kind: "PathEvent";
timing: "before" | "after" | "at";
pointId: string;
distance?: GrlNumberLiteral;
actionTokens: GrlToken[];
}
export type GrlPathItem =
| GrlPathDefaultsBlock
| GrlPathSourceBlock
| GrlPathPoint
| GrlPathEvent;
export interface GrlPathDeclaration extends GrlAstNode {
kind: "PathDeclaration";
name: string;
items: GrlPathItem[];
}
export interface GrlOperationProcessBlock extends GrlAstNode {
kind: "OperationProcessBlock";
properties: GrlPathProperty[];
}
export interface GrlOperationActionBlock extends GrlAstNode {
kind: "OperationActionBlock";
actionKind: "start_action" | "end_action";
actionTokens: GrlToken[];
}
export type GrlOperationItem = GrlOperationProcessBlock | GrlOperationActionBlock;
export interface GrlOperationDeclaration extends GrlAstNode {
kind: "OperationDeclaration";
name: string;
operationKind: string;
pathName: string;
items: GrlOperationItem[];
}
export interface GrlProcedureDeclaration extends GrlAstNode {
kind: "ProcedureDeclaration";
name: string;
params: GrlToken[];
bodyTokens: GrlToken[];
}
export interface GrlFunctionDeclaration extends GrlAstNode {
kind: "FunctionDeclaration";
returnType: string;
name: string;
params: GrlToken[];
bodyTokens: GrlToken[];
}
export interface GrlRawTopLevelDeclaration extends GrlAstNode {
kind: "RawTopLevelDeclaration";
declarationType: string;
tokens: GrlToken[];
}
export type GrlTopLevelDeclaration =
| GrlImportDeclaration
| GrlDataDeclaration
| GrlTargetDeclaration
| GrlPathDeclaration
| GrlOperationDeclaration
| GrlProcedureDeclaration
| GrlFunctionDeclaration
| GrlRawTopLevelDeclaration;
export interface GrlModuleDeclaration extends GrlAstNode {
kind: "ModuleDeclaration";
name: string;
declarations: GrlTopLevelDeclaration[];
}
export interface GrlProgram extends GrlAstNode {
kind: "Program";
language?: GrlLanguageDeclaration;
module: GrlModuleDeclaration;
}
export interface GrlIdentifierExpression extends GrlAstNode {
kind: "IdentifierExpression";
name: string;
}
export interface GrlNumberLiteral extends GrlAstNode {
kind: "NumberLiteral";
value: number;
raw: string;
unit?: {
raw: string;
kind: string;
siUnit: string;
normalizedValue: number;
};
}
export interface GrlStringLiteral extends GrlAstNode {
kind: "StringLiteral";
value: string;
}
export interface GrlBooleanLiteral extends GrlAstNode {
kind: "BooleanLiteral";
value: boolean;
}
export interface GrlArrayExpression extends GrlAstNode {
kind: "ArrayExpression";
elements: GrlExpression[];
}
export interface GrlCallExpression extends GrlAstNode {
kind: "CallExpression";
callee: string;
args: GrlExpression[];
}
export interface GrlObjectProperty {
key: string;
value: GrlExpression;
range: GrlSourceRange;
}
export interface GrlObjectExpression extends GrlAstNode {
kind: "ObjectExpression";
typeName: string;
properties: GrlObjectProperty[];
}
export interface GrlOffsetAxis {
axis: "x" | "y" | "z";
value: GrlNumberLiteral;
}
export interface GrlOffsetExpression extends GrlAstNode {
kind: "OffsetExpression";
base: GrlExpression;
mode: "frame" | "tool";
frameName?: string;
axes: GrlOffsetAxis[];
}
export type GrlExpression =
| GrlIdentifierExpression
| GrlNumberLiteral
| GrlStringLiteral
| GrlBooleanLiteral
| GrlArrayExpression
| GrlCallExpression
| GrlObjectExpression
| GrlOffsetExpression;

View File

@@ -0,0 +1,37 @@
export type {
GrlAstNode,
GrlAstNodeKind,
GrlArrayExpression,
GrlBooleanLiteral,
GrlCallExpression,
GrlDataDeclaration,
GrlDeclarationStorage,
GrlExpression,
GrlFunctionDeclaration,
GrlIdentifierExpression,
GrlImportDeclaration,
GrlLanguageDeclaration,
GrlModuleDeclaration,
GrlNumberLiteral,
GrlObjectExpression,
GrlObjectProperty,
GrlOffsetAxis,
GrlOffsetExpression,
GrlOperationActionBlock,
GrlOperationDeclaration,
GrlOperationItem,
GrlOperationProcessBlock,
GrlPathDeclaration,
GrlPathDefaultsBlock,
GrlPathEvent,
GrlPathItem,
GrlPathPoint,
GrlPathProperty,
GrlPathSourceBlock,
GrlProcedureDeclaration,
GrlProgram,
GrlRawTopLevelDeclaration,
GrlStringLiteral,
GrlTargetDeclaration,
GrlTopLevelDeclaration
} from "./ast.js";

View File

@@ -0,0 +1,210 @@
export type GrlGeneratorStyle = "expanded" | "compact";
export interface GeneratedTargetSpec {
name: string;
kind: "joint" | "pose";
values: number[];
}
export interface GeneratedPathPointSpec {
id?: string;
motion: "movej" | "movel" | "movec";
target: string;
via?: string;
speed?: string;
zone?: string;
}
export interface GeneratedPathSpec {
name: string;
source?: Record<string, string | number | boolean>;
defaults: {
speed: string;
zone: string;
};
points: GeneratedPathPointSpec[];
}
export interface GeneratedOperationSpec {
name: string;
kind: string;
path: string;
startAction?: string;
endAction?: string;
}
export interface GrlProgramGenerationSpec {
moduleName: string;
speeds?: Record<string, string>;
zones?: Record<string, string>;
targets: GeneratedTargetSpec[];
path: GeneratedPathSpec;
operation: GeneratedOperationSpec;
}
export interface GeneratedGrlProgram {
text: string;
stableIds: {
targets: string[];
points: string[];
path: string;
operation: string;
};
}
export function generateGrlProgram(spec: GrlProgramGenerationSpec, style: GrlGeneratorStyle = "expanded"): GeneratedGrlProgram {
const normalized = normalizeSpec(spec);
const text = style === "compact" ? renderCompact(normalized) : renderExpanded(normalized);
return {
text,
stableIds: {
targets: normalized.targets.map((target) => target.name),
points: normalized.path.points.map((point) => point.id!),
path: normalized.path.name,
operation: normalized.operation.name
}
};
}
function normalizeSpec(spec: GrlProgramGenerationSpec): GrlProgramGenerationSpec {
return {
...spec,
speeds: sortRecord(spec.speeds ?? {}),
zones: sortRecord(spec.zones ?? {}),
targets: [...spec.targets].sort((left, right) => left.name.localeCompare(right.name)),
path: {
...spec.path,
...(spec.path.source ? { source: sortRecord(spec.path.source) } : {}),
points: spec.path.points.map((point, index) => ({
...point,
id: point.id ?? `p${String(index).padStart(2, "0")}`
}))
}
};
}
function renderExpanded(spec: GrlProgramGenerationSpec): string {
const lines: string[] = [`language grl 0.1`, `module ${spec.moduleName}`];
for (const [name, expression] of Object.entries(spec.speeds ?? {})) {
lines.push(` const speed ${name} = ${expression}`);
}
for (const [name, expression] of Object.entries(spec.zones ?? {})) {
lines.push(` const zone ${name} = ${expression}`);
}
for (const target of spec.targets) {
lines.push(...renderTargetExpanded(target));
}
lines.push(` path ${spec.path.name} {`);
if (spec.path.source && Object.keys(spec.path.source).length > 0) {
lines.push(` source {`);
for (const [key, value] of Object.entries(spec.path.source)) {
lines.push(` ${key}: ${formatSourceLiteral(key, value)}`);
}
lines.push(` }`);
}
lines.push(` defaults {`);
lines.push(` speed: ${spec.path.defaults.speed}`);
lines.push(` zone: ${spec.path.defaults.zone}`);
lines.push(` }`);
for (const point of spec.path.points) {
lines.push(` ${renderPoint(point)}`);
}
lines.push(` }`);
lines.push(...renderOperationExpanded(spec.operation));
lines.push(` proc main()`);
lines.push(` run_operation ${spec.operation.name}`);
lines.push(` end`);
lines.push(`end`);
return lines.join("\n");
}
function renderCompact(spec: GrlProgramGenerationSpec): string {
const lines: string[] = [`language grl 0.1`, `module ${spec.moduleName}`];
for (const [name, expression] of Object.entries(spec.speeds ?? {})) {
lines.push(` const speed ${name} = ${expression}`);
}
for (const [name, expression] of Object.entries(spec.zones ?? {})) {
lines.push(` const zone ${name} = ${expression}`);
}
for (const target of spec.targets) {
lines.push(` ${renderTargetCompact(target)}`);
}
const source = spec.path.source && Object.keys(spec.path.source).length > 0
? ` source { ${Object.entries(spec.path.source).map(([key, value]) => `${key}: ${formatSourceLiteral(key, value)}`).join(" ")} }`
: "";
lines.push(` path ${spec.path.name} {${source} defaults { speed: ${spec.path.defaults.speed} zone: ${spec.path.defaults.zone} } ${spec.path.points.map(renderPoint).join(" ")} }`);
lines.push(` operation ${spec.operation.name} { kind: ${spec.operation.kind} path: ${spec.operation.path}${spec.operation.startAction ? ` start_action: ${spec.operation.startAction}` : ""}${spec.operation.endAction ? ` end_action: ${spec.operation.endAction}` : ""} }`);
lines.push(` proc main()`);
lines.push(` run_operation ${spec.operation.name}`);
lines.push(` end`);
lines.push(`end`);
return lines.join("\n");
}
function renderTargetExpanded(target: GeneratedTargetSpec): string[] {
if (target.kind === "joint") {
return [
` target ${target.name} = joint_target {`,
` joints: [${target.values.map((value) => `${value} deg`).join(", ")}]`,
` }`
];
}
return [
` target ${target.name} = pose_target {`,
` pose: pose(${target.values.map((value, index) => `${value} ${index < 3 ? "mm" : "deg"}`).join(", ")})`,
` }`
];
}
function renderTargetCompact(target: GeneratedTargetSpec): string {
if (target.kind === "joint") {
return `target ${target.name} = joint_target { joints: [${target.values.map((value) => `${value} deg`).join(", ")}] }`;
}
return `target ${target.name} = pose_target { pose: pose(${target.values.map((value, index) => `${value} ${index < 3 ? "mm" : "deg"}`).join(", ")}) }`;
}
function renderPoint(point: GeneratedPathPointSpec): string {
const params = [
`point ${point.id} ${point.motion}`,
point.motion === "movec" ? `via ${point.via}` : undefined,
point.motion === "movec" ? `target ${point.target}` : point.target,
point.speed ? `speed ${point.speed}` : undefined,
point.zone ? `zone ${point.zone}` : undefined
].filter(Boolean);
return params.join(" ");
}
function renderOperationExpanded(operation: GeneratedOperationSpec): string[] {
const lines = [` operation ${operation.name} {`, ` kind: ${operation.kind}`, ` path: ${operation.path}`];
if (operation.startAction) {
lines.push(` start_action:`);
lines.push(` ${operation.startAction}`);
}
if (operation.endAction) {
lines.push(` end_action:`);
lines.push(` ${operation.endAction}`);
}
lines.push(` }`);
return lines;
}
function sortRecord<T>(record: Record<string, T>): Record<string, T> {
return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
}
function formatLiteral(value: string | number | boolean): string {
if (typeof value === "string") {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) ? value : JSON.stringify(value);
}
return String(value);
}
function formatSourceLiteral(key: string, value: string | number | boolean): string {
if (typeof value !== "string") {
return String(value);
}
if (key === "type" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
return value;
}
return JSON.stringify(value);
}

View File

@@ -0,0 +1,10 @@
export {
generateGrlProgram,
type GeneratedGrlProgram,
type GeneratedOperationSpec,
type GeneratedPathPointSpec,
type GeneratedPathSpec,
type GeneratedTargetSpec,
type GrlGeneratorStyle,
type GrlProgramGenerationSpec
} from "./generator.js";

View File

@@ -0,0 +1,61 @@
export type {
CompiledMotionRequest,
CompiledOperation,
CompiledPath,
AlarmInstruction,
BreakInstruction,
CallInstruction,
CatchInstruction,
ContinueInstruction,
ControlExpression,
ControlFlowInstruction,
ExceptionFlowInstruction,
ExceptionInstruction,
ExecutableBranch,
ExecutableControlInstruction,
ExecutableForInstruction,
ExecutableIfInstruction,
ExecutableInstruction,
ExecutableProcedure,
ExecutableSwitchCase,
ExecutableSwitchInstruction,
ExecutableWhileInstruction,
FinallyInstruction,
ForInstruction,
FunctionSignature,
IfBranch,
IfInstruction,
IoDomain,
IoFlowInstruction,
IoReference,
IoWriteInstruction,
JumpInstruction,
KdlBridgeRequests,
LabelInstruction,
MotionInstruction,
MotionKind,
OperationActionInstruction,
OperationExecutionStep,
PathEventInstruction,
ProcedureFlowInstruction,
ProcedureSignature,
ProcFunctionAnalysis,
PulseInstruction,
RaiseInstruction,
RawProcedureStatement,
ReturnInstruction,
RoutineParameter,
RoutineParameterDirection,
SemanticProgramIr,
SemanticSourceMapEntry,
SemanticSymbol,
SemanticSymbolKind,
TryInstruction,
UnsupportedRuntimeInstruction,
RunOperationInstruction,
RunPathInstruction,
SwitchCaseInstruction,
SwitchInstruction,
WhileInstruction,
WaitInstruction
} from "./motion.js";

View File

@@ -0,0 +1,428 @@
import type {
JointTarget,
MoveCRequest,
MoveJRequest,
MoveLRequest,
MotionDiagnostic,
MotionSourceMap,
PathEventRequest,
PathPlanRequest,
Pose,
PoseTarget,
SpeedSpec,
ZoneSpec
} from "../../kdl/types.js";
export type MotionKind = "MOVEJ" | "MOVEL" | "MOVEC";
export interface MotionInstruction {
id?: string;
kind: MotionKind;
target?: JointTarget | PoseTarget;
via?: PoseTarget;
speed: SpeedSpec;
zone: ZoneSpec;
tool?: Pose;
frame?: Pose;
sourceMap?: MotionSourceMap;
pathId?: string;
pointId?: string;
source?: Record<string, unknown>;
}
export type CompiledMotionRequest = MoveJRequest | MoveLRequest | MoveCRequest;
export interface PathEventInstruction {
id?: string;
timing: PathEventRequest["timing"];
pointId: string;
distance?: number;
kind: string;
sourceMap?: MotionSourceMap;
data?: Record<string, unknown>;
}
export interface CompiledPath {
pathId: string;
request: PathPlanRequest;
motions: MotionInstruction[];
events: PathEventInstruction[];
}
export interface RunPathInstruction {
kind: "RUN_PATH";
pathId: string;
sourceMap?: MotionSourceMap;
}
export interface OperationActionInstruction {
kind: "ACTION";
actionKind: "start_action" | "end_action";
operationId: string;
statement: string;
tokens?: unknown[];
sourceMap?: MotionSourceMap;
}
export interface CompiledOperation {
operationId: string;
kind: string;
pathId: string;
process: Record<string, unknown>;
startActions: OperationActionInstruction[];
endActions: OperationActionInstruction[];
}
export interface RunOperationInstruction {
kind: "RUN_OPERATION";
operationId: string;
sourceMap?: MotionSourceMap;
}
export type OperationExecutionStep =
| OperationActionInstruction
| RunPathInstruction;
export type IoDomain = "di" | "do" | "ai" | "ao" | "gi" | "go" | "ri" | "ro" | "alias";
export interface IoReference {
domain: IoDomain;
index?: number;
alias?: string;
raw: string;
}
export interface IoWriteInstruction {
kind: "IO_WRITE";
target: IoReference;
value: boolean | number | string;
sourceMap?: MotionSourceMap;
}
export interface WaitInstruction {
kind: "WAIT";
condition: string;
timeout?: number;
onTimeout?: {
kind: "alarm" | "call";
value: string;
};
sourceMap?: MotionSourceMap;
}
export interface PulseInstruction {
kind: "PULSE";
target: IoReference;
duration: number;
trace: Array<{
time: number;
action: "set" | "reset";
target: IoReference;
value: boolean;
}>;
sourceMap?: MotionSourceMap;
}
export type IoFlowInstruction = IoWriteInstruction | WaitInstruction | PulseInstruction;
export interface ControlExpression {
text: string;
tokens?: unknown[];
sourceMap?: MotionSourceMap;
}
export interface RawProcedureStatement {
kind: "RAW_STATEMENT";
text: string;
tokens?: unknown[];
sourceMap?: MotionSourceMap;
}
export interface IfBranch {
branchKind: "if" | "elseif" | "else";
condition?: ControlExpression;
body: ProcedureFlowInstruction[];
sourceMap?: MotionSourceMap;
}
export interface IfInstruction {
kind: "IF";
branches: IfBranch[];
sourceMap?: MotionSourceMap;
}
export interface WhileInstruction {
kind: "WHILE";
condition: ControlExpression;
body: ProcedureFlowInstruction[];
sourceMap?: MotionSourceMap;
}
export interface ForInstruction {
kind: "FOR";
iterator: string;
from: ControlExpression;
to: ControlExpression;
step?: ControlExpression;
body: ProcedureFlowInstruction[];
sourceMap?: MotionSourceMap;
}
export interface SwitchCaseInstruction {
caseKind: "case" | "default";
value?: string | number | boolean;
raw?: string;
body: ProcedureFlowInstruction[];
sourceMap?: MotionSourceMap;
}
export interface SwitchInstruction {
kind: "SWITCH";
expression: ControlExpression;
cases: SwitchCaseInstruction[];
sourceMap?: MotionSourceMap;
}
export interface BreakInstruction {
kind: "BREAK";
sourceMap?: MotionSourceMap;
}
export interface ContinueInstruction {
kind: "CONTINUE";
sourceMap?: MotionSourceMap;
}
export interface LabelInstruction {
kind: "LABEL";
name: string;
scopePath: string[];
sourceMap?: MotionSourceMap;
}
export interface JumpInstruction {
kind: "JUMP";
label: string;
scopePath: string[];
sourceMap?: MotionSourceMap;
}
export type ControlFlowInstruction =
| IfInstruction
| WhileInstruction
| ForInstruction
| SwitchInstruction
| BreakInstruction
| ContinueInstruction
| LabelInstruction
| JumpInstruction;
export type ProcedureFlowInstruction = ControlFlowInstruction | RawProcedureStatement;
export type RoutineParameterDirection = "in" | "out" | "inout";
export interface RoutineParameter {
name: string;
typeName: string;
direction: RoutineParameterDirection;
sourceMap?: MotionSourceMap;
}
export interface ProcedureSignature {
kind: "PROC_SIGNATURE";
name: string;
parameters: RoutineParameter[];
sourceMap?: MotionSourceMap;
}
export interface FunctionSignature {
kind: "FUNC_SIGNATURE";
name: string;
returnType: string;
parameters: RoutineParameter[];
sourceMap?: MotionSourceMap;
}
export interface CallInstruction {
kind: "CALL";
target: string;
args: ControlExpression[];
sourceMap?: MotionSourceMap;
}
export interface ReturnInstruction {
kind: "RETURN";
value?: ControlExpression;
sourceMap?: MotionSourceMap;
}
export interface ProcFunctionAnalysis {
procedures: ProcedureSignature[];
functions: FunctionSignature[];
calls: CallInstruction[];
returns: ReturnInstruction[];
diagnostics: MotionDiagnostic[];
}
export interface AlarmInstruction {
kind: "ALARM";
alarmId: string;
message?: string;
severity?: string;
sourceMap?: MotionSourceMap;
}
export interface RaiseInstruction {
kind: "RAISE";
alarmId: string;
sourceMap?: MotionSourceMap;
}
export interface CatchInstruction {
alarmId?: string;
body: ExceptionFlowInstruction[];
sourceMap?: MotionSourceMap;
}
export interface FinallyInstruction {
body: ExceptionFlowInstruction[];
sourceMap?: MotionSourceMap;
}
export interface TryInstruction {
kind: "TRY";
body: ExceptionFlowInstruction[];
catches: CatchInstruction[];
finally?: FinallyInstruction;
sourceMap?: MotionSourceMap;
}
export interface UnsupportedRuntimeInstruction {
kind: "UNSUPPORTED_RUNTIME";
feature: "trap" | "interrupt" | "task";
message: string;
sourceMap?: MotionSourceMap;
}
export type ExceptionInstruction =
| AlarmInstruction
| RaiseInstruction
| TryInstruction
| UnsupportedRuntimeInstruction;
export type ExceptionFlowInstruction = ExceptionInstruction | RawProcedureStatement;
export type SemanticSymbolKind =
| "data"
| "target"
| "path"
| "operation"
| "procedure"
| "function"
| "raw";
export interface SemanticSymbol {
kind: SemanticSymbolKind;
name: string;
typeName?: string;
sourceMap?: MotionSourceMap;
}
export interface SemanticSourceMapEntry {
kind: string;
id: string;
sourceMap: MotionSourceMap;
pathId?: string;
pointId?: string;
operationId?: string;
procedureId?: string;
}
export interface ExecutableBranch {
branchKind: "if" | "elseif" | "else";
condition?: ControlExpression;
body: ExecutableInstruction[];
sourceMap?: MotionSourceMap;
}
export interface ExecutableIfInstruction {
kind: "EXEC_IF";
branches: ExecutableBranch[];
sourceMap?: MotionSourceMap;
}
export interface ExecutableWhileInstruction {
kind: "EXEC_WHILE";
condition: ControlExpression;
body: ExecutableInstruction[];
sourceMap?: MotionSourceMap;
}
export interface ExecutableForInstruction {
kind: "EXEC_FOR";
iterator: string;
from: ControlExpression;
to: ControlExpression;
step?: ControlExpression;
body: ExecutableInstruction[];
sourceMap?: MotionSourceMap;
}
export interface ExecutableSwitchCase {
caseKind: "case" | "default";
value?: string | number | boolean;
raw?: string;
body: ExecutableInstruction[];
sourceMap?: MotionSourceMap;
}
export interface ExecutableSwitchInstruction {
kind: "EXEC_SWITCH";
expression: ControlExpression;
cases: ExecutableSwitchCase[];
sourceMap?: MotionSourceMap;
}
export type ExecutableControlInstruction =
| ExecutableIfInstruction
| ExecutableWhileInstruction
| ExecutableForInstruction
| ExecutableSwitchInstruction;
export type ExecutableInstruction =
| MotionInstruction
| IoFlowInstruction
| RunPathInstruction
| RunOperationInstruction
| CallInstruction
| ReturnInstruction
| BreakInstruction
| ContinueInstruction
| AlarmInstruction
| RaiseInstruction
| UnsupportedRuntimeInstruction
| ExecutableControlInstruction
| RawProcedureStatement;
export interface ExecutableProcedure {
name: string;
instructions: ExecutableInstruction[];
sourceMap?: MotionSourceMap;
}
export interface KdlBridgeRequests {
motionRequests: CompiledMotionRequest[];
pathRequests: PathPlanRequest[];
}
export interface SemanticProgramIr {
moduleName: string;
symbols: SemanticSymbol[];
semanticChecks: string[];
procedures: ExecutableProcedure[];
paths: CompiledPath[];
operations: CompiledOperation[];
diagnostics: MotionDiagnostic[];
sourceMap: SemanticSourceMapEntry[];
kdlBridge: KdlBridgeRequests;
}

View File

@@ -0,0 +1,15 @@
export { GRL_KEYWORDS, isGrlKeyword, type GrlKeyword } from "./keywords.js";
export { lexGrl, type GrlLexerOptions } from "./lexer.js";
export { isGrlUnitLiteral, normalizeUnitLiteral, normalizeUnitValue, type UnitKind } from "./units.js";
export type {
GrlCommentToken,
GrlEofToken,
GrlIdentifierToken,
GrlKeywordToken,
GrlNumberToken,
GrlSourcePosition,
GrlSourceRange,
GrlStringToken,
GrlToken,
GrlTokenKind
} from "./tokens.js";

View File

@@ -0,0 +1,95 @@
export const GRL_KEYWORDS = [
"language",
"module",
"import",
"end",
"persistent",
"const",
"var",
"robot",
"tool",
"frame",
"load",
"target",
"speed",
"zone",
"path",
"operation",
"process",
"proc",
"func",
"return",
"call",
"if",
"elseif",
"else",
"switch",
"case",
"default",
"while",
"for",
"to",
"step",
"break",
"continue",
"label",
"jump",
"movej",
"movel",
"movec",
"run_path",
"run_operation",
"set_tool",
"set_frame",
"set_speed",
"set_zone",
"wait",
"pulse",
"timer",
"io",
"true",
"false",
"all",
"any",
"rising",
"falling",
"changed",
"trap",
"interrupt",
"enable",
"disable",
"raise",
"alarm",
"try",
"catch",
"finally",
"task",
"sync",
"post_hint",
"source",
"defaults",
"point",
"event",
"before",
"after",
"at",
"joint_target",
"pose_target",
"pose",
"poseq",
"joints",
"robot_config",
"ext_axis",
"fine",
"continuous",
"cnt",
"z"
] as const;
export type GrlKeyword = (typeof GRL_KEYWORDS)[number];
const KEYWORD_SET = new Set<string>(GRL_KEYWORDS);
export function isGrlKeyword(value: string): value is GrlKeyword {
return KEYWORD_SET.has(value);
}

View File

@@ -0,0 +1,429 @@
import { isGrlKeyword } from "./keywords.js";
import { isGrlUnitLiteral, normalizeUnitLiteral } from "./units.js";
import type {
GrlCommentToken,
GrlEofToken,
GrlIdentifierToken,
GrlKeywordToken,
GrlNumberToken,
GrlOperatorToken,
GrlPunctuationToken,
GrlSourcePosition,
GrlToken
} from "./tokens.js";
export interface GrlLexerOptions {
preserveComments?: boolean;
}
interface ScannerState {
index: number;
line: number;
column: number;
}
const TWO_CHAR_OPERATORS = new Set(["==", "!=", "<=", ">=", "&&", "||", "->", ":="]);
const PUNCTUATION = new Set(["(", ")", "{", "}", "[", "]", ",", ":", ";", "."]);
const OPERATORS = new Set(["+", "-", "*", "/", "=", "<", ">", "!"]);
export function lexGrl(source: string, options: GrlLexerOptions = {}): GrlToken[] {
const scanner = new GrlScanner(source, options);
return scanner.scanTokens();
}
class GrlScanner {
private index = 0;
private line = 1;
private column = 1;
private readonly tokens: GrlToken[] = [];
private readonly preserveComments: boolean;
constructor(
private readonly source: string,
options: GrlLexerOptions
) {
this.preserveComments = options.preserveComments ?? true;
}
scanTokens(): GrlToken[] {
while (!this.isAtEnd()) {
const char = this.peek();
if (this.isWhitespace(char)) {
this.advance();
continue;
}
if (char === "/" && this.peek(1) === "/") {
this.scanLineComment();
continue;
}
if (char === "/" && this.peek(1) === "*") {
this.scanBlockComment();
continue;
}
if (char === "\"") {
this.scanString();
continue;
}
if (this.isIdentifierStart(char)) {
this.scanIdentifierOrKeyword();
continue;
}
if (this.isNumberStart(char)) {
this.scanNumber();
continue;
}
this.scanPunctuationOrOperator();
}
const start = this.position();
const token: GrlEofToken = {
kind: "eof",
raw: "",
value: "",
range: { start, end: start }
};
this.tokens.push(token);
return this.tokens;
}
private scanLineComment(): void {
const start = this.position();
this.advance();
this.advance();
const contentStart = this.index;
while (!this.isAtEnd() && this.peek() !== "\n") {
this.advance();
}
if (this.preserveComments) {
const value = this.source.slice(contentStart, this.index);
const token: GrlCommentToken = {
kind: "comment",
style: "line",
raw: this.source.slice(start.offset, this.index),
value,
range: { start, end: this.position() }
};
this.tokens.push(token);
}
}
private scanBlockComment(): void {
const start = this.position();
this.advance();
this.advance();
const contentStart = this.index;
while (!this.isAtEnd()) {
if (this.peek() === "*" && this.peek(1) === "/") {
const value = this.source.slice(contentStart, this.index);
this.advance();
this.advance();
if (this.preserveComments) {
const token: GrlCommentToken = {
kind: "comment",
style: "block",
raw: this.source.slice(start.offset, this.index),
value,
range: { start, end: this.position() }
};
this.tokens.push(token);
}
return;
}
this.advance();
}
throw this.error(start, "Unterminated block comment");
}
private scanString(): void {
const start = this.position();
this.advance();
let value = "";
while (!this.isAtEnd()) {
const char = this.peek();
if (char === "\"") {
this.advance();
const token: GrlToken = {
kind: "string",
raw: this.source.slice(start.offset, this.index),
value,
range: { start, end: this.position() }
};
this.tokens.push(token);
return;
}
if (char === "\\") {
this.advance();
value += this.readEscapedCharacter(start);
continue;
}
value += this.advance();
}
throw this.error(start, "Unterminated string literal");
}
private readEscapedCharacter(start: GrlSourcePosition): string {
if (this.isAtEnd()) {
throw this.error(start, "Unterminated string escape");
}
const escaped = this.advance();
switch (escaped) {
case "n":
return "\n";
case "r":
return "\r";
case "t":
return "\t";
case "\\":
case "\"":
return escaped;
default:
return escaped;
}
}
private scanIdentifierOrKeyword(): void {
const start = this.position();
while (!this.isAtEnd() && this.isIdentifierPart(this.peek())) {
this.advance();
}
const raw = this.source.slice(start.offset, this.index);
if (isGrlKeyword(raw)) {
const token: GrlKeywordToken = {
kind: "keyword",
raw,
value: raw,
range: { start, end: this.position() }
};
this.tokens.push(token);
return;
}
const token: GrlIdentifierToken = {
kind: "identifier",
raw,
value: raw,
range: { start, end: this.position() }
};
this.tokens.push(token);
}
private scanNumber(): void {
const start = this.position();
if (this.peek() === ".") {
this.advance();
}
while (!this.isAtEnd() && this.isDigit(this.peek())) {
this.advance();
}
if (this.peek() === "." && this.isDigit(this.peek(1))) {
this.advance();
while (!this.isAtEnd() && this.isDigit(this.peek())) {
this.advance();
}
}
if ((this.peek() === "e" || this.peek() === "E") && this.isExponentStart()) {
this.advance();
if (this.peek() === "+" || this.peek() === "-") {
this.advance();
}
while (!this.isAtEnd() && this.isDigit(this.peek())) {
this.advance();
}
}
const numericEnd = this.position();
const numericRaw = this.source.slice(start.offset, numericEnd.offset);
const value = Number(numericRaw);
if (!Number.isFinite(value)) {
throw this.error(start, `Invalid number literal: ${numericRaw}`);
}
const unit = this.tryScanUnitAfterNumber();
const token: GrlNumberToken = {
kind: "number",
raw: this.source.slice(start.offset, this.index),
value,
range: { start, end: this.position() },
...(unit
? {
unit: {
raw: unit.literal,
kind: unit.kind,
siUnit: unit.siUnit,
normalizedValue: value * unit.factor
}
}
: {})
};
this.tokens.push(token);
}
private tryScanUnitAfterNumber(): ReturnType<typeof normalizeUnitLiteral> | undefined {
const state = this.save();
while (!this.isAtEnd() && (this.peek() === " " || this.peek() === "\t")) {
this.advance();
}
const unitStart = this.index;
if (this.peek() === "%") {
this.advance();
} else {
while (!this.isAtEnd() && /[A-Za-z0-9/^]/.test(this.peek())) {
this.advance();
}
}
const literal = this.source.slice(unitStart, this.index);
if (!literal || !isGrlUnitLiteral(literal)) {
this.restore(state);
return undefined;
}
return normalizeUnitLiteral(literal);
}
private scanPunctuationOrOperator(): void {
const start = this.position();
const two = `${this.peek()}${this.peek(1)}`;
if (TWO_CHAR_OPERATORS.has(two)) {
this.advance();
this.advance();
const token: GrlOperatorToken = {
kind: "operator",
raw: two,
value: two,
range: { start, end: this.position() }
};
this.tokens.push(token);
return;
}
const char = this.advance();
if (PUNCTUATION.has(char)) {
const token: GrlPunctuationToken = {
kind: "punctuation",
raw: char,
value: char,
range: { start, end: this.position() }
};
this.tokens.push(token);
return;
}
if (OPERATORS.has(char)) {
const token: GrlOperatorToken = {
kind: "operator",
raw: char,
value: char,
range: { start, end: this.position() }
};
this.tokens.push(token);
return;
}
throw this.error(start, `Unexpected character: ${char}`);
}
private isExponentStart(): boolean {
const next = this.peek(1);
if (this.isDigit(next)) {
return true;
}
return (next === "+" || next === "-") && this.isDigit(this.peek(2));
}
private isNumberStart(char: string): boolean {
return this.isDigit(char) || (char === "." && this.isDigit(this.peek(1)));
}
private isIdentifierStart(char: string): boolean {
return /[A-Za-z_]/.test(char);
}
private isIdentifierPart(char: string): boolean {
return /[A-Za-z0-9_]/.test(char);
}
private isDigit(char: string): boolean {
return /[0-9]/.test(char);
}
private isWhitespace(char: string): boolean {
return char === " " || char === "\t" || char === "\r" || char === "\n";
}
private advance(): string {
const char = this.source[this.index] ?? "";
this.index += 1;
if (char === "\n") {
this.line += 1;
this.column = 1;
} else {
this.column += 1;
}
return char;
}
private peek(distance = 0): string {
return this.source[this.index + distance] ?? "";
}
private isAtEnd(): boolean {
return this.index >= this.source.length;
}
private position(): GrlSourcePosition {
return {
offset: this.index,
line: this.line,
column: this.column
};
}
private save(): ScannerState {
return {
index: this.index,
line: this.line,
column: this.column
};
}
private restore(state: ScannerState): void {
this.index = state.index;
this.line = state.line;
this.column = state.column;
}
private error(position: GrlSourcePosition, message: string): Error {
return new Error(`${message} at ${position.line}:${position.column}`);
}
}

View File

@@ -0,0 +1,86 @@
import type { GrlKeyword } from "./keywords.js";
import type { UnitKind } from "./units.js";
export type GrlTokenKind =
| "keyword"
| "identifier"
| "number"
| "string"
| "comment"
| "punctuation"
| "operator"
| "eof";
export interface GrlSourcePosition {
offset: number;
line: number;
column: number;
}
export interface GrlSourceRange {
start: GrlSourcePosition;
end: GrlSourcePosition;
}
export interface GrlBaseToken {
kind: GrlTokenKind;
raw: string;
range: GrlSourceRange;
}
export interface GrlKeywordToken extends GrlBaseToken {
kind: "keyword";
value: GrlKeyword;
}
export interface GrlIdentifierToken extends GrlBaseToken {
kind: "identifier";
value: string;
}
export interface GrlNumberToken extends GrlBaseToken {
kind: "number";
value: number;
unit?: {
raw: string;
kind: UnitKind;
siUnit: string;
normalizedValue: number;
};
}
export interface GrlStringToken extends GrlBaseToken {
kind: "string";
value: string;
}
export interface GrlCommentToken extends GrlBaseToken {
kind: "comment";
style: "line" | "block";
value: string;
}
export interface GrlPunctuationToken extends GrlBaseToken {
kind: "punctuation";
value: string;
}
export interface GrlOperatorToken extends GrlBaseToken {
kind: "operator";
value: string;
}
export interface GrlEofToken extends GrlBaseToken {
kind: "eof";
value: "";
}
export type GrlToken =
| GrlKeywordToken
| GrlIdentifierToken
| GrlNumberToken
| GrlStringToken
| GrlCommentToken
| GrlPunctuationToken
| GrlOperatorToken
| GrlEofToken;

View File

@@ -0,0 +1,59 @@
export type UnitKind =
| "length"
| "angle"
| "time"
| "mass"
| "linear_velocity"
| "angular_velocity"
| "linear_acceleration"
| "angular_acceleration"
| "percent";
export interface UnitDefinition {
literal: string;
kind: UnitKind;
siUnit: string;
factor: number;
}
const UNIT_DEFINITIONS: UnitDefinition[] = [
{ literal: "m", kind: "length", siUnit: "m", factor: 1 },
{ literal: "mm", kind: "length", siUnit: "m", factor: 0.001 },
{ literal: "rad", kind: "angle", siUnit: "rad", factor: 1 },
{ literal: "deg", kind: "angle", siUnit: "rad", factor: Math.PI / 180 },
{ literal: "s", kind: "time", siUnit: "s", factor: 1 },
{ literal: "ms", kind: "time", siUnit: "s", factor: 0.001 },
{ literal: "kg", kind: "mass", siUnit: "kg", factor: 1 },
{ literal: "m/s", kind: "linear_velocity", siUnit: "m/s", factor: 1 },
{ literal: "mm/s", kind: "linear_velocity", siUnit: "m/s", factor: 0.001 },
{ literal: "rad/s", kind: "angular_velocity", siUnit: "rad/s", factor: 1 },
{ literal: "deg/s", kind: "angular_velocity", siUnit: "rad/s", factor: Math.PI / 180 },
{ literal: "m/s2", kind: "linear_acceleration", siUnit: "m/s2", factor: 1 },
{ literal: "m/s^2", kind: "linear_acceleration", siUnit: "m/s2", factor: 1 },
{ literal: "mm/s2", kind: "linear_acceleration", siUnit: "m/s2", factor: 0.001 },
{ literal: "mm/s^2", kind: "linear_acceleration", siUnit: "m/s2", factor: 0.001 },
{ literal: "rad/s2", kind: "angular_acceleration", siUnit: "rad/s2", factor: 1 },
{ literal: "rad/s^2", kind: "angular_acceleration", siUnit: "rad/s2", factor: 1 },
{ literal: "deg/s2", kind: "angular_acceleration", siUnit: "rad/s2", factor: Math.PI / 180 },
{ literal: "deg/s^2", kind: "angular_acceleration", siUnit: "rad/s2", factor: Math.PI / 180 },
{ literal: "%", kind: "percent", siUnit: "ratio", factor: 0.01 }
];
const UNITS = new Map(UNIT_DEFINITIONS.map((definition) => [definition.literal, definition]));
export function normalizeUnitLiteral(literal: string): UnitDefinition {
const definition = UNITS.get(literal);
if (!definition) {
throw new Error(`Unknown GRL unit: ${literal}`);
}
return definition;
}
export function isGrlUnitLiteral(literal: string): boolean {
return UNITS.has(literal);
}
export function normalizeUnitValue(value: number, unitLiteral: string): number {
const unit = normalizeUnitLiteral(unitLiteral);
return value * unit.factor;
}

View File

@@ -0,0 +1,11 @@
import type { GrlToken } from "../lexer/index.js";
export class GrlParseError extends Error {
constructor(
message: string,
readonly token: GrlToken
) {
super(`${message} at ${token.range.start.line}:${token.range.start.column}`);
this.name = "GrlParseError";
}
}

View File

@@ -0,0 +1,351 @@
import type {
GrlArrayExpression,
GrlBooleanLiteral,
GrlCallExpression,
GrlExpression,
GrlIdentifierExpression,
GrlNumberLiteral,
GrlObjectExpression,
GrlObjectProperty,
GrlOffsetAxis,
GrlOffsetExpression,
GrlStringLiteral
} from "../ast/index.js";
import type { GrlToken } from "../lexer/index.js";
import { GrlParseError } from "./errors.js";
export function parseGrlExpression(tokens: GrlToken[]): GrlExpression {
const parser = new GrlExpressionParser(tokens);
return parser.parse();
}
class GrlExpressionParser {
private current = 0;
constructor(private readonly tokens: GrlToken[]) {}
parse(): GrlExpression {
const expression = this.parseOffsetExpression();
if (!this.isAtEnd()) {
throw new GrlParseError("Unexpected token after expression", this.peek());
}
return expression;
}
private parseOffsetExpression(): GrlExpression {
const base = this.parsePrimary();
if (this.matchKeyword("offset")) {
return this.finishOffsetExpression(base, "frame");
}
if (this.matchKeyword("offset_in")) {
if (this.matchKeyword("tool")) {
return this.finishOffsetExpression(base, "tool");
}
this.consumeKeyword("frame", "Expected tool or frame after offset_in");
const frameName = this.consumeIdentifierLike("Expected frame name after offset_in frame");
return this.finishOffsetExpression(base, "frame", frameName.raw);
}
return base;
}
private finishOffsetExpression(
base: GrlExpression,
mode: "frame" | "tool",
frameName?: string
): GrlOffsetExpression {
const axes: GrlOffsetAxis[] = [];
while (!this.isAtEnd()) {
const axis = this.consumeAxis();
const valueExpression = this.parsePrimary();
if (valueExpression.kind !== "NumberLiteral") {
throw new GrlParseError(`Expected length value after offset ${axis}`, valueExpression.range ? this.previous() : this.peek());
}
const value = valueExpression;
axes.push({ axis, value });
}
if (axes.length === 0) {
throw new GrlParseError("Expected at least one offset axis", this.peek());
}
return {
kind: "OffsetExpression",
base,
mode,
...(frameName ? { frameName } : {}),
axes,
range: {
start: base.range.start,
end: axes.at(-1)?.value.range.end ?? base.range.end
}
};
}
private parsePrimary(): GrlExpression {
const token = this.peek();
if (token.kind === "operator" && token.raw === "-") {
return this.parseNegativeNumber();
}
if (token.kind === "number") {
this.advance();
return numberLiteralFromToken(token);
}
if (token.kind === "string") {
this.advance();
const literal: GrlStringLiteral = {
kind: "StringLiteral",
value: token.value,
range: token.range
};
return literal;
}
if (token.kind === "keyword" && (token.raw === "true" || token.raw === "false")) {
this.advance();
const literal: GrlBooleanLiteral = {
kind: "BooleanLiteral",
value: token.raw === "true",
range: token.range
};
return literal;
}
if (token.kind === "punctuation" && token.raw === "[") {
return this.parseArrayExpression();
}
if (token.kind === "punctuation" && token.raw === "(") {
this.advance();
const expression = this.parseOffsetExpression();
this.consumePunctuation(")", "Expected ) after expression");
return expression;
}
if (token.kind === "identifier" || token.kind === "keyword") {
const name = this.advance();
if (this.matchPunctuation("(")) {
return this.finishCallExpression(name);
}
if (this.matchPunctuation("{")) {
return this.finishObjectExpression(name);
}
const expression: GrlIdentifierExpression = {
kind: "IdentifierExpression",
name: name.raw,
range: name.range
};
return expression;
}
throw new GrlParseError("Expected expression", token);
}
private parseArrayExpression(): GrlArrayExpression {
const start = this.consumePunctuation("[", "Expected [");
const elements: GrlExpression[] = [];
while (!this.checkPunctuation("]") && !this.isAtEnd()) {
elements.push(this.parseOffsetExpression());
this.matchPunctuation(",");
}
const end = this.consumePunctuation("]", "Expected ] after array expression");
return {
kind: "ArrayExpression",
elements,
range: {
start: start.range.start,
end: end.range.end
}
};
}
private parseNegativeNumber(): GrlNumberLiteral {
const minus = this.advance();
const number = this.consumeNumberLiteral("Expected number after -");
return {
...number,
value: -number.value,
raw: `${minus.raw}${number.raw}`,
...(number.unit
? {
unit: {
...number.unit,
normalizedValue: -number.unit.normalizedValue
}
}
: {}),
range: {
start: minus.range.start,
end: number.range.end
}
};
}
private finishCallExpression(callee: GrlToken): GrlCallExpression {
const args: GrlExpression[] = [];
while (!this.checkPunctuation(")") && !this.isAtEnd()) {
args.push(this.parseOffsetExpression());
this.matchPunctuation(",");
}
const end = this.consumePunctuation(")", "Expected ) after call expression");
return {
kind: "CallExpression",
callee: callee.raw,
args,
range: {
start: callee.range.start,
end: end.range.end
}
};
}
private finishObjectExpression(typeName: GrlToken): GrlObjectExpression {
const properties: GrlObjectProperty[] = [];
while (!this.checkPunctuation("}") && !this.isAtEnd()) {
const key = this.consumeIdentifierLike("Expected object property name");
this.consumePunctuation(":", "Expected : after object property name");
const value = this.parseOffsetExpression();
properties.push({
key: key.raw,
value,
range: {
start: key.range.start,
end: value.range.end
}
});
this.matchPunctuation(",");
}
const end = this.consumePunctuation("}", "Expected } after object expression");
return {
kind: "ObjectExpression",
typeName: typeName.raw,
properties,
range: {
start: typeName.range.start,
end: end.range.end
}
};
}
private consumeAxis(): "x" | "y" | "z" {
const token = this.consumeIdentifierLike("Expected offset axis");
if (token.raw === "x" || token.raw === "y" || token.raw === "z") {
return token.raw;
}
throw new GrlParseError("Expected offset axis x, y, or z", token);
}
private consumeNumberLiteral(message: string): GrlNumberLiteral {
const token = this.consume("number", message);
if (token.kind !== "number") {
throw new GrlParseError(message, token);
}
return numberLiteralFromToken(token);
}
private consumeIdentifierLike(message: string): GrlToken {
const token = this.peek();
if (token.kind === "identifier" || token.kind === "keyword") {
return this.advance();
}
throw new GrlParseError(message, token);
}
private consume(kind: GrlToken["kind"], message: string): GrlToken {
if (this.check(kind)) {
return this.advance();
}
throw new GrlParseError(message, this.peek());
}
private consumeKeyword(keyword: string, message: string): GrlToken {
if (this.checkKeyword(keyword)) {
return this.advance();
}
throw new GrlParseError(message, this.peek());
}
private consumePunctuation(value: string, message: string): GrlToken {
if (this.checkPunctuation(value)) {
return this.advance();
}
throw new GrlParseError(message, this.peek());
}
private matchKeyword(keyword: string): boolean {
if (this.checkKeyword(keyword)) {
this.advance();
return true;
}
return false;
}
private matchPunctuation(value: string): boolean {
if (this.checkPunctuation(value)) {
this.advance();
return true;
}
return false;
}
private check(kind: GrlToken["kind"]): boolean {
return !this.isAtEnd() && this.peek().kind === kind;
}
private checkKeyword(keyword: string): boolean {
const token = this.peek();
return (token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword;
}
private checkPunctuation(value: string): boolean {
const token = this.peek();
return token.kind === "punctuation" && token.raw === value;
}
private advance(): GrlToken {
if (!this.isAtEnd()) {
this.current += 1;
}
return this.previous();
}
private isAtEnd(): boolean {
return this.current >= this.tokens.length;
}
private peek(): GrlToken {
return this.tokens[this.current] ?? this.tokens[this.tokens.length - 1]!;
}
private previous(): GrlToken {
return this.tokens[this.current - 1] ?? this.tokens[0]!;
}
}
function numberLiteralFromToken(token: GrlToken): GrlNumberLiteral {
if (token.kind !== "number") {
throw new GrlParseError("Expected number literal", token);
}
return {
kind: "NumberLiteral",
value: token.value,
raw: token.raw,
...(token.unit
? {
unit: {
raw: token.unit.raw,
kind: token.unit.kind,
siUnit: token.unit.siUnit,
normalizedValue: token.unit.normalizedValue
}
}
: {}),
range: token.range
};
}

View File

@@ -0,0 +1,3 @@
export { GrlParseError } from "./errors.js";
export { parseGrl } from "./parser.js";
export { parseGrlExpression } from "./expressionParser.js";

View File

@@ -0,0 +1,859 @@
import { lexGrl, type GrlToken } from "../lexer/index.js";
import type {
GrlDataDeclaration,
GrlDeclarationStorage,
GrlFunctionDeclaration,
GrlImportDeclaration,
GrlLanguageDeclaration,
GrlModuleDeclaration,
GrlOperationActionBlock,
GrlOperationDeclaration,
GrlOperationItem,
GrlOperationProcessBlock,
GrlPathDeclaration,
GrlPathDefaultsBlock,
GrlPathEvent,
GrlPathItem,
GrlPathPoint,
GrlPathProperty,
GrlPathSourceBlock,
GrlProcedureDeclaration,
GrlProgram,
GrlRawTopLevelDeclaration,
GrlTargetDeclaration,
GrlTopLevelDeclaration
} from "../ast/index.js";
import { GrlParseError } from "./errors.js";
import { parseGrlExpression } from "./expressionParser.js";
const RAW_TOP_LEVEL_KEYWORDS = new Set([
"trap",
"task",
"post_hint"
]);
export function parseGrl(source: string): GrlProgram {
const tokens = lexGrl(source).filter((token) => token.kind !== "comment");
return new GrlParser(tokens).parseProgram();
}
class GrlParser {
private current = 0;
constructor(private readonly tokens: GrlToken[]) {}
parseProgram(): GrlProgram {
const first = this.peek();
const language = this.matchKeyword("language") ? this.finishLanguageDeclaration(this.previous()) : undefined;
const module = this.parseModuleDeclaration();
const eof = this.consume("eof", "Expected end of file after module declaration");
return {
kind: "Program",
...(language ? { language } : {}),
module,
range: {
start: (language?.range ?? module.range).start,
end: eof.range.end
}
};
}
private finishLanguageDeclaration(languageToken: GrlToken): GrlLanguageDeclaration {
const languageName = this.consumeIdentifierLike("Expected language name after language");
if (languageName.raw !== "grl") {
throw new GrlParseError("Only language grl is supported", languageName);
}
const version = this.consume("number", "Expected GRL language version");
return {
kind: "LanguageDeclaration",
language: "grl",
version: version.raw,
range: {
start: languageToken.range.start,
end: version.range.end
}
};
}
private parseModuleDeclaration(): GrlModuleDeclaration {
const moduleToken = this.consumeKeyword("module", "Expected module declaration");
const name = this.consume("identifier", "Expected module name");
const declarations: GrlTopLevelDeclaration[] = [];
while (!this.checkKeyword("end") && !this.isAtEnd()) {
declarations.push(this.parseTopLevelDeclaration());
}
const end = this.consumeKeyword("end", "Expected end after module declaration");
return {
kind: "ModuleDeclaration",
name: name.raw,
declarations,
range: {
start: moduleToken.range.start,
end: end.range.end
}
};
}
private parseTopLevelDeclaration(): GrlTopLevelDeclaration {
if (this.matchKeyword("import")) {
return this.finishImportDeclaration(this.previous());
}
if (this.matchKeyword("proc")) {
return this.finishProcedureDeclaration(this.previous());
}
if (this.matchKeyword("func")) {
return this.finishFunctionDeclaration(this.previous());
}
if (this.checkDataDeclarationStart()) {
return this.parseDataDeclaration();
}
if (this.matchKeyword("target")) {
return this.finishTargetDeclaration(this.previous());
}
if (this.matchKeyword("path")) {
return this.finishPathDeclaration(this.previous());
}
if (this.matchKeyword("operation")) {
return this.finishOperationDeclaration(this.previous());
}
const token = this.peek();
if (token.kind === "keyword" && RAW_TOP_LEVEL_KEYWORDS.has(token.raw)) {
return this.parseRawTopLevelDeclaration();
}
throw new GrlParseError("Expected top-level declaration", token);
}
private finishImportDeclaration(importToken: GrlToken): GrlImportDeclaration {
const moduleName = this.consume("identifier", "Expected imported module name");
return {
kind: "ImportDeclaration",
moduleName: moduleName.raw,
range: {
start: importToken.range.start,
end: moduleName.range.end
}
};
}
private parseDataDeclaration(): GrlDataDeclaration {
const storageToken = this.advance();
const storage = storageToken.raw as GrlDeclarationStorage;
const typeName = this.consumeIdentifierLike("Expected type name in data declaration");
const name = this.consume("identifier", "Expected variable name in data declaration");
this.consumeOperator("=", "Expected = in data declaration");
const initializerTokens = this.collectFlatExpressionTokens();
const initializer = parseGrlExpression(initializerTokens);
return {
kind: "DataDeclaration",
storage,
typeName: typeName.raw,
name: name.raw,
initializer,
range: {
start: storageToken.range.start,
end: initializer.range.end
}
};
}
private finishTargetDeclaration(targetToken: GrlToken): GrlTargetDeclaration {
const name = this.consume("identifier", "Expected target name");
this.consumeOperator("=", "Expected = in target declaration");
const targetTokens = this.collectFlatExpressionTokens();
const target = parseGrlExpression(targetTokens);
return {
kind: "TargetDeclaration",
name: name.raw,
target,
range: {
start: targetToken.range.start,
end: target.range.end
}
};
}
private finishPathDeclaration(pathToken: GrlToken): GrlPathDeclaration {
const name = this.consume("identifier", "Expected path name");
this.consumePunctuation("{", "Expected { after path name");
const items: GrlPathItem[] = [];
while (!this.checkPunctuation("}") && !this.isAtEnd()) {
if (this.matchKeyword("defaults")) {
items.push(this.finishPathDefaultsBlock(this.previous()));
continue;
}
if (this.matchKeyword("source")) {
items.push(this.finishPathSourceBlock(this.previous()));
continue;
}
if (this.matchKeyword("point")) {
items.push(this.finishPathPoint(this.previous()));
continue;
}
if (this.matchKeyword("event")) {
items.push(this.finishPathEvent(this.previous()));
continue;
}
throw new GrlParseError("Expected path item", this.peek());
}
const end = this.consumePunctuation("}", "Expected } after path declaration");
return {
kind: "PathDeclaration",
name: name.raw,
items,
range: {
start: pathToken.range.start,
end: end.range.end
}
};
}
private finishPathDefaultsBlock(start: GrlToken): GrlPathDefaultsBlock {
const { properties, end } = this.parsePathPropertyBlock("defaults");
return {
kind: "PathDefaultsBlock",
properties,
range: {
start: start.range.start,
end: end.range.end
}
};
}
private finishPathSourceBlock(start: GrlToken): GrlPathSourceBlock {
const { properties, end } = this.parsePathPropertyBlock("source");
return {
kind: "PathSourceBlock",
properties,
range: {
start: start.range.start,
end: end.range.end
}
};
}
private parsePathPropertyBlock(blockName: string): { properties: GrlPathProperty[]; end: GrlToken } {
this.consumePunctuation("{", `Expected { after path ${blockName}`);
const properties: GrlPathProperty[] = [];
while (!this.checkPunctuation("}") && !this.isAtEnd()) {
const key = this.consumeIdentifierLike(`Expected ${blockName} property name`);
this.consumePunctuation(":", `Expected : after ${blockName} property name`);
const valueTokens = this.collectPathPropertyValueTokens();
const value = parseGrlExpression(valueTokens);
properties.push({
key: key.raw,
value,
range: {
start: key.range.start,
end: value.range.end
}
});
this.matchPunctuation(",");
}
const end = this.consumePunctuation("}", `Expected } after path ${blockName}`);
return { properties, end };
}
private finishPathPoint(start: GrlToken): GrlPathPoint {
const id = this.consume("identifier", "Expected path point id");
const motionTokens = this.collectPathMotionTokens();
if (motionTokens.length === 0) {
throw new GrlParseError("Expected path point motion statement", this.peek());
}
return {
kind: "PathPoint",
id: id.raw,
motionTokens,
range: {
start: start.range.start,
end: motionTokens.at(-1)?.range.end ?? id.range.end
}
};
}
private finishPathEvent(start: GrlToken): GrlPathEvent {
const timing = this.consumeIdentifierLike("Expected before, after, or at after event");
if (timing.raw !== "before" && timing.raw !== "after" && timing.raw !== "at") {
throw new GrlParseError("Expected before, after, or at after event", timing);
}
const point = this.consume("identifier", "Expected event point id");
let distance: GrlPathEvent["distance"];
if (timing.raw === "at") {
this.consumeIdentifierValue("distance", "Expected distance in event at");
const distanceToken = this.peek();
const distanceTokens = this.collectSignedNumberTokens();
const distanceExpression = parseGrlExpression(distanceTokens);
if (distanceExpression.kind !== "NumberLiteral") {
throw new GrlParseError("Expected numeric event distance", distanceToken);
}
distance = distanceExpression;
}
const actionTokens = this.collectPathEventActionTokens();
if (actionTokens.length === 0) {
throw new GrlParseError("Expected path event action", this.peek());
}
return {
kind: "PathEvent",
timing: timing.raw,
pointId: point.raw,
...(distance ? { distance } : {}),
actionTokens,
range: {
start: start.range.start,
end: actionTokens.at(-1)?.range.end ?? point.range.end
}
};
}
private finishOperationDeclaration(operationToken: GrlToken): GrlOperationDeclaration {
const name = this.consume("identifier", "Expected operation name");
this.consumePunctuation("{", "Expected { after operation name");
let operationKind: string | undefined;
let pathName: string | undefined;
const items: GrlOperationItem[] = [];
while (!this.checkPunctuation("}") && !this.isAtEnd()) {
if (this.matchIdentifierValue("kind")) {
this.consumePunctuation(":", "Expected : after operation kind");
operationKind = this.consumeIdentifierLike("Expected operation kind").raw;
this.matchPunctuation(",");
continue;
}
if (this.matchIdentifierValue("path")) {
this.consumePunctuation(":", "Expected : after operation path");
pathName = this.consumeIdentifierLike("Expected operation path name").raw;
this.matchPunctuation(",");
continue;
}
if (this.matchIdentifierValue("process")) {
items.push(this.finishOperationProcessBlock(this.previous()));
continue;
}
if (this.matchIdentifierValue("start_action")) {
items.push(this.finishOperationActionBlock(this.previous(), "start_action"));
continue;
}
if (this.matchIdentifierValue("end_action")) {
items.push(this.finishOperationActionBlock(this.previous(), "end_action"));
continue;
}
throw new GrlParseError("Expected operation item", this.peek());
}
const end = this.consumePunctuation("}", "Expected } after operation declaration");
if (!operationKind) {
throw new GrlParseError("Operation requires kind", end);
}
if (!pathName) {
throw new GrlParseError("Operation requires path", end);
}
return {
kind: "OperationDeclaration",
name: name.raw,
operationKind,
pathName,
items,
range: {
start: operationToken.range.start,
end: end.range.end
}
};
}
private finishOperationProcessBlock(start: GrlToken): GrlOperationProcessBlock {
const { properties, end } = this.parsePathPropertyBlock("process");
return {
kind: "OperationProcessBlock",
properties,
range: {
start: start.range.start,
end: end.range.end
}
};
}
private finishOperationActionBlock(
start: GrlToken,
actionKind: "start_action" | "end_action"
): GrlOperationActionBlock {
this.consumePunctuation(":", `Expected : after ${actionKind}`);
const actionTokens = this.collectOperationActionTokens();
if (actionTokens.length === 0) {
throw new GrlParseError(`Expected ${actionKind} action`, this.peek());
}
return {
kind: "OperationActionBlock",
actionKind,
actionTokens,
range: {
start: start.range.start,
end: actionTokens.at(-1)?.range.end ?? start.range.end
}
};
}
private finishProcedureDeclaration(procToken: GrlToken): GrlProcedureDeclaration {
const name = this.consume("identifier", "Expected procedure name");
this.consumePunctuation("(", "Expected ( after procedure name");
const params: GrlToken[] = [];
while (!this.checkPunctuation(")") && !this.isAtEnd()) {
params.push(this.advance());
}
this.consumePunctuation(")", "Expected ) after procedure parameters");
const bodyTokens: GrlToken[] = [];
let nestedBlocks = 0;
while (!this.isAtEnd()) {
if (this.checkKeyword("end") && nestedBlocks === 0) {
break;
}
const token = this.advance();
bodyTokens.push(token);
if (token.kind === "keyword" && token.raw === "end" && nestedBlocks > 0) {
nestedBlocks -= 1;
} else if (token.kind === "keyword" && ["if", "while", "for", "switch", "try"].includes(token.raw)) {
nestedBlocks += 1;
}
}
const end = this.consumeKeyword("end", "Expected end after procedure declaration");
return {
kind: "ProcedureDeclaration",
name: name.raw,
params,
bodyTokens,
range: {
start: procToken.range.start,
end: end.range.end
}
};
}
private finishFunctionDeclaration(funcToken: GrlToken): GrlFunctionDeclaration {
const returnType = this.consumeIdentifierLike("Expected function return type");
const name = this.consume("identifier", "Expected function name");
this.consumePunctuation("(", "Expected ( after function name");
const params: GrlToken[] = [];
while (!this.checkPunctuation(")") && !this.isAtEnd()) {
params.push(this.advance());
}
this.consumePunctuation(")", "Expected ) after function parameters");
const bodyTokens: GrlToken[] = [];
let nestedBlocks = 0;
while (!this.isAtEnd()) {
if (this.checkKeyword("end") && nestedBlocks === 0) {
break;
}
const token = this.advance();
bodyTokens.push(token);
if (token.kind === "keyword" && token.raw === "end" && nestedBlocks > 0) {
nestedBlocks -= 1;
} else if (token.kind === "keyword" && ["if", "while", "for", "switch", "try"].includes(token.raw)) {
nestedBlocks += 1;
}
}
const end = this.consumeKeyword("end", "Expected end after function declaration");
return {
kind: "FunctionDeclaration",
returnType: returnType.raw,
name: name.raw,
params,
bodyTokens,
range: {
start: funcToken.range.start,
end: end.range.end
}
};
}
private parseRawTopLevelDeclaration(): GrlRawTopLevelDeclaration {
const first = this.advance();
const tokens: GrlToken[] = [first];
if (["path", "operation"].includes(first.raw)) {
this.collectBalancedBlock(tokens);
} else if (["trap", "task"].includes(first.raw)) {
this.collectUntilMatchingEnd(tokens);
} else {
this.collectFlatDeclaration(tokens);
}
return {
kind: "RawTopLevelDeclaration",
declarationType: first.raw,
tokens,
range: {
start: first.range.start,
end: tokens.at(-1)?.range.end ?? first.range.end
}
};
}
private collectBalancedBlock(tokens: GrlToken[]): void {
let braceDepth = 0;
while (!this.isAtEnd()) {
const token = this.advance();
tokens.push(token);
if (token.kind === "punctuation" && token.raw === "{") {
braceDepth += 1;
} else if (token.kind === "punctuation" && token.raw === "}") {
braceDepth -= 1;
if (braceDepth === 0) {
return;
}
}
}
}
private collectUntilMatchingEnd(tokens: GrlToken[]): void {
let nestedBlocks = 0;
while (!this.isAtEnd()) {
const token = this.advance();
tokens.push(token);
if (token.kind === "keyword" && token.raw === "end") {
if (nestedBlocks === 0) {
return;
}
nestedBlocks -= 1;
} else if (token.kind === "keyword" && ["if", "while", "for", "switch", "try"].includes(token.raw)) {
nestedBlocks += 1;
}
}
}
private collectFlatDeclaration(tokens: GrlToken[]): void {
while (!this.isAtEnd()) {
if (this.checkKeyword("end") || this.startsTopLevelDeclaration(this.peek())) {
return;
}
tokens.push(this.advance());
}
}
private collectFlatExpressionTokens(): GrlToken[] {
const tokens: GrlToken[] = [];
let braceDepth = 0;
let bracketDepth = 0;
let parenDepth = 0;
while (!this.isAtEnd()) {
if (
braceDepth === 0 &&
bracketDepth === 0 &&
parenDepth === 0 &&
(this.checkKeyword("end") || this.startsTopLevelDeclaration(this.peek()) || this.startsNextDataDeclaration())
) {
break;
}
const token = this.advance();
tokens.push(token);
if (token.kind === "punctuation") {
if (token.raw === "{") {
braceDepth += 1;
} else if (token.raw === "}") {
braceDepth -= 1;
} else if (token.raw === "[") {
bracketDepth += 1;
} else if (token.raw === "]") {
bracketDepth -= 1;
} else if (token.raw === "(") {
parenDepth += 1;
} else if (token.raw === ")") {
parenDepth -= 1;
}
}
}
if (tokens.length === 0) {
throw new GrlParseError("Expected expression", this.peek());
}
return tokens;
}
private collectPathPropertyValueTokens(): GrlToken[] {
return this.collectUntil((token, depth) =>
depth.brace === 0 &&
depth.bracket === 0 &&
depth.paren === 0 &&
((token.kind === "punctuation" && (token.raw === "," || token.raw === "}")) ||
this.isPathItemStart(token) ||
this.isPathPropertyStart())
);
}
private collectPathMotionTokens(): GrlToken[] {
return this.collectUntil((token, depth) =>
depth.brace === 0 &&
depth.bracket === 0 &&
depth.paren === 0 &&
((token.kind === "punctuation" && token.raw === "}") || this.isPathItemStart(token))
);
}
private collectPathEventActionTokens(): GrlToken[] {
return this.collectUntil((token, depth) =>
depth.brace === 0 &&
depth.bracket === 0 &&
depth.paren === 0 &&
((token.kind === "punctuation" && token.raw === "}") || this.isPathItemStart(token))
);
}
private collectOperationActionTokens(): GrlToken[] {
return this.collectUntil((token, depth) =>
depth.brace === 0 &&
depth.bracket === 0 &&
depth.paren === 0 &&
((token.kind === "punctuation" && token.raw === "}") || this.isOperationItemStart(token))
);
}
private collectSignedNumberTokens(): GrlToken[] {
const tokens: GrlToken[] = [];
if (this.peek().kind === "operator" && this.peek().raw === "-") {
tokens.push(this.advance());
}
tokens.push(this.consume("number", "Expected numeric value"));
return tokens;
}
private collectUntil(
shouldStop: (
token: GrlToken,
depth: { brace: number; bracket: number; paren: number }
) => boolean
): GrlToken[] {
const tokens: GrlToken[] = [];
const depth = { brace: 0, bracket: 0, paren: 0 };
while (!this.isAtEnd() && !shouldStop(this.peek(), depth)) {
const token = this.advance();
tokens.push(token);
if (token.kind === "punctuation") {
if (token.raw === "{") {
depth.brace += 1;
} else if (token.raw === "}") {
depth.brace -= 1;
} else if (token.raw === "[") {
depth.bracket += 1;
} else if (token.raw === "]") {
depth.bracket -= 1;
} else if (token.raw === "(") {
depth.paren += 1;
} else if (token.raw === ")") {
depth.paren -= 1;
}
}
}
if (tokens.length === 0) {
throw new GrlParseError("Expected expression", this.peek());
}
return tokens;
}
private isPathItemStart(token: GrlToken): boolean {
return (
token.kind === "keyword" &&
(token.raw === "defaults" || token.raw === "source" || token.raw === "point" || token.raw === "event")
);
}
private isOperationItemStart(token: GrlToken): boolean {
return (
((token.kind === "keyword" || token.kind === "identifier") &&
(token.raw === "kind" || token.raw === "path" || token.raw === "process")) ||
((token.kind === "identifier" || token.kind === "keyword") &&
(token.raw === "start_action" || token.raw === "end_action"))
);
}
private isPathPropertyStart(): boolean {
const token = this.peek();
const next = this.peek(1);
return (
(token.kind === "keyword" || token.kind === "identifier") &&
next.kind === "punctuation" &&
next.raw === ":"
);
}
private startsNextDataDeclaration(): boolean {
const current = this.peek();
const next = this.peekNext();
const following = this.peek(2);
if (current.kind !== "identifier" && current.kind !== "keyword") {
return false;
}
if (next.kind !== "identifier" && next.kind !== "keyword") {
return false;
}
return following.kind === "operator" && following.raw === "=";
}
private startsTopLevelDeclaration(token: GrlToken): boolean {
if (token.kind !== "keyword") {
return false;
}
if (token.raw === "target") {
const name = this.peek(1);
const equals = this.peek(2);
return name.kind === "identifier" && equals.kind === "operator" && equals.raw === "=";
}
return (
token.raw === "import" ||
token.raw === "proc" ||
token.raw === "path" ||
token.raw === "operation" ||
token.raw === "persistent" ||
token.raw === "const" ||
token.raw === "var" ||
RAW_TOP_LEVEL_KEYWORDS.has(token.raw)
);
}
private checkDataDeclarationStart(): boolean {
return this.checkKeyword("persistent") || this.checkKeyword("const") || this.checkKeyword("var");
}
private consumeIdentifierLike(message: string): GrlToken {
const token = this.peek();
if (token.kind === "identifier" || token.kind === "keyword") {
return this.advance();
}
throw new GrlParseError(message, token);
}
private consume(kind: GrlToken["kind"], message: string): GrlToken {
if (this.check(kind)) {
return this.advance();
}
throw new GrlParseError(message, this.peek());
}
private consumeKeyword(keyword: string, message: string): GrlToken {
if (this.checkKeyword(keyword)) {
return this.advance();
}
throw new GrlParseError(message, this.peek());
}
private consumeIdentifierValue(value: string, message: string): GrlToken {
const token = this.peek();
if ((token.kind === "keyword" || token.kind === "identifier") && token.raw === value) {
return this.advance();
}
throw new GrlParseError(message, token);
}
private consumePunctuation(value: string, message: string): GrlToken {
if (this.checkPunctuation(value)) {
return this.advance();
}
throw new GrlParseError(message, this.peek());
}
private consumeOperator(value: string, message: string): GrlToken {
const token = this.peek();
if (token.kind === "operator" && token.raw === value) {
return this.advance();
}
throw new GrlParseError(message, token);
}
private matchKeyword(keyword: string): boolean {
if (this.checkKeyword(keyword)) {
this.advance();
return true;
}
return false;
}
private matchIdentifierValue(value: string): boolean {
const token = this.peek();
if ((token.kind === "keyword" || token.kind === "identifier") && token.raw === value) {
this.advance();
return true;
}
return false;
}
private matchPunctuation(value: string): boolean {
if (this.checkPunctuation(value)) {
this.advance();
return true;
}
return false;
}
private check(kind: GrlToken["kind"]): boolean {
return this.peek().kind === kind;
}
private checkKeyword(keyword: string): boolean {
const token = this.peek();
return token.kind === "keyword" && token.raw === keyword;
}
private checkPunctuation(value: string): boolean {
const token = this.peek();
return token.kind === "punctuation" && token.raw === value;
}
private advance(): GrlToken {
if (!this.isAtEnd()) {
this.current += 1;
}
return this.previous();
}
private isAtEnd(): boolean {
return this.peek().kind === "eof";
}
private peek(distance = 0): GrlToken {
return this.tokens[this.current + distance] ?? this.tokens[this.tokens.length - 1]!;
}
private peekNext(): GrlToken {
return this.peek(1);
}
private previous(): GrlToken {
return this.tokens[this.current - 1] ?? this.tokens[0]!;
}
}

View File

@@ -0,0 +1,8 @@
export {
postProcessAllBrands,
postProcessBrand,
type MultiBrandPostResult,
type PostBrand,
type PostIssue,
type PostResult
} from "./postProcessor.js";

View File

@@ -0,0 +1,232 @@
import type { MotionDiagnostic, SpeedSpec, ZoneSpec } from "../../kdl/types.js";
import type {
ExecutableInstruction,
MotionInstruction,
SemanticProgramIr
} from "../ir/index.js";
export type PostBrand = "abb" | "fanuc" | "kuka";
export interface PostIssue {
severity: MotionDiagnostic["severity"];
code: string;
message: string;
brand?: PostBrand;
}
export interface PostResult {
brand: PostBrand;
filename: string;
text: string;
report: PostIssue[];
}
export interface MultiBrandPostResult {
outputs: Record<PostBrand, PostResult>;
report: PostIssue[];
}
export function postProcessAllBrands(ir: SemanticProgramIr): MultiBrandPostResult {
const abb = postProcessBrand(ir, "abb");
const fanuc = postProcessBrand(ir, "fanuc");
const kuka = postProcessBrand(ir, "kuka");
return {
outputs: { abb, fanuc, kuka },
report: [...abb.report, ...fanuc.report, ...kuka.report]
};
}
export function postProcessBrand(ir: SemanticProgramIr, brand: PostBrand): PostResult {
const report: PostIssue[] = collectBrandHintIssues(ir, brand);
const text = renderBrandProgram(ir, brand, report);
return {
brand,
filename: filenameFor(ir.moduleName, brand),
text,
report
};
}
function renderBrandProgram(ir: SemanticProgramIr, brand: PostBrand, report: PostIssue[]): string {
switch (brand) {
case "abb":
return renderAbb(ir, report);
case "fanuc":
return renderFanuc(ir, report);
case "kuka":
return renderKuka(ir, report);
}
}
function renderAbb(ir: SemanticProgramIr, report: PostIssue[]): string {
const lines = [`MODULE ${ir.moduleName}`, " PROC main()"];
for (const instruction of mainInstructions(ir)) {
lines.push(` ${renderAbbInstruction(instruction, report)}`);
}
lines.push(" ENDPROC", "ENDMODULE");
return lines.join("\n");
}
function renderFanuc(ir: SemanticProgramIr, report: PostIssue[]): string {
const lines = ["/PROG MAIN", "/MN"];
mainInstructions(ir).forEach((instruction, index) => {
lines.push(` ${index + 1}: ${renderFanucInstruction(instruction, report)} ;`);
});
lines.push("/END");
return lines.join("\n");
}
function renderKuka(ir: SemanticProgramIr, report: PostIssue[]): string {
const lines = ["DEF Main()"];
for (const instruction of mainInstructions(ir)) {
lines.push(` ${renderKukaInstruction(instruction, report)}`);
}
lines.push("END");
return lines.join("\n");
}
function renderAbbInstruction(instruction: ExecutableInstruction, report: PostIssue[]): string {
if (isMotion(instruction)) {
const target = motionTargetName(instruction);
const zone = abbZone(instruction.zone);
const speed = abbSpeed(instruction.speed);
if (instruction.kind === "MOVEJ") return `MoveJ ${target},${speed},${zone},tool0;`;
if (instruction.kind === "MOVEL") return `MoveL ${target},${speed},${zone},tool0;`;
return `MoveC ${motionViaName(instruction)},${target},${speed},${zone},tool0;`;
}
if (instruction.kind === "IO_WRITE") return `SetDO ${instruction.target.raw},${formatValue(instruction.value)};`;
if (instruction.kind === "WAIT") return `WaitUntil ${instruction.condition};`;
if (instruction.kind === "PULSE") return `PulseDO ${instruction.target.raw},${instruction.duration.toFixed(3)};`;
return unsupportedLine("abb", instruction.kind, report);
}
function renderFanucInstruction(instruction: ExecutableInstruction, report: PostIssue[]): string {
if (isMotion(instruction)) {
const target = motionTargetName(instruction);
const speed = fanucSpeed(instruction.speed);
const zone = fanucZone(instruction.zone);
if (instruction.kind === "MOVEJ") return `J ${target} ${speed} ${zone}`;
if (instruction.kind === "MOVEL") return `L ${target} ${speed} ${zone}`;
return `C ${motionViaName(instruction)} ${target} ${speed} ${zone}`;
}
if (instruction.kind === "IO_WRITE") return `${fanucIo(instruction.target.raw)}=${formatValue(instruction.value)}`;
if (instruction.kind === "WAIT") return `WAIT (${instruction.condition})`;
if (instruction.kind === "PULSE") return `PULSE ${fanucIo(instruction.target.raw)} ${Math.round(instruction.duration * 1000)}ms`;
return unsupportedLine("fanuc", instruction.kind, report);
}
function renderKukaInstruction(instruction: ExecutableInstruction, report: PostIssue[]): string {
if (isMotion(instruction)) {
const target = motionTargetName(instruction);
const speed = kukaSpeed(instruction.speed);
const zone = kukaZone(instruction.zone);
if (instruction.kind === "MOVEJ") return `PTP ${target} ${speed}${zone}`;
if (instruction.kind === "MOVEL") return `LIN ${target} ${speed}${zone}`;
return `CIRC ${motionViaName(instruction)}, ${target} ${speed}${zone}`;
}
if (instruction.kind === "IO_WRITE") return `${kukaIo(instruction.target.raw)} = ${formatValue(instruction.value)}`;
if (instruction.kind === "WAIT") return `WAIT FOR ${instruction.condition}`;
if (instruction.kind === "PULSE") return `PULSE ${kukaIo(instruction.target.raw)} ${instruction.duration.toFixed(3)}`;
return unsupportedLine("kuka", instruction.kind, report);
}
function mainInstructions(ir: SemanticProgramIr): ExecutableInstruction[] {
return ir.procedures.find((procedure) => procedure.name === "main")?.instructions ?? [];
}
function isMotion(instruction: ExecutableInstruction): instruction is MotionInstruction {
return instruction.kind === "MOVEJ" || instruction.kind === "MOVEL" || instruction.kind === "MOVEC";
}
function motionTargetName(instruction: MotionInstruction): string {
const target = instruction.target;
if (target && "id" in target && target.id) {
return target.id;
}
return instruction.pointId ?? instruction.id ?? "p_auto";
}
function motionViaName(instruction: MotionInstruction): string {
const via = instruction.via;
if (via && "id" in via && via.id) {
return via.id;
}
return "via_auto";
}
function abbSpeed(speed: SpeedSpec): string {
if (speed.kind === "joint_percent") return `v${Math.round(speed.value * 100)}`;
if (speed.kind === "linear") return `v${Math.round(speed.velocity * 1000)}`;
return "v100";
}
function fanucSpeed(speed: SpeedSpec): string {
if (speed.kind === "joint_percent") return `${Math.round(speed.value * 100)}%`;
if (speed.kind === "linear") return `${Math.round(speed.velocity * 1000)}mm/sec`;
return "100mm/sec";
}
function kukaSpeed(speed: SpeedSpec): string {
if (speed.kind === "joint_percent") return `Vel=${Math.round(speed.value * 100)}%`;
if (speed.kind === "linear") return `Vel=${speed.velocity.toFixed(3)}m/s`;
return "Vel=0.100m/s";
}
function abbZone(zone: ZoneSpec): string {
if (zone.kind === "fine") return "fine";
if (zone.kind === "distance") return `z${Math.round(zone.value * 1000)}`;
if (zone.kind === "cnt") return `z${Math.round(zone.value * 100)}`;
return "z10";
}
function fanucZone(zone: ZoneSpec): string {
if (zone.kind === "fine") return "FINE";
if (zone.kind === "cnt") return `CNT${Math.round(zone.value * 100)}`;
if (zone.kind === "distance") return `CNT${Math.max(1, Math.round(zone.value * 1000))}`;
return "CNT10";
}
function kukaZone(zone: ZoneSpec): string {
return zone.kind === "fine" ? "" : " C_DIS";
}
function fanucIo(raw: string): string {
return raw.replace("io.do", "DO").replace("io.di", "DI").replace("[", "[").replace("]", "]");
}
function kukaIo(raw: string): string {
return raw.replace("io.do", "$OUT").replace("io.di", "$IN");
}
function formatValue(value: boolean | number | string): string {
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
return String(value);
}
function unsupportedLine(brand: PostBrand, kind: string, report: PostIssue[]): string {
report.push({
severity: "warning",
code: "GRL_POST_UNSUPPORTED",
message: `${kind} is not supported by ${brand} prototype postprocessor`,
brand
});
return `! unsupported ${kind}`;
}
function collectBrandHintIssues(ir: SemanticProgramIr, brand: PostBrand): PostIssue[] {
return ir.symbols
.filter((symbol) => symbol.kind === "raw" && symbol.typeName === "post_hint")
.filter((symbol) => !symbol.name.includes(brand))
.map((symbol) => ({
severity: "info" as const,
code: "GRL_POST_HINT_IGNORED",
message: `post_hint ${symbol.name} ignored for ${brand}`,
brand
}));
}
function filenameFor(moduleName: string, brand: PostBrand): string {
if (brand === "abb") return `${moduleName}.mod`;
if (brand === "fanuc") return `${moduleName}.ls`;
return `${moduleName}.src`;
}

View File

@@ -0,0 +1,576 @@
import { KdlStructuredError } from "../../kdl/rpc.js";
import type { MotionSourceMap } from "../../kdl/types.js";
import type { GrlProcedureDeclaration } from "../ast/index.js";
import type {
ControlExpression,
ControlFlowInstruction,
ForInstruction,
IfInstruction,
JumpInstruction,
LabelInstruction,
ProcedureFlowInstruction,
RawProcedureStatement,
SwitchCaseInstruction,
SwitchInstruction,
WhileInstruction
} from "../ir/index.js";
import type { GrlToken } from "../lexer/index.js";
type StopKeyword = "elseif" | "else" | "case" | "default" | "end";
interface ParseContext {
scopePath: string[];
loopDepth: number;
switchDepth: number;
}
export function parseProcedureControlFlow(procedure: GrlProcedureDeclaration): ProcedureFlowInstruction[] {
return parseControlFlowStatements(procedure.bodyTokens);
}
export function parseControlFlowStatements(tokens: GrlToken[]): ProcedureFlowInstruction[] {
const parser = new ControlFlowParser(tokens);
const flow = parser.parseRoot();
validateJumps(flow);
return flow;
}
class ControlFlowParser {
private current = 0;
constructor(private readonly tokens: GrlToken[]) {}
parseRoot(): ProcedureFlowInstruction[] {
return this.parseBlock(new Set(), {
scopePath: [],
loopDepth: 0,
switchDepth: 0
});
}
private parseBlock(stopKeywords: Set<StopKeyword>, context: ParseContext): ProcedureFlowInstruction[] {
const instructions: ProcedureFlowInstruction[] = [];
while (!this.isAtEnd()) {
if (this.isStopKeyword(stopKeywords)) {
break;
}
if (this.matchKeyword("if")) {
instructions.push(this.finishIf(this.previous(), context));
continue;
}
if (this.matchKeyword("while")) {
instructions.push(this.finishWhile(this.previous(), context));
continue;
}
if (this.matchKeyword("for")) {
instructions.push(this.finishFor(this.previous(), context));
continue;
}
if (this.matchKeyword("switch")) {
instructions.push(this.finishSwitch(this.previous(), context));
continue;
}
if (this.matchKeyword("break")) {
instructions.push(this.finishBreak(this.previous(), context));
continue;
}
if (this.matchKeyword("continue")) {
instructions.push(this.finishContinue(this.previous(), context));
continue;
}
if (this.matchKeyword("label")) {
instructions.push(this.finishLabel(this.previous(), context));
continue;
}
if (this.matchKeyword("jump")) {
instructions.push(this.finishJump(this.previous(), context));
continue;
}
if (this.checkKeyword("end")) {
throw controlError("GRL_CONTROL_UNEXPECTED_END", "Unexpected end in procedure body", this.peek());
}
instructions.push(this.finishRawStatement());
}
return instructions;
}
private finishIf(start: GrlToken, context: ParseContext): IfInstruction {
const condition = this.parseBooleanLineExpression(start);
const branches: IfInstruction["branches"] = [
{
branchKind: "if",
condition,
body: this.parseBlock(new Set(["elseif", "else", "end"]), {
...context,
scopePath: [...context.scopePath, scopeId(start, "if")]
}),
sourceMap: tokenSourceMap(start)
}
];
while (this.matchKeyword("elseif")) {
const branchStart = this.previous();
const branchCondition = this.parseBooleanLineExpression(branchStart);
branches.push({
branchKind: "elseif",
condition: branchCondition,
body: this.parseBlock(new Set(["elseif", "else", "end"]), {
...context,
scopePath: [...context.scopePath, scopeId(branchStart, `elseif${branches.length}`)]
}),
sourceMap: tokenSourceMap(branchStart)
});
}
if (this.matchKeyword("else")) {
const branchStart = this.previous();
branches.push({
branchKind: "else",
body: this.parseBlock(new Set(["end"]), {
...context,
scopePath: [...context.scopePath, scopeId(branchStart, "else")]
}),
sourceMap: tokenSourceMap(branchStart)
});
}
this.consumeKeyword("end", "Expected end after if block");
return {
kind: "IF",
branches,
sourceMap: tokenSourceMap(start)
};
}
private finishWhile(start: GrlToken, context: ParseContext): WhileInstruction {
const condition = this.parseBooleanLineExpression(start);
const body = this.parseBlock(new Set(["end"]), {
scopePath: [...context.scopePath, scopeId(start, "while")],
loopDepth: context.loopDepth + 1,
switchDepth: context.switchDepth
});
this.consumeKeyword("end", "Expected end after while block");
return {
kind: "WHILE",
condition,
body,
sourceMap: tokenSourceMap(start)
};
}
private finishFor(start: GrlToken, context: ParseContext): ForInstruction {
const iterator = this.consumeIdentifier("Expected loop variable after for");
if (!this.matchOperator("=") && !this.matchOperator(":=")) {
throw controlError("GRL_FOR_ASSIGNMENT_EXPECTED", "Expected = after for loop variable", this.peek());
}
const from = this.parseLineExpressionUntil(start, ["to"]);
this.consumeKeyword("to", "Expected to in for loop");
const to = this.parseLineExpressionUntil(start, ["step"]);
const step = this.matchKeyword("step") ? this.parseLineExpressionUntil(start, []) : undefined;
const body = this.parseBlock(new Set(["end"]), {
scopePath: [...context.scopePath, scopeId(start, "for")],
loopDepth: context.loopDepth + 1,
switchDepth: context.switchDepth
});
this.consumeKeyword("end", "Expected end after for block");
return {
kind: "FOR",
iterator: iterator.raw,
from,
to,
...(step ? { step } : {}),
body,
sourceMap: tokenSourceMap(start)
};
}
private finishSwitch(start: GrlToken, context: ParseContext): SwitchInstruction {
const expression = this.parseLineExpressionUntil(start, []);
const cases: SwitchCaseInstruction[] = [];
const seenCases = new Set<string>();
let seenDefault = false;
while (!this.isAtEnd() && !this.checkKeyword("end")) {
if (this.matchKeyword("case")) {
const caseStart = this.previous();
const valueTokens = this.collectLineExpressionTokens(caseStart, []);
if (valueTokens.length === 0) {
throw controlError("GRL_SWITCH_CASE_VALUE_MISSING", "Expected case value", caseStart);
}
const constant = parseCaseConstant(valueTokens);
const key = `${typeof constant.value}:${String(constant.value)}`;
if (seenCases.has(key)) {
throw controlError("GRL_SWITCH_CASE_DUPLICATE", `Duplicate switch case ${constant.raw}`, caseStart);
}
seenCases.add(key);
cases.push({
caseKind: "case",
value: constant.value,
raw: constant.raw,
body: this.parseBlock(new Set(["case", "default", "end"]), {
scopePath: [...context.scopePath, scopeId(caseStart, `case:${constant.raw}`)],
loopDepth: context.loopDepth,
switchDepth: context.switchDepth + 1
}),
sourceMap: tokenSourceMap(caseStart)
});
continue;
}
if (this.matchKeyword("default")) {
const defaultStart = this.previous();
if (seenDefault) {
throw controlError("GRL_SWITCH_DEFAULT_DUPLICATE", "Duplicate switch default case", defaultStart);
}
seenDefault = true;
cases.push({
caseKind: "default",
body: this.parseBlock(new Set(["case", "default", "end"]), {
scopePath: [...context.scopePath, scopeId(defaultStart, "default")],
loopDepth: context.loopDepth,
switchDepth: context.switchDepth + 1
}),
sourceMap: tokenSourceMap(defaultStart)
});
continue;
}
throw controlError("GRL_SWITCH_CASE_EXPECTED", "Expected case, default, or end in switch", this.peek());
}
this.consumeKeyword("end", "Expected end after switch block");
return {
kind: "SWITCH",
expression,
cases,
sourceMap: tokenSourceMap(start)
};
}
private finishBreak(start: GrlToken, context: ParseContext): ControlFlowInstruction {
if (context.loopDepth === 0 && context.switchDepth === 0) {
throw controlError("GRL_BREAK_OUTSIDE_FLOW", "break is only valid inside loop or switch", start);
}
return {
kind: "BREAK",
sourceMap: tokenSourceMap(start)
};
}
private finishContinue(start: GrlToken, context: ParseContext): ControlFlowInstruction {
if (context.loopDepth === 0) {
throw controlError("GRL_CONTINUE_OUTSIDE_LOOP", "continue is only valid inside loop", start);
}
return {
kind: "CONTINUE",
sourceMap: tokenSourceMap(start)
};
}
private finishLabel(start: GrlToken, context: ParseContext): LabelInstruction {
const label = this.consumeIdentifier("Expected label name");
return {
kind: "LABEL",
name: label.raw,
scopePath: [...context.scopePath],
sourceMap: tokenSourceMap(start)
};
}
private finishJump(start: GrlToken, context: ParseContext): JumpInstruction {
const label = this.consumeIdentifier("Expected label name after jump");
return {
kind: "JUMP",
label: label.raw,
scopePath: [...context.scopePath],
sourceMap: tokenSourceMap(start)
};
}
private finishRawStatement(): RawProcedureStatement {
const start = this.peek();
const tokens = this.collectLineExpressionTokens(start, []);
if (tokens.length === 0) {
const token = this.advance();
return {
kind: "RAW_STATEMENT",
text: token.raw,
tokens: [token],
sourceMap: tokenSourceMap(token)
};
}
return {
kind: "RAW_STATEMENT",
text: stringifyTokens(tokens),
tokens,
sourceMap: tokenSourceMap(start)
};
}
private parseBooleanLineExpression(start: GrlToken): ControlExpression {
const expression = this.parseLineExpressionUntil(start, []);
if (!isBooleanCondition(expression.tokens as GrlToken[])) {
throw controlError("GRL_CONTROL_CONDITION_NOT_BOOL", "Control condition must be boolean", start);
}
return expression;
}
private parseLineExpressionUntil(start: GrlToken, stopKeywords: string[]): ControlExpression {
const tokens = this.collectLineExpressionTokens(start, stopKeywords);
if (tokens.length === 0) {
throw controlError("GRL_CONTROL_EXPRESSION_MISSING", "Expected control expression", start);
}
return {
text: stringifyTokens(tokens),
tokens,
sourceMap: tokenSourceMap(tokens[0]!)
};
}
private collectLineExpressionTokens(start: GrlToken, stopKeywords: string[]): GrlToken[] {
const tokens: GrlToken[] = [];
let parenDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
while (!this.isAtEnd()) {
const token = this.peek();
if (token.range.start.line !== start.range.start.line) {
break;
}
if (
parenDepth === 0 &&
bracketDepth === 0 &&
braceDepth === 0 &&
(token.kind === "keyword" || token.kind === "identifier") &&
stopKeywords.includes(token.raw)
) {
break;
}
const consumed = this.advance();
tokens.push(consumed);
if (consumed.kind === "punctuation") {
if (consumed.raw === "(") parenDepth += 1;
if (consumed.raw === ")") parenDepth = Math.max(0, parenDepth - 1);
if (consumed.raw === "[") bracketDepth += 1;
if (consumed.raw === "]") bracketDepth = Math.max(0, bracketDepth - 1);
if (consumed.raw === "{") braceDepth += 1;
if (consumed.raw === "}") braceDepth = Math.max(0, braceDepth - 1);
}
}
return tokens;
}
private isStopKeyword(stopKeywords: Set<StopKeyword>): boolean {
if (stopKeywords.size === 0) {
return false;
}
const token = this.peek();
return (token.kind === "keyword" || token.kind === "identifier") && stopKeywords.has(token.raw as StopKeyword);
}
private matchKeyword(keyword: string): boolean {
if (this.checkKeyword(keyword)) {
this.advance();
return true;
}
return false;
}
private checkKeyword(keyword: string): boolean {
const token = this.peek();
return (token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword;
}
private consumeKeyword(keyword: string, message: string): GrlToken {
if (this.checkKeyword(keyword)) {
return this.advance();
}
throw controlError("GRL_KEYWORD_EXPECTED", message, this.peek());
}
private consumeIdentifier(message: string): GrlToken {
const token = this.peek();
if (token.kind === "identifier" || token.kind === "keyword") {
return this.advance();
}
throw controlError("GRL_IDENTIFIER_EXPECTED", message, token);
}
private matchOperator(operator: string): boolean {
const token = this.peek();
if (token.kind === "operator" && token.raw === operator) {
this.advance();
return true;
}
return false;
}
private advance(): GrlToken {
this.current += 1;
return this.previous();
}
private previous(): GrlToken {
return this.tokens[this.current - 1]!;
}
private peek(): GrlToken {
return this.tokens[this.current]!;
}
private isAtEnd(): boolean {
return this.current >= this.tokens.length;
}
}
function validateJumps(flow: ProcedureFlowInstruction[]): void {
const labels = new Map<string, LabelInstruction>();
const jumps: JumpInstruction[] = [];
visitFlow(flow, (instruction) => {
if (instruction.kind === "LABEL") {
if (labels.has(instruction.name)) {
throw controlError("GRL_LABEL_DUPLICATE", `Duplicate label ${instruction.name}`, undefined, instruction.sourceMap);
}
labels.set(instruction.name, instruction);
} else if (instruction.kind === "JUMP") {
jumps.push(instruction);
}
});
for (const jump of jumps) {
const label = labels.get(jump.label);
if (!label) {
throw controlError("GRL_LABEL_NOT_FOUND", `Unknown label ${jump.label}`, undefined, jump.sourceMap);
}
if (!isPrefix(label.scopePath, jump.scopePath)) {
throw controlError(
"GRL_JUMP_INTO_BLOCK",
`jump ${jump.label} cannot enter a nested or sibling block`,
undefined,
jump.sourceMap
);
}
}
}
function visitFlow(flow: ProcedureFlowInstruction[], visit: (instruction: ProcedureFlowInstruction) => void): void {
for (const instruction of flow) {
visit(instruction);
if (instruction.kind === "IF") {
for (const branch of instruction.branches) {
visitFlow(branch.body, visit);
}
} else if (instruction.kind === "WHILE" || instruction.kind === "FOR") {
visitFlow(instruction.body, visit);
} else if (instruction.kind === "SWITCH") {
for (const switchCase of instruction.cases) {
visitFlow(switchCase.body, visit);
}
}
}
}
function isPrefix(prefix: string[], value: string[]): boolean {
return prefix.length <= value.length && prefix.every((part, index) => value[index] === part);
}
function isBooleanCondition(tokens: GrlToken[]): boolean {
if (tokens.length === 0) {
return false;
}
if (tokens.length === 1) {
const [token] = tokens;
if (!token) return false;
if (token.kind === "number" || token.kind === "string") {
return false;
}
return token.kind === "identifier" || token.kind === "keyword";
}
if (tokens.length === 2 && tokens[0]?.raw === "-" && tokens[1]?.kind === "number") {
return false;
}
if (tokens.some((token) => token.kind === "operator" && ["==", "!=", "<", ">", "<=", ">=", "&&", "||", "!"].includes(token.raw))) {
return true;
}
if (tokens.some((token) => token.kind === "keyword" && (token.raw === "true" || token.raw === "false"))) {
return true;
}
const first = tokens[0];
return Boolean(
first &&
(first.kind === "keyword" || first.kind === "identifier") &&
["all", "any", "rising", "falling", "changed"].includes(first.raw)
);
}
function parseCaseConstant(tokens: GrlToken[]): { value: string | number | boolean; raw: string } {
if (tokens.length === 2 && tokens[0]?.kind === "operator" && tokens[0].raw === "-" && tokens[1]?.kind === "number") {
const value = -(tokens[1].unit?.normalizedValue ?? tokens[1].value);
return { value, raw: stringifyTokens(tokens) };
}
if (tokens.length !== 1) {
throw controlError("GRL_SWITCH_CASE_NOT_CONSTANT", "switch case must be a constant expression", tokens[0]);
}
const token = tokens[0]!;
if (token.kind === "number") {
return { value: token.unit?.normalizedValue ?? token.value, raw: token.raw };
}
if (token.kind === "string") {
return { value: token.value, raw: token.raw };
}
if (token.kind === "keyword" && (token.raw === "true" || token.raw === "false")) {
return { value: token.raw === "true", raw: token.raw };
}
if (token.kind === "identifier" || token.kind === "keyword") {
return { value: token.raw, raw: token.raw };
}
throw controlError("GRL_SWITCH_CASE_NOT_CONSTANT", "switch case must be a constant expression", token);
}
function stringifyTokens(tokens: GrlToken[]): string {
return tokens.map((token) => token.raw).join(" ");
}
function scopeId(token: GrlToken, kind: string): string {
return `${kind}@${token.range.start.line}:${token.range.start.column}`;
}
function tokenSourceMap(token: GrlToken): MotionSourceMap {
return {
line: token.range.start.line,
column: token.range.start.column
};
}
function controlError(
code: string,
message: string,
token?: GrlToken,
sourceMap?: MotionSourceMap
): KdlStructuredError {
const map = sourceMap ?? (token ? tokenSourceMap(token) : undefined);
return new KdlStructuredError(
code,
message,
map
? [
{
severity: "error",
code,
message,
sourceMap: map
}
]
: undefined
);
}

View File

@@ -0,0 +1,296 @@
import { rpyToQuaternion } from "../../math/poseMath.js";
import type { JointTarget, OffsetSpec, Pose, PoseTarget, SpeedSpec, ZoneSpec } from "../../kdl/types.js";
import { KdlStructuredError } from "../../kdl/rpc.js";
import type {
GrlArrayExpression,
GrlCallExpression,
GrlDataDeclaration,
GrlExpression,
GrlNumberLiteral,
GrlObjectExpression,
GrlOffsetExpression,
GrlTargetDeclaration
} from "../ast/index.js";
export type CompiledGrlDataValue =
| Pose
| JointTarget
| PoseTarget
| SpeedSpec
| ZoneSpec
| OffsetSpec
| Record<string, unknown>
| string
| number
| boolean
| number[];
export interface CompiledGrlDataDeclaration {
name: string;
storage: GrlDataDeclaration["storage"];
typeName: string;
value: CompiledGrlDataValue;
}
export interface CompiledGrlTargetDeclaration {
name: string;
target: JointTarget | PoseTarget;
}
export function compileGrlDataDeclaration(declaration: GrlDataDeclaration): CompiledGrlDataDeclaration {
return {
name: declaration.name,
storage: declaration.storage,
typeName: declaration.typeName,
value: compileByType(declaration.typeName, declaration.initializer)
};
}
export function compileGrlTargetDeclaration(declaration: GrlTargetDeclaration): CompiledGrlTargetDeclaration {
const target = compileTargetExpression(declaration.target);
return {
name: declaration.name,
target: {
...target,
id: target.id ?? declaration.name
}
};
}
export function compileTargetExpression(expression: GrlExpression): JointTarget | PoseTarget {
if (isObjectExpression(expression, "joint_target")) {
return {
joints: compileNumberArray(requiredProperty(expression, "joints"))
};
}
if (isObjectExpression(expression, "pose_target")) {
const pose = compilePoseExpression(requiredProperty(expression, "pose"));
const configExpression = findProperty(expression, "config");
const config = configExpression ? compileRobotConfig(configExpression) : undefined;
return {
pose,
...(config ? { config } : {}),
...(findIdentifierName(expression, "tool") ? { tool: { id: findIdentifierName(expression, "tool") } as unknown as Pose } : {}),
...(findIdentifierName(expression, "frame") ? { frame: { id: findIdentifierName(expression, "frame") } as unknown as Pose } : {})
};
}
throw compileError("GRL_UNSUPPORTED_TARGET", "Expected joint_target or pose_target expression");
}
export function compileSpeedExpression(expression: GrlExpression): SpeedSpec {
if (!isCallExpression(expression)) {
throw compileError("GRL_INVALID_SPEED", "Speed expression must be a call");
}
const first = expression.args[0];
if (!first || first.kind !== "NumberLiteral") {
throw compileError("GRL_INVALID_SPEED", "Speed expression requires a numeric value");
}
if (expression.callee === "joint") {
if (first.unit?.kind === "percent") {
return { kind: "joint_percent", value: first.unit.normalizedValue };
}
return { kind: "joint_abs", velocity: normalizedNumber(first) };
}
if (expression.callee === "linear") {
return { kind: "linear", velocity: normalizedNumber(first), ...compileAcceleration(expression) };
}
if (expression.callee === "angular") {
return { kind: "linear", velocity: 0, angularVelocity: normalizedNumber(first), ...compileAcceleration(expression) };
}
throw compileError("GRL_INVALID_SPEED", `Unsupported speed expression: ${expression.callee}`);
}
export function compileZoneExpression(expression: GrlExpression): ZoneSpec {
if (expression.kind === "IdentifierExpression") {
if (expression.name === "fine") {
return { kind: "fine" };
}
if (expression.name === "continuous") {
return { kind: "continuous" };
}
}
if (isCallExpression(expression) && expression.callee === "z") {
const first = expression.args[0];
if (!first || first.kind !== "NumberLiteral") {
throw compileError("GRL_INVALID_ZONE", "z(...) requires a distance");
}
return { kind: "distance", value: normalizedNumber(first) };
}
if (isCallExpression(expression) && expression.callee === "cnt") {
const first = expression.args[0];
if (!first || first.kind !== "NumberLiteral") {
throw compileError("GRL_INVALID_ZONE", "cnt(...) requires a percent value");
}
return { kind: "cnt", value: normalizedNumber(first) };
}
throw compileError("GRL_INVALID_ZONE", "Unsupported zone expression");
}
export function compileOffsetExpression(expression: GrlOffsetExpression): OffsetSpec {
const xyz: [number, number, number] = [0, 0, 0];
for (const axis of expression.axes) {
const index = axis.axis === "x" ? 0 : axis.axis === "y" ? 1 : 2;
xyz[index] = normalizedNumber(axis.value);
}
return {
mode: expression.mode,
...(expression.frameName ? { frameId: expression.frameName } : {}),
xyz
};
}
function compileByType(typeName: string, expression: GrlExpression): CompiledGrlDataValue {
if (typeName === "speed") {
return compileSpeedExpression(expression);
}
if (typeName === "zone") {
return compileZoneExpression(expression);
}
if (typeName === "pose") {
return compilePoseExpression(expression);
}
if (typeName === "pose_target" || typeName === "joint_target") {
return compileTargetExpression(expression);
}
if (expression.kind === "OffsetExpression") {
return compileOffsetExpression(expression);
}
if (typeName === "tool" && isObjectExpression(expression, "tool")) {
return {
tcp: compilePoseExpression(requiredProperty(expression, "tcp")),
...(findProperty(expression, "mass") ? { mass: normalizedNumber(findProperty(expression, "mass") as GrlNumberLiteral) } : {}),
...(findProperty(expression, "cog") ? { cog: compileNumberArray(findProperty(expression, "cog")!) } : {})
};
}
if (typeName === "frame" && isObjectExpression(expression, "frame")) {
return {
origin: compilePoseExpression(requiredProperty(expression, "origin"))
};
}
if (expression.kind === "NumberLiteral") {
return normalizedNumber(expression);
}
if (expression.kind === "StringLiteral" || expression.kind === "BooleanLiteral") {
return expression.value;
}
if (expression.kind === "ArrayExpression") {
return compileNumberArray(expression);
}
return { kind: expression.kind };
}
function compilePoseExpression(expression: GrlExpression): Pose {
if (!isCallExpression(expression) || (expression.callee !== "pose" && expression.callee !== "poseq")) {
throw compileError("GRL_INVALID_POSE", "Expected pose(...) or poseq(...) expression");
}
const values = expression.args.map((arg) => {
if (arg.kind !== "NumberLiteral") {
throw compileError("GRL_INVALID_POSE", "Pose arguments must be numeric");
}
return normalizedNumber(arg);
});
if (expression.callee === "pose") {
if (values.length !== 6) {
throw compileError("GRL_INVALID_POSE", "pose(...) requires 6 arguments");
}
return {
position: [values[0]!, values[1]!, values[2]!],
quaternion: rpyToQuaternion([values[3]!, values[4]!, values[5]!])
};
}
if (values.length !== 7) {
throw compileError("GRL_INVALID_POSE", "poseq(...) requires 7 arguments");
}
return {
position: [values[0]!, values[1]!, values[2]!],
quaternion: [values[3]!, values[4]!, values[5]!, values[6]!]
};
}
function compileRobotConfig(expression: GrlExpression) {
if (!isCallExpression(expression) || expression.callee !== "robot_config") {
throw compileError("GRL_INVALID_CONFIG", "Expected robot_config(...)");
}
const values = expression.args.map((arg) => {
if (arg.kind !== "NumberLiteral") {
throw compileError("GRL_INVALID_CONFIG", "robot_config arguments must be numeric");
}
return arg.value as -1 | 0 | 1;
});
return {
...(values[0] !== undefined ? { shoulder: values[0] } : {}),
...(values[1] !== undefined ? { elbow: values[1] } : {}),
...(values[2] !== undefined ? { wrist: values[2] } : {})
};
}
function compileNumberArray(expression: GrlExpression): number[] {
if (expression.kind !== "ArrayExpression") {
throw compileError("GRL_INVALID_ARRAY", "Expected numeric array");
}
return expression.elements.map((element) => {
if (element.kind !== "NumberLiteral") {
throw compileError("GRL_INVALID_ARRAY", "Array elements must be numeric");
}
return normalizedNumber(element);
});
}
function compileAcceleration(expression: GrlCallExpression): { acceleration?: number } {
for (let index = 1; index < expression.args.length; index += 1) {
const marker = expression.args[index];
const value = expression.args[index + 1];
if (marker?.kind === "IdentifierExpression" && marker.name === "acc" && value?.kind === "NumberLiteral") {
return { acceleration: normalizedNumber(value) };
}
}
return {};
}
function normalizedNumber(expression: GrlNumberLiteral): number {
return expression.unit?.normalizedValue ?? expression.value;
}
function requiredProperty(expression: GrlObjectExpression, key: string): GrlExpression {
const property = findProperty(expression, key);
if (!property) {
throw compileError("GRL_MISSING_PROPERTY", `${expression.typeName} is missing ${key}`);
}
return property;
}
function findProperty(expression: GrlObjectExpression, key: string): GrlExpression | undefined {
return expression.properties.find((property) => property.key === key)?.value;
}
function findIdentifierName(expression: GrlObjectExpression, key: string): string | undefined {
const value = findProperty(expression, key);
return value?.kind === "IdentifierExpression" ? value.name : undefined;
}
function isObjectExpression(expression: GrlExpression, typeName: string): expression is GrlObjectExpression {
return expression.kind === "ObjectExpression" && expression.typeName === typeName;
}
function isCallExpression(expression: GrlExpression): expression is GrlCallExpression {
return expression.kind === "CallExpression";
}
function compileError(code: string, message: string): KdlStructuredError {
return new KdlStructuredError(code, message);
}

View File

@@ -0,0 +1,321 @@
import { KdlStructuredError } from "../../kdl/rpc.js";
import type { MotionDiagnostic, MotionSourceMap } from "../../kdl/types.js";
import type { GrlProcedureDeclaration, GrlRawTopLevelDeclaration, GrlTopLevelDeclaration } from "../ast/index.js";
import type {
AlarmInstruction,
ExceptionFlowInstruction,
RaiseInstruction,
RawProcedureStatement,
TryInstruction,
UnsupportedRuntimeInstruction
} from "../ir/index.js";
import type { GrlToken } from "../lexer/index.js";
type StopKeyword = "catch" | "finally" | "end";
export interface ExceptionAnalysis {
procedures: Record<string, ExceptionFlowInstruction[]>;
unsupported: UnsupportedRuntimeInstruction[];
diagnostics: MotionDiagnostic[];
}
export function analyzeExceptionSemantics(declarations: GrlTopLevelDeclaration[]): ExceptionAnalysis {
const diagnostics: MotionDiagnostic[] = [];
const procedures: Record<string, ExceptionFlowInstruction[]> = {};
const unsupported: UnsupportedRuntimeInstruction[] = [];
for (const declaration of declarations) {
if (declaration.kind === "ProcedureDeclaration") {
procedures[declaration.name] = parseProcedureExceptionFlow(declaration);
continue;
}
if (declaration.kind === "RawTopLevelDeclaration") {
const instruction = compileUnsupportedTopLevel(declaration);
if (instruction) {
unsupported.push(instruction);
diagnostics.push(diagnostic("warning", "GRL_P1_UNIMPLEMENTED", instruction.message, instruction.sourceMap));
}
}
}
return { procedures, unsupported, diagnostics };
}
export function parseProcedureExceptionFlow(procedure: GrlProcedureDeclaration): ExceptionFlowInstruction[] {
return parseExceptionFlowStatements(procedure.bodyTokens);
}
export function parseExceptionFlowStatements(tokens: GrlToken[]): ExceptionFlowInstruction[] {
return new ExceptionFlowParser(tokens).parseRoot();
}
class ExceptionFlowParser {
private current = 0;
constructor(private readonly tokens: GrlToken[]) {}
parseRoot(): ExceptionFlowInstruction[] {
return this.parseBlock(new Set());
}
private parseBlock(stopKeywords: Set<StopKeyword>): ExceptionFlowInstruction[] {
const instructions: ExceptionFlowInstruction[] = [];
while (!this.isAtEnd()) {
if (this.isStopKeyword(stopKeywords)) {
break;
}
if (this.matchKeyword("alarm")) {
instructions.push(this.finishAlarm(this.previous()));
continue;
}
if (this.matchKeyword("raise")) {
instructions.push(this.finishRaise(this.previous()));
continue;
}
if (this.matchKeyword("try")) {
instructions.push(this.finishTry(this.previous()));
continue;
}
if (this.matchKeyword("enable") || this.matchKeyword("disable")) {
instructions.push(this.finishUnsupportedInterrupt(this.previous()));
continue;
}
instructions.push(this.finishRawStatement());
}
return instructions;
}
private finishAlarm(start: GrlToken): AlarmInstruction {
const tokens = this.collectLineTokens(start);
const id = tokens[0];
if (!id || !isIdentifierLike(id)) {
throw exceptionError("GRL_ALARM_ID_MISSING", "alarm requires an alarm id", tokenSourceMap(start));
}
const message = tokens.find((token) => token.kind === "string");
const severityIndex = tokens.findIndex((token) => token.raw === "severity");
const severity = severityIndex >= 0 ? tokens[severityIndex + 1] : undefined;
return {
kind: "ALARM",
alarmId: id.raw,
...(message?.kind === "string" ? { message: message.value } : {}),
...(severity && isIdentifierLike(severity) ? { severity: severity.raw } : {}),
sourceMap: tokenSourceMap(start)
};
}
private finishRaise(start: GrlToken): RaiseInstruction {
const tokens = this.collectLineTokens(start);
const id = tokens[0];
if (!id || !isIdentifierLike(id)) {
throw exceptionError("GRL_RAISE_ID_MISSING", "raise requires an alarm id", tokenSourceMap(start));
}
return {
kind: "RAISE",
alarmId: id.raw,
sourceMap: tokenSourceMap(start)
};
}
private finishTry(start: GrlToken): TryInstruction {
const body = this.parseBlock(new Set(["catch", "finally", "end"]));
const catches: TryInstruction["catches"] = [];
let finallyBlock: TryInstruction["finally"];
while (this.matchKeyword("catch")) {
const catchStart = this.previous();
const header = this.collectLineTokens(catchStart);
const alarmId = header[0] && isIdentifierLike(header[0]) ? header[0].raw : undefined;
catches.push({
...(alarmId ? { alarmId } : {}),
body: this.parseBlock(new Set(["catch", "finally", "end"])),
sourceMap: tokenSourceMap(catchStart)
});
}
if (this.matchKeyword("finally")) {
const finallyStart = this.previous();
finallyBlock = {
body: this.parseBlock(new Set(["end"])),
sourceMap: tokenSourceMap(finallyStart)
};
}
this.consumeKeyword("end", "Expected end after try block");
if (catches.length === 0 && !finallyBlock) {
throw exceptionError("GRL_TRY_HANDLER_MISSING", "try requires catch or finally", tokenSourceMap(start));
}
return {
kind: "TRY",
body,
catches,
...(finallyBlock ? { finally: finallyBlock } : {}),
sourceMap: tokenSourceMap(start)
};
}
private finishUnsupportedInterrupt(start: GrlToken): UnsupportedRuntimeInstruction {
const tokens = this.collectLineTokens(start);
const hasInterrupt = tokens.some((token) => token.raw === "interrupt");
return {
kind: "UNSUPPORTED_RUNTIME",
feature: "interrupt",
message: hasInterrupt
? `${start.raw} interrupt is parsed but not executable in P0`
: `${start.raw} is parsed but not executable in P0`,
sourceMap: tokenSourceMap(start)
};
}
private finishRawStatement(): RawProcedureStatement {
const start = this.peek();
const tokens = this.collectLineTokens(start);
if (tokens.length === 0) {
const token = this.advance();
return {
kind: "RAW_STATEMENT",
text: token.raw,
tokens: [token],
sourceMap: tokenSourceMap(token)
};
}
return {
kind: "RAW_STATEMENT",
text: stringifyTokens(tokens),
tokens,
sourceMap: tokenSourceMap(start)
};
}
private collectLineTokens(start: GrlToken): GrlToken[] {
const tokens: GrlToken[] = [];
let parenDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
while (!this.isAtEnd()) {
const token = this.peek();
if (token.range.start.line !== start.range.start.line) {
break;
}
const consumed = this.advance();
tokens.push(consumed);
if (consumed.kind === "punctuation") {
if (consumed.raw === "(") parenDepth += 1;
if (consumed.raw === ")") parenDepth = Math.max(0, parenDepth - 1);
if (consumed.raw === "[") bracketDepth += 1;
if (consumed.raw === "]") bracketDepth = Math.max(0, bracketDepth - 1);
if (consumed.raw === "{") braceDepth += 1;
if (consumed.raw === "}") braceDepth = Math.max(0, braceDepth - 1);
}
if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
continue;
}
}
return tokens;
}
private isStopKeyword(stopKeywords: Set<StopKeyword>): boolean {
if (stopKeywords.size === 0) {
return false;
}
const token = this.peek();
return (token.kind === "keyword" || token.kind === "identifier") && stopKeywords.has(token.raw as StopKeyword);
}
private matchKeyword(keyword: string): boolean {
if (this.checkKeyword(keyword)) {
this.advance();
return true;
}
return false;
}
private checkKeyword(keyword: string): boolean {
const token = this.peek();
return (token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword;
}
private consumeKeyword(keyword: string, message: string): GrlToken {
if (this.checkKeyword(keyword)) {
return this.advance();
}
throw exceptionError("GRL_KEYWORD_EXPECTED", message, tokenSourceMap(this.peek()));
}
private advance(): GrlToken {
this.current += 1;
return this.previous();
}
private previous(): GrlToken {
return this.tokens[this.current - 1]!;
}
private peek(): GrlToken {
return this.tokens[this.current]!;
}
private isAtEnd(): boolean {
return this.current >= this.tokens.length;
}
}
function compileUnsupportedTopLevel(declaration: GrlRawTopLevelDeclaration): UnsupportedRuntimeInstruction | undefined {
if (declaration.declarationType === "trap") {
return {
kind: "UNSUPPORTED_RUNTIME",
feature: "trap",
message: `trap ${declaration.tokens[1]?.raw ?? ""}`.trim() + " is parsed but not executable in P0",
sourceMap: rangeSourceMap(declaration.range.start)
};
}
if (declaration.declarationType === "task") {
return {
kind: "UNSUPPORTED_RUNTIME",
feature: "task",
message: `task ${declaration.tokens[1]?.raw ?? ""}`.trim() + " is parsed but not executable in P0",
sourceMap: rangeSourceMap(declaration.range.start)
};
}
return undefined;
}
function isIdentifierLike(token: GrlToken): boolean {
return token.kind === "identifier" || token.kind === "keyword";
}
function stringifyTokens(tokens: GrlToken[]): string {
return tokens.map((token) => token.raw).join(" ");
}
function tokenSourceMap(token: GrlToken): MotionSourceMap {
return {
line: token.range.start.line,
column: token.range.start.column
};
}
function rangeSourceMap(position: { line: number; column: number }): MotionSourceMap {
return {
line: position.line,
column: position.column
};
}
function diagnostic(
severity: MotionDiagnostic["severity"],
code: string,
message: string,
sourceMap?: MotionSourceMap
): MotionDiagnostic {
return {
severity,
code,
message,
...(sourceMap ? { sourceMap } : {})
};
}
function exceptionError(code: string, message: string, sourceMap: MotionSourceMap): KdlStructuredError {
return new KdlStructuredError(code, message, [diagnostic("error", code, message, sourceMap)]);
}

View File

@@ -0,0 +1,380 @@
import { KdlStructuredError } from "../../kdl/rpc.js";
import type { MotionSourceMap } from "../../kdl/types.js";
import type {
IoFlowInstruction,
IoReference,
IoWriteInstruction,
OperationActionInstruction,
PathEventInstruction,
PulseInstruction,
WaitInstruction
} from "../ir/index.js";
import { lexGrl, type GrlToken } from "../lexer/index.js";
export interface IoMap {
aliases?: Record<string, IoReference>;
allowedRanges?: Partial<Record<IoReference["domain"], { min: number; max: number }>>;
}
export function parseIoFlowStatements(tokens: GrlToken[], ioMap: IoMap = {}): IoFlowInstruction[] {
const parser = new IoStatementParser(tokens, ioMap);
return parser.parseAll();
}
export function compilePathEventIo(event: PathEventInstruction, ioMap: IoMap = {}): IoFlowInstruction[] {
const tokens = event.data?.tokens;
if (Array.isArray(tokens)) {
return parseIoFlowStatements(tokens as GrlToken[], ioMap);
}
const statement = event.data?.statement;
if (typeof statement !== "string") {
return [];
}
return compileStatementString(statement, event.sourceMap, ioMap);
}
export function compileOperationActionIo(
action: OperationActionInstruction,
ioMap: IoMap = {}
): IoFlowInstruction[] {
if (Array.isArray(action.tokens)) {
return parseIoFlowStatements(action.tokens as GrlToken[], ioMap);
}
return compileStatementString(action.statement, action.sourceMap, ioMap);
}
class IoStatementParser {
private current = 0;
constructor(
private readonly tokens: GrlToken[],
private readonly ioMap: IoMap
) {}
parseAll(): IoFlowInstruction[] {
const instructions: IoFlowInstruction[] = [];
while (!this.isAtEnd()) {
if (this.checkKeyword("wait")) {
instructions.push(this.finishWait(this.advance()));
continue;
}
if (this.checkKeyword("pulse")) {
instructions.push(this.finishPulse(this.advance()));
continue;
}
if (this.checkIoStart()) {
instructions.push(this.finishIoWrite(this.peek()));
continue;
}
this.advance();
}
return instructions;
}
private finishIoWrite(start: GrlToken): IoWriteInstruction {
const target = this.parseIoReference();
this.consumeOperator("=", "Expected = in IO assignment");
const value = this.parseValue();
return {
kind: "IO_WRITE",
target,
value,
sourceMap: tokenSourceMap(start)
};
}
private finishWait(start: GrlToken): WaitInstruction {
const conditionTokens = this.collectUntilKeyword(["timeout", "on_timeout"]);
if (conditionTokens.length === 0) {
throw ioError("GRL_WAIT_CONDITION_MISSING", "wait requires a condition");
}
let timeout: number | undefined;
let onTimeout: WaitInstruction["onTimeout"];
if (this.matchKeyword("timeout")) {
timeout = this.parseDuration();
}
if (this.matchKeyword("on_timeout")) {
const kind = this.consumeIdentifier("Expected on_timeout action").raw;
if (kind === "alarm") {
const message = this.consumeString("Expected alarm message");
onTimeout = { kind: "alarm", value: message.value };
} else if (kind === "call") {
onTimeout = { kind: "call", value: this.collectRest().map((token) => token.raw).join(" ") };
} else {
throw ioError("GRL_WAIT_TIMEOUT_ACTION_INVALID", `Unsupported on_timeout action ${kind}`);
}
}
return {
kind: "WAIT",
condition: conditionTokens.map((token) => token.raw).join(" "),
...(timeout !== undefined ? { timeout } : {}),
...(onTimeout ? { onTimeout } : {}),
sourceMap: tokenSourceMap(start)
};
}
private finishPulse(start: GrlToken): PulseInstruction {
const target = this.parseIoReference();
this.consumeKeyword("duration", "Expected duration in pulse");
const duration = this.parseDuration();
return {
kind: "PULSE",
target,
duration,
trace: [
{ time: 0, action: "set", target, value: true },
{ time: duration, action: "reset", target, value: false }
],
sourceMap: tokenSourceMap(start)
};
}
private parseIoReference(): IoReference {
const io = this.consumeKeyword("io", "Expected io reference");
this.consumePunctuation(".", "Expected . after io");
const domain = this.consumeIdentifier("Expected IO domain").raw as IoReference["domain"];
if (!["di", "do", "ai", "ao", "gi", "go", "ri", "ro", "alias"].includes(domain)) {
throw ioError("GRL_IO_DOMAIN_INVALID", `Unsupported IO domain ${domain}`);
}
if (domain === "alias") {
this.consumePunctuation(".", "Expected . after io.alias");
const alias = this.consumeIdentifier("Expected IO alias").raw;
const mapped = this.ioMap.aliases?.[alias];
return mapped ?? { domain, alias, raw: `io.alias.${alias}` };
}
this.consumePunctuation("[", "Expected [ after IO domain");
const indexToken = this.consumeNumber("Expected IO index");
this.consumePunctuation("]", "Expected ] after IO index");
const index = indexToken.value;
this.validateIoIndex(domain, index, io);
return {
domain,
index,
raw: `io.${domain}[${index}]`
};
}
private validateIoIndex(domain: IoReference["domain"], index: number, token: GrlToken): void {
if (!Number.isInteger(index) || index < 0) {
throw ioError("GRL_IO_INDEX_INVALID", `Invalid IO index ${index}`);
}
const range = this.ioMap.allowedRanges?.[domain];
if (range && (index < range.min || index > range.max)) {
throw new KdlStructuredError(
"GRL_IO_ADDRESS_NOT_FOUND",
`IO address io.${domain}[${index}] is outside [${range.min}, ${range.max}]`,
[
{
severity: "error",
code: "GRL_IO_ADDRESS_NOT_FOUND",
message: `IO address io.${domain}[${index}] is outside [${range.min}, ${range.max}]`,
sourceMap: tokenSourceMap(token)
}
]
);
}
}
private parseValue(): boolean | number | string {
const token = this.advance();
if (token.kind === "keyword" && (token.raw === "true" || token.raw === "false")) {
return token.raw === "true";
}
if (token.kind === "number") {
return normalizedNumber(token);
}
if (token.kind === "string") {
return token.value;
}
if (token.kind === "identifier" || token.kind === "keyword") {
return token.raw;
}
throw ioError("GRL_IO_VALUE_INVALID", "Unsupported IO assignment value");
}
private parseDuration(): number {
const token = this.consumeNumber("Expected duration");
return normalizedNumber(token);
}
private collectUntilKeyword(keywords: string[]): GrlToken[] {
const tokens: GrlToken[] = [];
let parenDepth = 0;
while (!this.isAtEnd()) {
const token = this.peek();
if (parenDepth === 0) {
if ((token.kind === "keyword" || token.kind === "identifier") && keywords.includes(token.raw)) {
break;
}
if (tokens.length > 0 && this.isCurrentStatementStartAfter(tokens)) {
break;
}
}
const consumed = this.advance();
tokens.push(consumed);
if (consumed.kind === "punctuation" && consumed.raw === "(") {
parenDepth += 1;
} else if (consumed.kind === "punctuation" && consumed.raw === ")") {
parenDepth = Math.max(0, parenDepth - 1);
}
}
return tokens;
}
private collectRest(): GrlToken[] {
const tokens: GrlToken[] = [];
while (!this.isAtEnd()) {
if (tokens.length > 0 && this.isCurrentStatementStartAfter(tokens)) {
break;
}
tokens.push(this.advance());
}
return tokens;
}
private checkIoStart(): boolean {
return this.isIoStartAtCurrent();
}
private isCurrentStatementStartAfter(tokens: GrlToken[]): boolean {
const token = this.peek();
if (this.isKeywordLike(token, "wait") || this.isKeywordLike(token, "pulse")) {
return true;
}
if (!this.isIoStartAtCurrent()) {
return false;
}
const previous = tokens.at(-1);
return previous ? token.range.start.line > previous.range.end.line : true;
}
private isIoStartAtCurrent(): boolean {
return this.peek().raw === "io" && this.maybePeek(1)?.raw === ".";
}
private isKeywordLike(token: GrlToken, keyword: string): boolean {
return (token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword;
}
private matchKeyword(keyword: string): boolean {
const token = this.peek();
if ((token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword) {
this.advance();
return true;
}
return false;
}
private checkKeyword(keyword: string): boolean {
const token = this.peek();
return (token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword;
}
private consumeKeyword(keyword: string, message: string): GrlToken {
if (this.checkKeyword(keyword)) {
return this.advance();
}
throw ioError("GRL_KEYWORD_EXPECTED", message);
}
private consumeIdentifier(message: string): GrlToken {
const token = this.peek();
if (token.kind === "identifier" || token.kind === "keyword") {
return this.advance();
}
throw ioError("GRL_IDENTIFIER_EXPECTED", message);
}
private consumePunctuation(value: string, message: string): GrlToken {
const token = this.peek();
if (token.kind === "punctuation" && token.raw === value) {
return this.advance();
}
throw ioError("GRL_PUNCTUATION_EXPECTED", message);
}
private consumeOperator(value: string, message: string): GrlToken {
const token = this.peek();
if (token.kind === "operator" && token.raw === value) {
return this.advance();
}
throw ioError("GRL_OPERATOR_EXPECTED", message);
}
private consume(kind: GrlToken["kind"], message: string): GrlToken {
if (this.peek().kind === kind) {
return this.advance();
}
throw ioError("GRL_TOKEN_EXPECTED", message);
}
private consumeNumber(message: string): Extract<GrlToken, { kind: "number" }> {
const token = this.peek();
if (token.kind === "number") {
return this.advance() as Extract<GrlToken, { kind: "number" }>;
}
throw ioError("GRL_TOKEN_EXPECTED", message);
}
private consumeString(message: string): Extract<GrlToken, { kind: "string" }> {
const token = this.peek();
if (token.kind === "string") {
return this.advance() as Extract<GrlToken, { kind: "string" }>;
}
throw ioError("GRL_TOKEN_EXPECTED", message);
}
private advance(): GrlToken {
this.current += 1;
return this.previous();
}
private previous(): GrlToken {
return this.tokens[this.current - 1]!;
}
private peek(): GrlToken {
return this.tokens[this.current]!;
}
private maybePeek(distance = 0): GrlToken | undefined {
return this.tokens[this.current + distance];
}
private isAtEnd(): boolean {
return this.current >= this.tokens.length;
}
}
function compileStatementString(
statement: string,
_sourceMap: MotionSourceMap | undefined,
ioMap: IoMap
): IoFlowInstruction[] {
const tokens = lexGrl(statement, { preserveComments: false }).filter(
(token) => token.kind !== "eof" && token.kind !== "comment"
);
return parseIoFlowStatements(tokens, ioMap);
}
function normalizedNumber(token: GrlToken): number {
if (token.kind !== "number") {
throw ioError("GRL_NUMBER_EXPECTED", "Expected number");
}
return token.unit?.normalizedValue ?? token.value;
}
function tokenSourceMap(token: GrlToken): MotionSourceMap {
return {
line: token.range.start.line,
column: token.range.start.column
};
}
function ioError(code: string, message: string): KdlStructuredError {
return new KdlStructuredError(code, message);
}

View File

@@ -0,0 +1,978 @@
import { KdlStructuredError } from "../../kdl/rpc.js";
import { applyOffset } from "../../kdl/poseApi.js";
import type {
JointTarget,
JsonObject,
MoveCRequest,
MoveJRequest,
MoveLRequest,
MotionSegmentRequest,
PathEventRequest,
PathPlanRequest,
Pose,
PoseTarget,
SpeedSpec,
ZoneSpec
} from "../../kdl/types.js";
import type {
CompiledOperation,
CompiledPath,
MotionInstruction,
OperationActionInstruction,
OperationExecutionStep,
PathEventInstruction,
RunOperationInstruction,
RunPathInstruction
} from "../ir/index.js";
import { parseGrlExpression } from "../parser/index.js";
import type {
GrlExpression,
GrlOperationActionBlock,
GrlOperationDeclaration,
GrlOperationProcessBlock,
GrlPathDeclaration,
GrlPathDefaultsBlock,
GrlPathEvent,
GrlPathPoint,
GrlPathProperty,
GrlPathSourceBlock,
GrlProcedureDeclaration
} from "../ast/index.js";
import type { GrlToken } from "../lexer/index.js";
import {
compileOffsetExpression,
compileGrlDataDeclaration,
compileGrlTargetDeclaration,
compileSpeedExpression,
compileTargetExpression,
compileZoneExpression,
type CompiledGrlDataValue
} from "./compileData.js";
import type { GrlDataDeclaration, GrlTargetDeclaration } from "../ast/index.js";
export interface GrlMotionContext {
targets: Map<string, JointTarget | PoseTarget>;
speeds: Map<string, SpeedSpec>;
zones: Map<string, ZoneSpec>;
tools: Map<string, Pose>;
frames: Map<string, Pose>;
currentSpeed?: SpeedSpec;
currentZone?: ZoneSpec;
currentTool?: Pose;
currentFrame?: Pose;
}
export interface MotionRequestOptions {
startJoints: number[];
sampleTime: number;
}
interface PathDefaults {
speed?: SpeedSpec;
zone?: ZoneSpec;
tool?: Pose;
frame?: Pose;
}
export interface PathCompileOptions extends MotionRequestOptions {
speedOverride?: number;
stopOnError?: boolean;
}
export function buildMotionContext(declarations: Array<GrlDataDeclaration | GrlTargetDeclaration>): GrlMotionContext {
const context: GrlMotionContext = {
targets: new Map(),
speeds: new Map(),
zones: new Map(),
tools: new Map(),
frames: new Map()
};
for (const declaration of declarations) {
if (declaration.kind === "TargetDeclaration") {
const compiled = compileGrlTargetDeclaration(declaration);
context.targets.set(compiled.name, compiled.target);
continue;
}
const compiled = compileGrlDataDeclaration(declaration);
addCompiledData(context, compiled.name, compiled.typeName, compiled.value);
}
return context;
}
export function parseProcedureMotionInstructions(
procedure: GrlProcedureDeclaration,
context: GrlMotionContext
): MotionInstruction[] {
const parser = new MotionStatementParser(procedure.bodyTokens, context);
return parser.parseAll();
}
export function parseProcedureRunPathStatements(procedure: GrlProcedureDeclaration): RunPathInstruction[] {
const parser = new RunPathStatementParser(procedure.bodyTokens);
return parser.parseAll();
}
export function parseProcedureRunOperationStatements(procedure: GrlProcedureDeclaration): RunOperationInstruction[] {
const parser = new RunOperationStatementParser(procedure.bodyTokens);
return parser.parseAll();
}
export function compilePathToPlanRequest(
path: GrlPathDeclaration,
context: GrlMotionContext,
options: PathCompileOptions
): CompiledPath {
const defaults = compilePathDefaults(path.items.find((item): item is GrlPathDefaultsBlock => item.kind === "PathDefaultsBlock"), context);
const source = compilePathSource(path.items.find((item): item is GrlPathSourceBlock => item.kind === "PathSourceBlock"));
const points = path.items.filter((item): item is GrlPathPoint => item.kind === "PathPoint");
const events = path.items.filter((item): item is GrlPathEvent => item.kind === "PathEvent");
if (points.length === 0) {
throw motionError("GRL_PATH_EMPTY", `Path ${path.name} must contain at least one point`);
}
const pointIds = new Set<string>();
const motions: MotionInstruction[] = [];
const segments: MotionSegmentRequest[] = [];
for (const point of points) {
if (pointIds.has(point.id)) {
throw motionError("GRL_PATH_POINT_DUPLICATE", `Path ${path.name} contains duplicate point ${point.id}`);
}
pointIds.add(point.id);
const pointContext = cloneMotionContext(context);
applyPathDefaults(pointContext, defaults);
const [motion] = new MotionStatementParser(point.motionTokens, pointContext).parseAll();
if (!motion) {
throw motionError("GRL_PATH_POINT_MOTION_MISSING", `Path point ${point.id} has no motion`);
}
const instruction: MotionInstruction = {
...motion,
id: point.id,
pathId: path.name,
pointId: point.id,
...(source ? { source } : {})
};
motions.push(instruction);
segments.push(motionToSegment(instruction, path.name, source));
}
const compiledEvents = events.map((event, index) => compilePathEvent(event, index, pointIds));
const request: PathPlanRequest = {
pathId: path.name,
startJoints: options.startJoints,
segments,
...(compiledEvents.length > 0 ? { events: compiledEvents } : {}),
sampleTime: options.sampleTime,
...(options.speedOverride !== undefined ? { speedOverride: options.speedOverride } : {}),
...(options.stopOnError !== undefined ? { stopOnError: options.stopOnError } : {}),
...(source ? { source } : {})
};
return {
pathId: path.name,
request,
motions,
events: compiledEvents
};
}
export function compileOperation(
operation: GrlOperationDeclaration,
paths: Map<string, GrlPathDeclaration>
): CompiledOperation {
if (!paths.has(operation.pathName)) {
throw motionError(
"GRL_OPERATION_PATH_NOT_FOUND",
`Operation ${operation.name} references unknown path ${operation.pathName}`
);
}
const processBlock = operation.items.find(
(item): item is GrlOperationProcessBlock => item.kind === "OperationProcessBlock"
);
const actionBlocks = operation.items.filter(
(item): item is GrlOperationActionBlock => item.kind === "OperationActionBlock"
);
return {
operationId: operation.name,
kind: operation.operationKind,
pathId: operation.pathName,
process: processBlock ? compileProcessBlock(processBlock) : {},
startActions: actionBlocks
.filter((item) => item.actionKind === "start_action")
.map((item) => compileOperationAction(operation.name, item)),
endActions: actionBlocks
.filter((item) => item.actionKind === "end_action")
.map((item) => compileOperationAction(operation.name, item))
};
}
export function expandRunOperation(
run: RunOperationInstruction,
operations: Map<string, CompiledOperation>
): OperationExecutionStep[] {
const operation = operations.get(run.operationId);
if (!operation) {
throw motionError("GRL_OPERATION_NOT_FOUND", `Unknown operation ${run.operationId}`);
}
return [
...operation.startActions,
{
kind: "RUN_PATH",
pathId: operation.pathId,
...(run.sourceMap ? { sourceMap: run.sourceMap } : {})
},
...operation.endActions
];
}
export function compileMotionToKdlRequest(
instruction: MotionInstruction,
options: MotionRequestOptions
): MoveJRequest | MoveLRequest | MoveCRequest {
if (instruction.kind === "MOVEJ") {
if (!instruction.target) {
throw motionError("GRL_MOTION_TARGET_MISSING", "MOVEJ requires target");
}
return {
startJoints: options.startJoints,
target: instruction.target,
speed: instruction.speed,
zone: instruction.zone,
...(instruction.tool ? { tool: instruction.tool } : {}),
...(instruction.frame ? { frame: instruction.frame } : {}),
sampleTime: options.sampleTime,
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
};
}
if (instruction.kind === "MOVEL") {
if (!instruction.target || !isPoseTarget(instruction.target)) {
throw motionError("GRL_MOTION_TARGET_TYPE", "MOVEL requires PoseTarget");
}
return {
startJoints: options.startJoints,
target: instruction.target,
speed: instruction.speed,
zone: instruction.zone,
...(instruction.tool ? { tool: instruction.tool } : {}),
...(instruction.frame ? { frame: instruction.frame } : {}),
sampleTime: options.sampleTime,
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
};
}
if (!instruction.via || !instruction.target || !isPoseTarget(instruction.target)) {
throw motionError("GRL_MOTION_TARGET_TYPE", "MOVEC requires via and target PoseTarget");
}
return {
startJoints: options.startJoints,
via: instruction.via,
target: instruction.target,
speed: instruction.speed,
zone: instruction.zone,
...(instruction.tool ? { tool: instruction.tool } : {}),
...(instruction.frame ? { frame: instruction.frame } : {}),
sampleTime: options.sampleTime,
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
};
}
function motionToSegment(
instruction: MotionInstruction,
pathId: string,
source?: Record<string, unknown>
): MotionSegmentRequest {
if (instruction.kind === "MOVEJ") {
if (!instruction.target) {
throw motionError("GRL_MOTION_TARGET_MISSING", "MOVEJ requires target");
}
const targetId = targetIdOf(instruction.target);
return {
id: instruction.pointId ?? instruction.id ?? `${pathId}_${instruction.kind.toLowerCase()}`,
motion: "MOVEJ",
target: instruction.target,
...(targetId ? { targetId } : {}),
speed: instruction.speed,
zone: instruction.zone,
...(instruction.tool ? { tool: instruction.tool } : {}),
...(instruction.frame ? { frame: instruction.frame } : {}),
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {}),
...(source ? { source } : {})
};
}
if (instruction.kind === "MOVEL") {
if (!instruction.target || !isPoseTarget(instruction.target)) {
throw motionError("GRL_MOTION_TARGET_TYPE", "MOVEL requires PoseTarget");
}
const targetId = targetIdOf(instruction.target);
return {
id: instruction.pointId ?? instruction.id ?? `${pathId}_${instruction.kind.toLowerCase()}`,
motion: "MOVEL",
target: instruction.target,
...(targetId ? { targetId } : {}),
speed: instruction.speed,
zone: instruction.zone,
...(instruction.tool ? { tool: instruction.tool } : {}),
...(instruction.frame ? { frame: instruction.frame } : {}),
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {}),
...(source ? { source } : {})
};
}
if (!instruction.via || !instruction.target || !isPoseTarget(instruction.target)) {
throw motionError("GRL_MOTION_TARGET_TYPE", "MOVEC requires via and target PoseTarget");
}
const targetId = targetIdOf(instruction.target);
return {
id: instruction.pointId ?? instruction.id ?? `${pathId}_${instruction.kind.toLowerCase()}`,
motion: "MOVEC",
via: instruction.via,
target: instruction.target,
...(targetId ? { targetId } : {}),
speed: instruction.speed,
zone: instruction.zone,
...(instruction.tool ? { tool: instruction.tool } : {}),
...(instruction.frame ? { frame: instruction.frame } : {}),
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {}),
...(source ? { source } : {})
};
}
function addCompiledData(
context: GrlMotionContext,
name: string,
typeName: string,
value: CompiledGrlDataValue
): void {
if (typeName === "speed") {
context.speeds.set(name, value as SpeedSpec);
} else if (typeName === "zone") {
context.zones.set(name, value as ZoneSpec);
} else if (typeName === "tool") {
context.tools.set(name, (value as { tcp: Pose }).tcp);
} else if (typeName === "frame") {
context.frames.set(name, (value as { origin: Pose }).origin);
}
}
class MotionStatementParser {
private current = 0;
constructor(
private readonly tokens: GrlToken[],
private readonly context: GrlMotionContext
) {}
parseAll(): MotionInstruction[] {
const instructions: MotionInstruction[] = [];
while (!this.isAtEnd()) {
if (this.matchKeyword("set_tool")) {
this.context.currentTool = this.resolveNamedPose(this.consumeIdentifier("Expected tool name"), "tool");
continue;
}
if (this.matchKeyword("set_frame")) {
this.context.currentFrame = this.resolveNamedPose(this.consumeIdentifier("Expected frame name"), "frame");
continue;
}
if (this.matchKeyword("set_speed")) {
this.context.currentSpeed = this.parseSpeedArgument();
continue;
}
if (this.matchKeyword("set_zone")) {
this.context.currentZone = this.parseZoneArgument();
continue;
}
if (this.matchKeyword("movej")) {
instructions.push(this.finishMoveJ(this.previous()));
continue;
}
if (this.matchKeyword("movel")) {
instructions.push(this.finishMoveL(this.previous()));
continue;
}
if (this.matchKeyword("movec")) {
instructions.push(this.finishMoveC(this.previous()));
continue;
}
this.advance();
}
return instructions;
}
private finishMoveJ(start: GrlToken): MotionInstruction {
const target = this.parseTargetArgument();
const params = this.parseMotionParams();
return this.withDefaults({
kind: "MOVEJ",
target,
...params,
sourceMap: tokenSourceMap(start)
});
}
private finishMoveL(start: GrlToken): MotionInstruction {
const target = this.parseTargetArgument();
if (!isPoseTarget(target)) {
throw motionError("GRL_MOTION_TARGET_TYPE", "MOVEL requires PoseTarget");
}
const params = this.parseMotionParams();
return this.withDefaults({
kind: "MOVEL",
target,
...params,
sourceMap: tokenSourceMap(start)
});
}
private finishMoveC(start: GrlToken): MotionInstruction {
this.consumeKeyword("via", "Expected via in MOVEC");
const via = this.parseTargetArgument();
if (!isPoseTarget(via)) {
throw motionError("GRL_MOTION_TARGET_TYPE", "MOVEC via requires PoseTarget");
}
this.consumeKeyword("target", "Expected target in MOVEC");
const target = this.parseTargetArgument();
if (!isPoseTarget(target)) {
throw motionError("GRL_MOTION_TARGET_TYPE", "MOVEC target requires PoseTarget");
}
const params = this.parseMotionParams();
return this.withDefaults({
kind: "MOVEC",
via,
target,
...params,
sourceMap: tokenSourceMap(start)
});
}
private parseMotionParams(): Partial<MotionInstruction> {
const params: Partial<MotionInstruction> = {};
while (!this.isAtEnd() && !this.isMotionStart(this.peek())) {
if (this.matchKeyword("speed")) {
params.speed = this.parseSpeedArgument();
} else if (this.matchKeyword("zone")) {
params.zone = this.parseZoneArgument();
} else if (this.matchKeyword("tool")) {
params.tool = this.resolveNamedPose(this.consumeIdentifier("Expected tool name"), "tool");
} else if (this.matchKeyword("frame")) {
params.frame = this.resolveNamedPose(this.consumeIdentifier("Expected frame name"), "frame");
} else {
break;
}
}
return params;
}
private parseTargetArgument(): JointTarget | PoseTarget {
const expressionTokens = this.collectExpressionUntilParamKeyword();
const expression = parseGrlExpression(expressionTokens);
return this.resolveTargetExpression(expression);
}
private parseSpeedArgument(): SpeedSpec {
const token = this.peek();
if ((token.kind === "identifier" || token.kind === "keyword") && this.context.speeds.has(token.raw)) {
this.advance();
return this.context.speeds.get(token.raw)!;
}
return compileSpeedExpression(parseGrlExpression(this.collectExpressionUntilParamKeyword()));
}
private parseZoneArgument(): ZoneSpec {
const token = this.peek();
if ((token.kind === "identifier" || token.kind === "keyword") && this.context.zones.has(token.raw)) {
this.advance();
return this.context.zones.get(token.raw)!;
}
return compileZoneExpression(parseGrlExpression(this.collectExpressionUntilParamKeyword()));
}
private resolveTargetExpression(expression: GrlExpression): JointTarget | PoseTarget {
if (expression.kind === "OffsetExpression") {
const base = this.resolveTargetExpression(expression.base);
if (!isPoseTarget(base)) {
throw motionError("GRL_MOTION_TARGET_TYPE", "offset target requires PoseTarget");
}
return applyOffset(base, compileOffsetExpression(expression));
}
if (expression.kind === "IdentifierExpression") {
const target = this.context.targets.get(expression.name);
if (!target) {
throw motionError("GRL_TARGET_NOT_FOUND", `Unknown target ${expression.name}`);
}
return target;
}
return compileTargetExpression(expression);
}
private withDefaults(instruction: Partial<MotionInstruction> & Pick<MotionInstruction, "kind">): MotionInstruction {
const speed = instruction.speed ?? this.context.currentSpeed;
const zone = instruction.zone ?? this.context.currentZone;
if (!speed) {
throw motionError("GRL_SPEED_UNRESOLVED", `${instruction.kind} has no speed`);
}
if (!zone) {
throw motionError("GRL_ZONE_UNRESOLVED", `${instruction.kind} has no zone`);
}
return {
kind: instruction.kind,
...(instruction.target ? { target: instruction.target } : {}),
...(instruction.via ? { via: instruction.via } : {}),
speed,
zone,
...(instruction.tool ?? this.context.currentTool ? { tool: instruction.tool ?? this.context.currentTool } : {}),
...(instruction.frame ?? this.context.currentFrame ? { frame: instruction.frame ?? this.context.currentFrame } : {}),
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
};
}
private resolveNamedPose(name: string, kind: "tool" | "frame"): Pose {
const source = kind === "tool" ? this.context.tools : this.context.frames;
const pose = source.get(name);
if (!pose) {
throw motionError(kind === "tool" ? "GRL_TOOL_NOT_FOUND" : "GRL_FRAME_NOT_FOUND", `Unknown ${kind} ${name}`);
}
return pose;
}
private collectExpressionUntilParamKeyword(): GrlToken[] {
const tokens: GrlToken[] = [];
let parenDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
const startLine = this.peek().range.start.line;
while (!this.isAtEnd()) {
const token = this.peek();
if (tokens.length > 0 && token.range.start.line > startLine && this.isStatementStart(token)) {
break;
}
if (
parenDepth === 0 &&
bracketDepth === 0 &&
braceDepth === 0 &&
this.isExpressionTerminator(token)
) {
break;
}
const consumed = this.advance();
tokens.push(consumed);
if (consumed.kind === "punctuation") {
if (consumed.raw === "(") parenDepth += 1;
if (consumed.raw === ")") parenDepth -= 1;
if (consumed.raw === "[") bracketDepth += 1;
if (consumed.raw === "]") bracketDepth -= 1;
if (consumed.raw === "{") braceDepth += 1;
if (consumed.raw === "}") braceDepth -= 1;
}
}
if (tokens.length === 0) {
throw motionError("GRL_EXPRESSION_MISSING", "Expected motion expression");
}
return tokens;
}
private isExpressionTerminator(token: GrlToken): boolean {
return (
this.isMotionStart(token) ||
((token.kind === "keyword" || token.kind === "identifier") &&
["speed", "zone", "tool", "frame", "via", "target"].includes(token.raw))
);
}
private isMotionStart(token: GrlToken): boolean {
return (
(token.kind === "keyword" || token.kind === "identifier") &&
["movej", "movel", "movec", "set_tool", "set_frame", "set_speed", "set_zone"].includes(token.raw)
);
}
private isStatementStart(token: GrlToken): boolean {
return (
(token.kind === "keyword" || token.kind === "identifier") &&
[
"movej",
"movel",
"movec",
"set_tool",
"set_frame",
"set_speed",
"set_zone",
"io",
"wait",
"pulse",
"run_path",
"run_operation",
"if",
"elseif",
"else",
"while",
"for",
"switch",
"case",
"default",
"break",
"continue",
"label",
"jump",
"call",
"return",
"alarm",
"raise",
"try",
"catch",
"finally",
"enable",
"disable",
"end"
].includes(token.raw)
);
}
private consumeIdentifier(message: string): string {
const token = this.peek();
if (token.kind === "identifier" || token.kind === "keyword") {
this.advance();
return token.raw;
}
throw motionError("GRL_IDENTIFIER_EXPECTED", message);
}
private consumeKeyword(keyword: string, message: string): void {
if (!this.matchKeyword(keyword)) {
throw motionError("GRL_KEYWORD_EXPECTED", message);
}
}
private matchKeyword(keyword: string): boolean {
const token = this.peek();
if ((token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword) {
this.advance();
return true;
}
return false;
}
private advance(): GrlToken {
this.current += 1;
return this.previous();
}
private previous(): GrlToken {
return this.tokens[this.current - 1]!;
}
private peek(): GrlToken {
return this.tokens[this.current]!;
}
private isAtEnd(): boolean {
return this.current >= this.tokens.length;
}
}
class RunPathStatementParser {
private current = 0;
constructor(private readonly tokens: GrlToken[]) {}
parseAll(): RunPathInstruction[] {
const instructions: RunPathInstruction[] = [];
while (!this.isAtEnd()) {
if (this.matchKeyword("run_path")) {
const start = this.previous();
const path = this.consumeIdentifier("Expected path name after run_path");
instructions.push({
kind: "RUN_PATH",
pathId: path.raw,
sourceMap: tokenSourceMap(start)
});
continue;
}
this.advance();
}
return instructions;
}
private consumeIdentifier(message: string): GrlToken {
const token = this.peek();
if (token.kind === "identifier" || token.kind === "keyword") {
return this.advance();
}
throw motionError("GRL_IDENTIFIER_EXPECTED", message);
}
private matchKeyword(keyword: string): boolean {
const token = this.peek();
if ((token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword) {
this.advance();
return true;
}
return false;
}
private advance(): GrlToken {
this.current += 1;
return this.previous();
}
private previous(): GrlToken {
return this.tokens[this.current - 1]!;
}
private peek(): GrlToken {
return this.tokens[this.current]!;
}
private isAtEnd(): boolean {
return this.current >= this.tokens.length;
}
}
class RunOperationStatementParser {
private current = 0;
constructor(private readonly tokens: GrlToken[]) {}
parseAll(): RunOperationInstruction[] {
const instructions: RunOperationInstruction[] = [];
while (!this.isAtEnd()) {
if (this.matchKeyword("run_operation")) {
const start = this.previous();
const operation = this.consumeIdentifier("Expected operation name after run_operation");
instructions.push({
kind: "RUN_OPERATION",
operationId: operation.raw,
sourceMap: tokenSourceMap(start)
});
continue;
}
this.advance();
}
return instructions;
}
private consumeIdentifier(message: string): GrlToken {
const token = this.peek();
if (token.kind === "identifier" || token.kind === "keyword") {
return this.advance();
}
throw motionError("GRL_IDENTIFIER_EXPECTED", message);
}
private matchKeyword(keyword: string): boolean {
const token = this.peek();
if ((token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword) {
this.advance();
return true;
}
return false;
}
private advance(): GrlToken {
this.current += 1;
return this.previous();
}
private previous(): GrlToken {
return this.tokens[this.current - 1]!;
}
private peek(): GrlToken {
return this.tokens[this.current]!;
}
private isAtEnd(): boolean {
return this.current >= this.tokens.length;
}
}
function compileProcessBlock(block: GrlOperationProcessBlock): Record<string, unknown> {
return Object.fromEntries(block.properties.map((property) => [property.key, compileLiteralValue(property.value)]));
}
function compileOperationAction(
operationId: string,
block: GrlOperationActionBlock
): OperationActionInstruction {
const first = block.actionTokens[0] ?? block.actionTokens[block.actionTokens.length - 1]!;
return {
kind: "ACTION",
actionKind: block.actionKind,
operationId,
statement: block.actionTokens.map((token) => token.raw).join(" "),
tokens: block.actionTokens,
sourceMap: tokenSourceMap(first)
};
}
function compilePathDefaults(block: GrlPathDefaultsBlock | undefined, context: GrlMotionContext): PathDefaults {
const defaults: PathDefaults = {};
if (!block) {
return defaults;
}
for (const property of block.properties) {
if (property.key === "speed") {
defaults.speed = resolveSpeed(property.value, context);
} else if (property.key === "zone") {
defaults.zone = resolveZone(property.value, context);
} else if (property.key === "tool") {
defaults.tool = resolveNamedPoseFromExpression(property.value, context, "tool");
} else if (property.key === "frame") {
defaults.frame = resolveNamedPoseFromExpression(property.value, context, "frame");
}
}
return defaults;
}
function compilePathSource(block: GrlPathSourceBlock | undefined): JsonObject | undefined {
if (!block) {
return undefined;
}
return Object.fromEntries(block.properties.map((property) => [property.key, compileLiteralValue(property.value)]));
}
function compilePathEvent(
event: GrlPathEvent,
index: number,
pointIds: Set<string>
): PathEventInstruction & PathEventRequest {
if (!pointIds.has(event.pointId)) {
throw motionError("GRL_PATH_EVENT_POINT_NOT_FOUND", `Path event references unknown point ${event.pointId}`);
}
return {
id: `event_${index}`,
timing: event.timing,
pointId: event.pointId,
...(event.distance ? { distance: normalizedNumber(event.distance) } : {}),
kind: event.actionTokens[0]?.raw ?? "statement",
sourceMap: tokenSourceMap(event.actionTokens[0] ?? event.actionTokens[event.actionTokens.length - 1]!),
data: {
statement: event.actionTokens.map((token) => token.raw).join(" "),
tokens: event.actionTokens
}
};
}
function cloneMotionContext(context: GrlMotionContext): GrlMotionContext {
return {
targets: context.targets,
speeds: context.speeds,
zones: context.zones,
tools: context.tools,
frames: context.frames,
...(context.currentSpeed ? { currentSpeed: context.currentSpeed } : {}),
...(context.currentZone ? { currentZone: context.currentZone } : {}),
...(context.currentTool ? { currentTool: context.currentTool } : {}),
...(context.currentFrame ? { currentFrame: context.currentFrame } : {})
};
}
function applyPathDefaults(context: GrlMotionContext, defaults: PathDefaults): void {
if (defaults.speed) {
context.currentSpeed = defaults.speed;
}
if (defaults.zone) {
context.currentZone = defaults.zone;
}
if (defaults.tool) {
context.currentTool = defaults.tool;
}
if (defaults.frame) {
context.currentFrame = defaults.frame;
}
}
function resolveSpeed(expression: GrlExpression, context: GrlMotionContext): SpeedSpec {
if (expression.kind === "IdentifierExpression" && context.speeds.has(expression.name)) {
return context.speeds.get(expression.name)!;
}
return compileSpeedExpression(expression);
}
function resolveZone(expression: GrlExpression, context: GrlMotionContext): ZoneSpec {
if (expression.kind === "IdentifierExpression" && context.zones.has(expression.name)) {
return context.zones.get(expression.name)!;
}
return compileZoneExpression(expression);
}
function resolveNamedPoseFromExpression(
expression: GrlExpression,
context: GrlMotionContext,
kind: "tool" | "frame"
): Pose {
if (expression.kind !== "IdentifierExpression") {
throw motionError(kind === "tool" ? "GRL_TOOL_NOT_FOUND" : "GRL_FRAME_NOT_FOUND", `Path ${kind} must reference a named ${kind}`);
}
const source = kind === "tool" ? context.tools : context.frames;
const pose = source.get(expression.name);
if (!pose) {
throw motionError(kind === "tool" ? "GRL_TOOL_NOT_FOUND" : "GRL_FRAME_NOT_FOUND", `Unknown ${kind} ${expression.name}`);
}
return pose;
}
function compileLiteralValue(expression: GrlExpression): unknown {
if (expression.kind === "NumberLiteral") {
return normalizedNumber(expression);
}
if (expression.kind === "StringLiteral" || expression.kind === "BooleanLiteral") {
return expression.value;
}
if (expression.kind === "IdentifierExpression") {
return expression.name;
}
if (expression.kind === "ArrayExpression") {
return expression.elements.map(compileLiteralValue);
}
if (expression.kind === "ObjectExpression") {
return Object.fromEntries(expression.properties.map((property) => [property.key, compileLiteralValue(property.value)]));
}
if (expression.kind === "CallExpression") {
return {
callee: expression.callee,
args: expression.args.map(compileLiteralValue)
};
}
return {
kind: expression.kind
};
}
function tokenSourceMap(token: GrlToken) {
return {
line: token.range.start.line,
column: token.range.start.column
};
}
function isPoseTarget(target: JointTarget | PoseTarget): target is PoseTarget {
return "pose" in target;
}
function targetIdOf(target: JointTarget | PoseTarget): string | undefined {
return target.id;
}
function normalizedNumber(expression: { value: number; unit?: { normalizedValue: number } }): number {
return expression.unit?.normalizedValue ?? expression.value;
}
function motionError(code: string, message: string): KdlStructuredError {
return new KdlStructuredError(code, message);
}

View File

@@ -0,0 +1,647 @@
import { KdlStructuredError } from "../../kdl/rpc.js";
import type { MotionDiagnostic, MotionSourceMap } from "../../kdl/types.js";
import type {
GrlDataDeclaration,
GrlFunctionDeclaration,
GrlProcedureDeclaration,
GrlTargetDeclaration,
GrlTopLevelDeclaration
} from "../ast/index.js";
import type {
CallInstruction,
ControlExpression,
FunctionSignature,
ProcedureFlowInstruction,
ProcedureSignature,
ProcFunctionAnalysis,
ReturnInstruction,
RoutineParameter,
RoutineParameterDirection
} from "../ir/index.js";
import type { GrlToken } from "../lexer/index.js";
import { parseControlFlowStatements } from "./compileControlFlow.js";
type RoutineDeclaration = GrlProcedureDeclaration | GrlFunctionDeclaration;
type RoutineSignature = ProcedureSignature | FunctionSignature;
type RoutineKind = "proc" | "func";
type InferredType = string | "unknown";
interface AnalysisContext {
routineName: string;
routineKind: RoutineKind;
returnType?: string;
parameters: RoutineParameter[];
symbols: Map<string, string>;
outerNames: Set<string>;
signatures: Map<string, RoutineSignature>;
diagnostics: MotionDiagnostic[];
calls: CallInstruction[];
returns: ReturnInstruction[];
}
interface FlowResult {
normalExits: Set<string>[];
returnExits: Set<string>[];
}
export function analyzeProcFunctionSemantics(declarations: GrlTopLevelDeclaration[]): ProcFunctionAnalysis {
const procedures = declarations.filter(
(decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration"
);
const functions = declarations.filter(
(decl): decl is GrlFunctionDeclaration => decl.kind === "FunctionDeclaration"
);
const globalNames = collectGlobalNames(declarations);
const diagnostics: MotionDiagnostic[] = [];
const signatures = new Map<string, RoutineSignature>();
const procedureSignatures = procedures.map((procedure) => compileProcedureSignature(procedure, globalNames, diagnostics));
const functionSignatures = functions.map((func) => compileFunctionSignature(func, globalNames, diagnostics));
for (const signature of [...procedureSignatures, ...functionSignatures]) {
if (signatures.has(signature.name)) {
throw routineError("GRL_ROUTINE_DUPLICATE", `Duplicate routine ${signature.name}`, signature.sourceMap);
}
signatures.set(signature.name, signature);
}
const calls: CallInstruction[] = [];
const returns: ReturnInstruction[] = [];
for (const procedure of procedures) {
analyzeRoutineBody(procedure, "proc", undefined, signatures, globalNames, diagnostics, calls, returns);
}
for (const func of functions) {
analyzeRoutineBody(func, "func", func.returnType, signatures, globalNames, diagnostics, calls, returns);
}
return {
procedures: procedureSignatures,
functions: functionSignatures,
calls,
returns,
diagnostics
};
}
export function compileProcedureSignature(
procedure: GrlProcedureDeclaration,
globalNames: Set<string> = new Set(),
diagnostics: MotionDiagnostic[] = []
): ProcedureSignature {
return {
kind: "PROC_SIGNATURE",
name: procedure.name,
parameters: parseRoutineParameters(procedure.params, procedure.name, globalNames, diagnostics),
sourceMap: rangeSourceMap(procedure.range.start)
};
}
export function compileFunctionSignature(
func: GrlFunctionDeclaration,
globalNames: Set<string> = new Set(),
diagnostics: MotionDiagnostic[] = []
): FunctionSignature {
return {
kind: "FUNC_SIGNATURE",
name: func.name,
returnType: func.returnType,
parameters: parseRoutineParameters(func.params, func.name, globalNames, diagnostics),
sourceMap: rangeSourceMap(func.range.start)
};
}
function analyzeRoutineBody(
declaration: RoutineDeclaration,
routineKind: RoutineKind,
returnType: string | undefined,
signatures: Map<string, RoutineSignature>,
globalNames: Set<string>,
diagnostics: MotionDiagnostic[],
calls: CallInstruction[],
returns: ReturnInstruction[]
): void {
const signature = signatures.get(declaration.name);
if (!signature) {
throw routineError("GRL_ROUTINE_NOT_FOUND", `Missing routine signature ${declaration.name}`);
}
const symbols = new Map(signature.parameters.map((parameter) => [parameter.name, parameter.typeName]));
const context: AnalysisContext = {
routineName: declaration.name,
routineKind,
...(returnType ? { returnType } : {}),
parameters: signature.parameters,
symbols,
outerNames: globalNames,
signatures,
diagnostics,
calls,
returns
};
const flow = parseControlFlowStatements(declaration.bodyTokens);
const result = analyzeFlow(flow, new Set(), context);
const outParameters = signature.parameters.filter((parameter) => parameter.direction === "out");
for (const exit of [...result.normalExits, ...result.returnExits]) {
for (const parameter of outParameters) {
if (!exit.has(parameter.name)) {
throw routineError(
"GRL_OUT_PARAM_NOT_ASSIGNED",
`out parameter ${parameter.name} is not assigned on all normal return paths`,
parameter.sourceMap
);
}
}
}
if (routineKind === "func" && returnType !== "void" && result.normalExits.length > 0) {
throw routineError("GRL_FUNC_MISSING_RETURN", `Function ${declaration.name} does not return on all normal paths`, rangeSourceMap(declaration.range.start));
}
}
function analyzeFlow(flow: ProcedureFlowInstruction[], incoming: Set<string>, context: AnalysisContext): FlowResult {
let normalStates: Set<string>[] = [new Set(incoming)];
const returnStates: Set<string>[] = [];
for (const instruction of flow) {
const nextNormalStates: Set<string>[] = [];
for (const state of normalStates) {
const result = analyzeInstruction(instruction, state, context);
nextNormalStates.push(...result.normalExits);
returnStates.push(...result.returnExits);
}
normalStates = nextNormalStates;
if (normalStates.length === 0) {
break;
}
}
return {
normalExits: normalStates,
returnExits: returnStates
};
}
function analyzeInstruction(
instruction: ProcedureFlowInstruction,
incoming: Set<string>,
context: AnalysisContext
): FlowResult {
if (instruction.kind === "RAW_STATEMENT") {
return analyzeRawStatement(instruction.tokens as GrlToken[] | undefined, incoming, context);
}
if (instruction.kind === "IF") {
const normalExits: Set<string>[] = [];
const returnExits: Set<string>[] = [];
for (const branch of instruction.branches) {
const result = analyzeFlow(branch.body, new Set(incoming), context);
normalExits.push(...result.normalExits);
returnExits.push(...result.returnExits);
}
if (!instruction.branches.some((branch) => branch.branchKind === "else")) {
normalExits.push(new Set(incoming));
}
return { normalExits, returnExits };
}
if (instruction.kind === "WHILE" || instruction.kind === "FOR") {
const body = analyzeFlow(instruction.body, new Set(incoming), context);
return {
normalExits: [new Set(incoming), ...body.normalExits],
returnExits: body.returnExits
};
}
if (instruction.kind === "SWITCH") {
const normalExits: Set<string>[] = [];
const returnExits: Set<string>[] = [];
for (const switchCase of instruction.cases) {
const result = analyzeFlow(switchCase.body, new Set(incoming), context);
normalExits.push(...result.normalExits);
returnExits.push(...result.returnExits);
}
if (!instruction.cases.some((switchCase) => switchCase.caseKind === "default")) {
normalExits.push(new Set(incoming));
}
return { normalExits, returnExits };
}
return {
normalExits: [new Set(incoming)],
returnExits: []
};
}
function analyzeRawStatement(
tokens: GrlToken[] | undefined,
incoming: Set<string>,
context: AnalysisContext
): FlowResult {
if (!tokens || tokens.length === 0) {
return { normalExits: [new Set(incoming)], returnExits: [] };
}
checkFunctionSideEffects(tokens, context);
const assigned = new Set(incoming);
const declaration = parseLocalDeclaration(tokens);
if (declaration) {
if (context.symbols.has(declaration.name) || context.outerNames.has(declaration.name)) {
context.diagnostics.push(diagnostic("warning", "GRL_NAME_SHADOWS_OUTER_SCOPE", `Local ${declaration.name} shadows an outer name`, declaration.sourceMap));
}
context.symbols.set(declaration.name, declaration.typeName);
assigned.add(declaration.name);
return { normalExits: [assigned], returnExits: [] };
}
const assignment = parseAssignment(tokens);
if (assignment) {
assigned.add(assignment.name);
}
const call = parseCallStatement(tokens);
if (call) {
validateCall(call, context);
applyCallAssignments(call, assigned, context);
context.calls.push(call);
if (call.target === context.routineName) {
context.diagnostics.push(diagnostic("warning", "GRL_RECURSIVE_CALL", `Routine ${context.routineName} calls itself`, call.sourceMap));
}
return { normalExits: [assigned], returnExits: [] };
}
const returnInstruction = parseReturnStatement(tokens);
if (returnInstruction) {
validateReturn(returnInstruction, context);
context.returns.push(returnInstruction);
return { normalExits: [], returnExits: [assigned] };
}
return { normalExits: [assigned], returnExits: [] };
}
function parseRoutineParameters(
tokens: GrlToken[],
routineName: string,
globalNames: Set<string>,
diagnostics: MotionDiagnostic[]
): RoutineParameter[] {
const parameters: RoutineParameter[] = [];
const seen = new Set<string>();
for (const group of splitTopLevel(tokens, ",")) {
if (group.length === 0) {
continue;
}
let offset = 0;
let direction: RoutineParameterDirection = "in";
const first = group[0]!;
if (isDirection(first)) {
direction = first.raw as RoutineParameterDirection;
offset = 1;
}
const typeName = group[offset];
const name = group[offset + 1];
if (!typeName || !name || !isIdentifierLike(typeName) || !isIdentifierLike(name)) {
throw routineError("GRL_PARAMETER_INVALID", `Invalid parameter list for ${routineName}`, tokenSourceMap(first));
}
if (seen.has(name.raw)) {
throw routineError("GRL_PARAMETER_DUPLICATE", `Duplicate parameter ${name.raw}`, tokenSourceMap(name));
}
seen.add(name.raw);
if (globalNames.has(name.raw)) {
diagnostics.push(diagnostic("warning", "GRL_NAME_SHADOWS_OUTER_SCOPE", `Parameter ${name.raw} shadows an outer name`, tokenSourceMap(name)));
}
parameters.push({
name: name.raw,
typeName: typeName.raw,
direction,
sourceMap: tokenSourceMap(name)
});
}
return parameters;
}
function parseLocalDeclaration(tokens: GrlToken[]): { name: string; typeName: string; sourceMap: MotionSourceMap } | undefined {
const storage = tokens[0];
if (!storage || !["var", "const", "persistent"].includes(storage.raw)) {
return undefined;
}
const typeName = tokens[1];
const name = tokens[2];
if (!typeName || !name || !isIdentifierLike(typeName) || !isIdentifierLike(name)) {
return undefined;
}
return {
name: name.raw,
typeName: typeName.raw,
sourceMap: tokenSourceMap(name)
};
}
function parseAssignment(tokens: GrlToken[]): { name: string; sourceMap: MotionSourceMap } | undefined {
const name = tokens[0];
const operator = tokens[1];
if (!name || !operator || !isIdentifierLike(name) || operator.kind !== "operator" || (operator.raw !== "=" && operator.raw !== ":=")) {
return undefined;
}
return {
name: name.raw,
sourceMap: tokenSourceMap(name)
};
}
function parseCallStatement(tokens: GrlToken[]): CallInstruction | undefined {
const start = tokens[0];
const target = tokens[1];
if (!start || start.raw !== "call" || !target || !isIdentifierLike(target)) {
return undefined;
}
const argTokens = tokens.slice(2);
const args = parseCallArgs(argTokens);
return {
kind: "CALL",
target: target.raw,
args,
sourceMap: tokenSourceMap(start)
};
}
function parseReturnStatement(tokens: GrlToken[]): ReturnInstruction | undefined {
const start = tokens[0];
if (!start || start.raw !== "return") {
return undefined;
}
const valueTokens = tokens.slice(1);
return {
kind: "RETURN",
...(valueTokens.length > 0 ? { value: expressionFromTokens(valueTokens) } : {}),
sourceMap: tokenSourceMap(start)
};
}
function parseCallArgs(tokens: GrlToken[]): ControlExpression[] {
if (tokens[0]?.raw === "(" && tokens.at(-1)?.raw === ")") {
return splitTopLevel(tokens.slice(1, -1), ",").filter((group) => group.length > 0).map(expressionFromTokens);
}
return splitTopLevel(tokens, ",").filter((group) => group.length > 0).map(expressionFromTokens);
}
function validateCall(call: CallInstruction, context: AnalysisContext): void {
const signature = context.signatures.get(call.target);
if (!signature) {
throw routineError("GRL_CALL_TARGET_NOT_FOUND", `Unknown call target ${call.target}`, call.sourceMap);
}
if (call.args.length !== signature.parameters.length) {
throw routineError("GRL_CALL_ARITY_MISMATCH", `Call ${call.target} expects ${signature.parameters.length} arguments`, call.sourceMap);
}
if (context.routineKind === "func" && signature.kind === "PROC_SIGNATURE") {
throw routineError("GRL_FUNC_SIDE_EFFECT", `Function ${context.routineName} cannot call procedure ${call.target}`, call.sourceMap);
}
for (let index = 0; index < signature.parameters.length; index += 1) {
const parameter = signature.parameters[index]!;
const arg = call.args[index]!;
const argTokens = arg.tokens as GrlToken[] | undefined;
if ((parameter.direction === "out" || parameter.direction === "inout") && (!argTokens || !isLValueExpression(argTokens))) {
throw routineError("GRL_ARGUMENT_NOT_LVALUE", `${parameter.direction} argument ${parameter.name} must be a writable lvalue`, arg.sourceMap);
}
const actualType = inferExpressionType(argTokens ?? [], context);
if (!isTypeCompatible(parameter.typeName, actualType)) {
throw routineError("GRL_CALL_ARGUMENT_TYPE", `Argument ${index + 1} for ${call.target} is not compatible with ${parameter.typeName}`, arg.sourceMap);
}
}
}
function applyCallAssignments(call: CallInstruction, assigned: Set<string>, context: AnalysisContext): void {
const signature = context.signatures.get(call.target);
if (!signature) {
return;
}
for (let index = 0; index < signature.parameters.length; index += 1) {
const parameter = signature.parameters[index]!;
if (parameter.direction !== "out" && parameter.direction !== "inout") {
continue;
}
const argTokens = call.args[index]?.tokens as GrlToken[] | undefined;
const target = argTokens?.[0];
if (target && isIdentifierLike(target)) {
assigned.add(target.raw);
}
}
}
function validateReturn(returnInstruction: ReturnInstruction, context: AnalysisContext): void {
if (context.routineKind === "proc") {
if (returnInstruction.value) {
throw routineError("GRL_RETURN_VALUE_IN_PROC", "proc return cannot include a value", returnInstruction.sourceMap);
}
return;
}
if (context.returnType === "void") {
if (returnInstruction.value) {
throw routineError("GRL_RETURN_TYPE_MISMATCH", "void function cannot return a value", returnInstruction.sourceMap);
}
return;
}
if (!returnInstruction.value) {
throw routineError("GRL_RETURN_VALUE_MISSING", `Function ${context.routineName} must return ${context.returnType}`, returnInstruction.sourceMap);
}
const actualType = inferExpressionType(returnInstruction.value.tokens as GrlToken[] | undefined ?? [], context);
if (!isTypeCompatible(context.returnType ?? "unknown", actualType)) {
throw routineError("GRL_RETURN_TYPE_MISMATCH", `Return value is not compatible with ${context.returnType}`, returnInstruction.value.sourceMap);
}
}
function checkFunctionSideEffects(tokens: GrlToken[], context: AnalysisContext): void {
if (context.routineKind !== "func") {
return;
}
const first = tokens[0];
if (!first) {
return;
}
if (["movej", "movel", "movec", "wait", "pulse", "run_path", "run_operation"].includes(first.raw)) {
throw routineError("GRL_FUNC_SIDE_EFFECT", `Function ${context.routineName} cannot execute ${first.raw}`, tokenSourceMap(first));
}
}
function inferExpressionType(tokens: GrlToken[], context: AnalysisContext): InferredType {
if (tokens.length === 0) {
return "unknown";
}
if (tokens.some((token) => token.kind === "operator" && ["==", "!=", "<", ">", "<=", ">=", "&&", "||", "!"].includes(token.raw))) {
return "bool";
}
if (tokens.length === 1) {
return inferSingleTokenType(tokens[0]!, context);
}
if (tokens[0] && isIdentifierLike(tokens[0]) && tokens[1]?.raw === "(") {
const signature = context.signatures.get(tokens[0].raw);
if (signature?.kind === "FUNC_SIGNATURE") {
return signature.returnType;
}
}
const operandTypes = tokens
.filter((token) => token.kind !== "operator" && token.kind !== "punctuation")
.map((token) => inferSingleTokenType(token, context))
.filter((type) => type !== "unknown");
if (operandTypes.length > 0 && operandTypes.every((type) => ["int", "real"].includes(type))) {
return operandTypes.includes("real") ? "real" : "int";
}
return "unknown";
}
function inferSingleTokenType(token: GrlToken, context: AnalysisContext): InferredType {
if (token.kind === "number") {
if (token.unit?.kind === "time") return "time";
if (token.unit?.kind === "length") return "length";
if (token.unit?.kind === "angle") return "angle";
if (token.unit?.kind === "percent") return "percent";
return Number.isInteger(token.value) ? "int" : "real";
}
if (token.kind === "string") {
return "string";
}
if (token.kind === "keyword" && (token.raw === "true" || token.raw === "false")) {
return "bool";
}
if (isIdentifierLike(token)) {
return context.symbols.get(token.raw) ?? "unknown";
}
return "unknown";
}
function isTypeCompatible(expected: string, actual: InferredType): boolean {
if (expected === "unknown" || actual === "unknown") {
return true;
}
if (expected === actual) {
return true;
}
return expected === "real" && actual === "int";
}
function isLValueExpression(tokens: GrlToken[]): boolean {
const first = tokens[0];
if (!first || !isIdentifierLike(first) || ["true", "false"].includes(first.raw)) {
return false;
}
return !tokens.some((token) => token.kind === "operator");
}
function expressionFromTokens(tokens: GrlToken[]): ControlExpression {
return {
text: stringifyTokens(tokens),
tokens,
...(tokens[0] ? { sourceMap: tokenSourceMap(tokens[0]) } : {})
};
}
function splitTopLevel(tokens: GrlToken[], separator: string): GrlToken[][] {
const groups: GrlToken[][] = [];
let current: GrlToken[] = [];
let parenDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
for (const token of tokens) {
if (
token.kind === "punctuation" &&
token.raw === separator &&
parenDepth === 0 &&
bracketDepth === 0 &&
braceDepth === 0
) {
groups.push(current);
current = [];
continue;
}
current.push(token);
if (token.kind === "punctuation") {
if (token.raw === "(") parenDepth += 1;
if (token.raw === ")") parenDepth = Math.max(0, parenDepth - 1);
if (token.raw === "[") bracketDepth += 1;
if (token.raw === "]") bracketDepth = Math.max(0, bracketDepth - 1);
if (token.raw === "{") braceDepth += 1;
if (token.raw === "}") braceDepth = Math.max(0, braceDepth - 1);
}
}
groups.push(current);
return groups;
}
function collectGlobalNames(declarations: GrlTopLevelDeclaration[]): Set<string> {
const names = new Set<string>();
for (const declaration of declarations) {
if (
declaration.kind === "DataDeclaration" ||
declaration.kind === "TargetDeclaration" ||
declaration.kind === "PathDeclaration" ||
declaration.kind === "OperationDeclaration" ||
declaration.kind === "ProcedureDeclaration" ||
declaration.kind === "FunctionDeclaration"
) {
names.add(declaration.name);
}
}
return names;
}
function isDirection(token: GrlToken | undefined): boolean {
return Boolean(token && (token.raw === "in" || token.raw === "out" || token.raw === "inout"));
}
function isIdentifierLike(token: GrlToken): boolean {
return token.kind === "identifier" || token.kind === "keyword";
}
function stringifyTokens(tokens: GrlToken[]): string {
return tokens.map((token) => token.raw).join(" ");
}
function tokenSourceMap(token: GrlToken): MotionSourceMap {
return {
line: token.range.start.line,
column: token.range.start.column
};
}
function rangeSourceMap(position: { line: number; column: number }): MotionSourceMap {
return {
line: position.line,
column: position.column
};
}
function diagnostic(
severity: MotionDiagnostic["severity"],
code: string,
message: string,
sourceMap?: MotionSourceMap
): MotionDiagnostic {
return {
severity,
code,
message,
...(sourceMap ? { sourceMap } : {})
};
}
function routineError(code: string, message: string, sourceMap?: MotionSourceMap): KdlStructuredError {
return new KdlStructuredError(
code,
message,
sourceMap
? [
{
severity: "error",
code,
message,
sourceMap
}
]
: undefined
);
}

View File

@@ -0,0 +1,549 @@
import type { MotionDiagnostic, MotionSourceMap } from "../../kdl/types.js";
import type {
GrlDataDeclaration,
GrlFunctionDeclaration,
GrlOperationDeclaration,
GrlPathDeclaration,
GrlProcedureDeclaration,
GrlProgram,
GrlRawTopLevelDeclaration,
GrlTargetDeclaration,
GrlTopLevelDeclaration
} from "../ast/index.js";
import type {
AlarmInstruction,
BreakInstruction,
CallInstruction,
ContinueInstruction,
ControlFlowInstruction,
ExecutableBranch,
ExecutableInstruction,
ExecutableProcedure,
ExecutableSwitchCase,
IoFlowInstruction,
ProcedureFlowInstruction,
RawProcedureStatement,
ReturnInstruction,
SemanticProgramIr,
SemanticSourceMapEntry,
SemanticSymbol,
UnsupportedRuntimeInstruction
} from "../ir/index.js";
import {
analyzeExceptionSemantics,
parseExceptionFlowStatements
} from "./compileException.js";
import { analyzeProcFunctionSemantics } from "./compileProcFunction.js";
import {
buildMotionContext,
compileMotionToKdlRequest,
compileOperation,
compilePathToPlanRequest,
parseProcedureMotionInstructions,
parseProcedureRunOperationStatements,
parseProcedureRunPathStatements
} from "./compileMotion.js";
import {
parseControlFlowStatements
} from "./compileControlFlow.js";
import { parseIoFlowStatements } from "./compileIo.js";
export interface SemanticCompileOptions {
startJoints: number[];
sampleTime: number;
}
const SEMANTIC_CHECKS = [
"language/module/proc",
"const/var/persistent symbols",
"tool/frame/speed/zone",
"joint_target/pose_target",
"movej/movel/movec",
"path/point/event/run_path",
"operation/run_operation",
"io/wait/pulse",
"if/elseif/else/while/for/switch",
"call/return/break/continue",
"proc parameter directions",
"func returns and side effects",
"alarm/raise/try/catch",
"source map propagation",
"KDL motion request bridge",
"KDL path request bridge",
"duplicate symbol diagnostics",
"missing reference diagnostics",
"P1 unsupported diagnostics",
"operation action expansion",
"path event expansion",
"raw statement preservation"
] as const;
export function compileSemanticProgram(program: GrlProgram, options: SemanticCompileOptions): SemanticProgramIr {
const declarations = program.module.declarations;
const diagnostics: MotionDiagnostic[] = [];
const sourceMap: SemanticSourceMapEntry[] = [];
const symbols = buildSemanticSymbols(declarations, diagnostics);
const motionContext = buildMotionContext(
declarations.filter(
(decl): decl is GrlDataDeclaration | GrlTargetDeclaration =>
decl.kind === "DataDeclaration" || decl.kind === "TargetDeclaration"
)
);
const paths = declarations
.filter((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")
.map((path) => compilePathToPlanRequest(path, motionContext, options));
const pathDeclarations = new Map(
declarations
.filter((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")
.map((path) => [path.name, path])
);
const operations = declarations
.filter((decl): decl is GrlOperationDeclaration => decl.kind === "OperationDeclaration")
.map((operation) => compileOperation(operation, pathDeclarations));
const procFunction = analyzeProcFunctionSemantics(declarations);
const exceptionAnalysis = analyzeExceptionSemantics(declarations);
diagnostics.push(...procFunction.diagnostics, ...exceptionAnalysis.diagnostics);
const procedures = declarations
.filter((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")
.map((procedure) => compileExecutableProcedure(procedure, motionContext, diagnostics));
for (const path of paths) {
collectPathSourceMaps(path, sourceMap);
}
for (const operation of operations) {
collectOperationSourceMaps(operation, sourceMap);
}
for (const procedure of procedures) {
collectExecutableSourceMaps(procedure.instructions, sourceMap, { procedureId: procedure.name });
}
return {
moduleName: program.module.name,
symbols,
semanticChecks: [...SEMANTIC_CHECKS],
procedures,
paths,
operations,
diagnostics,
sourceMap,
kdlBridge: {
motionRequests: procedures.flatMap((procedure) =>
procedure.instructions.flatMap((instruction) =>
instruction.kind === "MOVEJ" || instruction.kind === "MOVEL" || instruction.kind === "MOVEC"
? [compileMotionToKdlRequest(instruction, options)]
: []
)
),
pathRequests: paths.map((path) => path.request)
}
};
}
function compileExecutableProcedure(
procedure: GrlProcedureDeclaration,
motionContext: ReturnType<typeof buildMotionContext>,
diagnostics: MotionDiagnostic[]
): ExecutableProcedure {
const motion = parseProcedureMotionInstructions(procedure, cloneMotionContextForSemantic(motionContext));
const io = parseIoFlowStatements(procedure.bodyTokens);
const runPaths = parseProcedureRunPathStatements(procedure);
const runOperations = parseProcedureRunOperationStatements(procedure);
const controls = parseControlFlowStatements(procedure.bodyTokens);
const exceptions = parseExceptionFlowStatements(procedure.bodyTokens);
const nestedControlLines = collectNestedControlLines(controls);
const topLevelMotion = motion.filter((instruction) => !isNestedInstruction(instruction, nestedControlLines));
const topLevelIo = io.filter((instruction) => !isNestedInstruction(instruction, nestedControlLines));
const topLevelRunPaths = runPaths.filter((instruction) => !isNestedInstruction(instruction, nestedControlLines));
const topLevelRunOperations = runOperations.filter((instruction) => !isNestedInstruction(instruction, nestedControlLines));
const topLevelExceptions = exceptions.filter(
(instruction): instruction is AlarmInstruction | UnsupportedRuntimeInstruction =>
(instruction.kind === "ALARM" || instruction.kind === "UNSUPPORTED_RUNTIME") &&
!isNestedInstruction(instruction, nestedControlLines)
);
const topLevelStructuredLines = new Set([
...topLevelMotion,
...topLevelIo,
...topLevelRunPaths,
...topLevelRunOperations,
...topLevelExceptions
].map((instruction) => instruction.sourceMap?.line).filter((line): line is number => line !== undefined));
const instructions = mergeExecutableInstructions(
procedure.bodyTokens,
[
...topLevelMotion,
...topLevelIo,
...topLevelRunPaths,
...topLevelRunOperations,
...topLevelExceptions,
...flattenControlInstructions(controls, topLevelStructuredLines),
...extractRawCallsAndReturns(controls, topLevelStructuredLines)
],
diagnostics
);
return {
name: procedure.name,
instructions,
sourceMap: rangeSourceMap(procedure.range.start)
};
}
function mergeExecutableInstructions(
tokens: GrlProcedureDeclaration["bodyTokens"],
instructions: ExecutableInstruction[],
diagnostics: MotionDiagnostic[]
): ExecutableInstruction[] {
const sorted = [...instructions].sort((left, right) => sourceOrder(left.sourceMap, right.sourceMap));
const seen = new Set<string>();
const merged: ExecutableInstruction[] = [];
for (const instruction of sorted) {
const key = instructionKey(instruction);
if (key && seen.has(key)) {
continue;
}
if (key) {
seen.add(key);
}
merged.push(instruction);
}
for (const token of tokens) {
if (["catch", "finally", "end"].includes(token.raw)) {
continue;
}
if (!merged.some((instruction) => instruction.sourceMap?.line === token.range.start.line)) {
diagnostics.push(diagnostic("info", "GRL_RAW_STATEMENT_PRESERVED", `Statement ${token.raw} preserved as raw IR`, tokenSourceMap(token)));
}
}
return merged;
}
function flattenControlInstructions(instructions: ProcedureFlowInstruction[], excludedRawLines = new Set<number>()): ExecutableInstruction[] {
return instructions.flatMap((instruction): ExecutableInstruction[] => {
if (instruction.kind === "RAW_STATEMENT") {
return rawStatementToExecutable(instruction, excludedRawLines);
}
if (instruction.kind === "IF") {
return [
{
kind: "EXEC_IF",
branches: instruction.branches.map((branch): ExecutableBranch => ({
branchKind: branch.branchKind,
...(branch.condition ? { condition: branch.condition } : {}),
body: flattenProcedureFlow(branch.body),
...(branch.sourceMap ? { sourceMap: branch.sourceMap } : {})
})),
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
}
];
}
if (instruction.kind === "WHILE") {
return [
{
kind: "EXEC_WHILE",
condition: instruction.condition,
body: flattenProcedureFlow(instruction.body),
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
}
];
}
if (instruction.kind === "FOR") {
return [
{
kind: "EXEC_FOR",
iterator: instruction.iterator,
from: instruction.from,
to: instruction.to,
...(instruction.step ? { step: instruction.step } : {}),
body: flattenProcedureFlow(instruction.body),
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
}
];
}
if (instruction.kind === "SWITCH") {
return [
{
kind: "EXEC_SWITCH",
expression: instruction.expression,
cases: instruction.cases.map((switchCase): ExecutableSwitchCase => ({
caseKind: switchCase.caseKind,
...(switchCase.value !== undefined ? { value: switchCase.value } : {}),
...(switchCase.raw ? { raw: switchCase.raw } : {}),
body: flattenProcedureFlow(switchCase.body),
...(switchCase.sourceMap ? { sourceMap: switchCase.sourceMap } : {})
})),
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
}
];
}
if (instruction.kind === "BREAK" || instruction.kind === "CONTINUE") {
return [instruction as BreakInstruction | ContinueInstruction];
}
return [];
});
}
function flattenProcedureFlow(instructions: ProcedureFlowInstruction[]): ExecutableInstruction[] {
const controls = instructions.filter((instruction): instruction is ControlFlowInstruction => instruction.kind !== "RAW_STATEMENT");
const raw = instructions.filter((instruction): instruction is RawProcedureStatement => instruction.kind === "RAW_STATEMENT");
return [...flattenControlInstructions(controls), ...extractRawCallsAndReturns(raw)];
}
function extractRawCallsAndReturns(
instructions: Array<ControlFlowInstruction | RawProcedureStatement>,
excludedRawLines = new Set<number>()
): ExecutableInstruction[] {
const extracted: ExecutableInstruction[] = [];
for (const instruction of instructions) {
if (instruction.kind !== "RAW_STATEMENT") {
continue;
}
extracted.push(...rawStatementToExecutable(instruction, excludedRawLines));
}
return extracted;
}
function rawStatementToExecutable(
instruction: RawProcedureStatement,
excludedRawLines = new Set<number>()
): ExecutableInstruction[] {
if (instruction.sourceMap?.line && excludedRawLines.has(instruction.sourceMap.line)) {
return [];
}
const tokens = instruction.tokens as { raw: string }[] | undefined;
const first = tokens?.[0]?.raw;
const second = tokens?.[1]?.raw;
if (first && ["set_tool", "set_frame", "set_speed", "set_zone"].includes(first)) {
return [];
}
const typedTokens = instruction.tokens as GrlProcedureDeclaration["bodyTokens"] | undefined;
if (typedTokens && (first === "io" || first === "wait" || first === "pulse")) {
return parseIoFlowStatements(typedTokens);
}
if (typedTokens && (first === "alarm" || first === "raise" || first === "enable" || first === "disable")) {
return parseExceptionFlowStatements(typedTokens).filter(
(item): item is AlarmInstruction | UnsupportedRuntimeInstruction => item.kind === "ALARM" || item.kind === "UNSUPPORTED_RUNTIME"
);
}
if (first === "call" && second) {
return [{
kind: "CALL" as const,
target: second,
args: [],
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
}];
}
if (first === "return") {
return [{
kind: "RETURN" as const,
...(instruction.sourceMap ? { sourceMap: instruction.sourceMap } : {})
}];
}
return [instruction];
}
function collectNestedControlLines(instructions: ProcedureFlowInstruction[]): Set<number> {
const lines = new Set<number>();
for (const instruction of instructions) {
if (instruction.kind === "RAW_STATEMENT") {
continue;
}
if (instruction.kind === "IF") {
for (const branch of instruction.branches) {
collectFlowLines(branch.body, lines);
}
} else if (instruction.kind === "WHILE" || instruction.kind === "FOR") {
collectFlowLines(instruction.body, lines);
} else if (instruction.kind === "SWITCH") {
for (const switchCase of instruction.cases) {
collectFlowLines(switchCase.body, lines);
}
}
}
return lines;
}
function collectFlowLines(instructions: ProcedureFlowInstruction[], lines: Set<number>): void {
for (const instruction of instructions) {
if (instruction.sourceMap?.line) {
lines.add(instruction.sourceMap.line);
}
if (instruction.kind === "IF") {
for (const branch of instruction.branches) {
collectFlowLines(branch.body, lines);
}
} else if (instruction.kind === "WHILE" || instruction.kind === "FOR") {
collectFlowLines(instruction.body, lines);
} else if (instruction.kind === "SWITCH") {
for (const switchCase of instruction.cases) {
collectFlowLines(switchCase.body, lines);
}
}
}
}
function isNestedInstruction(instruction: { sourceMap?: MotionSourceMap }, nestedLines: Set<number>): boolean {
return Boolean(instruction.sourceMap?.line && nestedLines.has(instruction.sourceMap.line));
}
function buildSemanticSymbols(declarations: GrlTopLevelDeclaration[], diagnostics: MotionDiagnostic[]): SemanticSymbol[] {
const symbols: SemanticSymbol[] = [];
const seen = new Map<string, SemanticSymbol>();
for (const declaration of declarations) {
const symbol = symbolFromDeclaration(declaration);
if (!symbol) {
continue;
}
const previous = seen.get(symbol.name);
if (previous) {
diagnostics.push(diagnostic("error", "GRL_SYMBOL_DUPLICATE", `Duplicate symbol ${symbol.name}`, symbol.sourceMap));
}
seen.set(symbol.name, symbol);
symbols.push(symbol);
}
return symbols;
}
function symbolFromDeclaration(declaration: GrlTopLevelDeclaration): SemanticSymbol | undefined {
if (declaration.kind === "DataDeclaration") {
return { kind: "data", name: declaration.name, typeName: declaration.typeName, sourceMap: rangeSourceMap(declaration.range.start) };
}
if (declaration.kind === "TargetDeclaration") {
return { kind: "target", name: declaration.name, sourceMap: rangeSourceMap(declaration.range.start) };
}
if (declaration.kind === "PathDeclaration") {
return { kind: "path", name: declaration.name, sourceMap: rangeSourceMap(declaration.range.start) };
}
if (declaration.kind === "OperationDeclaration") {
return { kind: "operation", name: declaration.name, sourceMap: rangeSourceMap(declaration.range.start) };
}
if (declaration.kind === "ProcedureDeclaration") {
return { kind: "procedure", name: declaration.name, sourceMap: rangeSourceMap(declaration.range.start) };
}
if (declaration.kind === "FunctionDeclaration") {
return { kind: "function", name: declaration.name, typeName: declaration.returnType, sourceMap: rangeSourceMap(declaration.range.start) };
}
if (declaration.kind === "RawTopLevelDeclaration") {
const name = declaration.tokens[1]?.raw ?? declaration.declarationType;
return { kind: "raw", name, typeName: declaration.declarationType, sourceMap: rangeSourceMap(declaration.range.start) };
}
return undefined;
}
function collectPathSourceMaps(path: SemanticProgramIr["paths"][number], sourceMap: SemanticSourceMapEntry[]): void {
for (const segment of path.request.segments) {
if (segment.sourceMap) {
sourceMap.push({
kind: "path_point",
id: segment.id ?? `${path.pathId}:${segment.motion}`,
pathId: path.pathId,
...(segment.id ? { pointId: segment.id } : {}),
sourceMap: segment.sourceMap
});
}
}
for (const event of path.events) {
if (event.sourceMap) {
sourceMap.push({
kind: "path_event",
id: event.id ?? `${path.pathId}:event`,
pathId: path.pathId,
pointId: event.pointId,
sourceMap: event.sourceMap
});
}
}
}
function collectOperationSourceMaps(operation: SemanticProgramIr["operations"][number], sourceMap: SemanticSourceMapEntry[]): void {
for (const action of [...operation.startActions, ...operation.endActions]) {
if (action.sourceMap) {
sourceMap.push({
kind: "operation_action",
id: `${operation.operationId}:${action.actionKind}`,
operationId: operation.operationId,
sourceMap: action.sourceMap
});
}
}
}
function collectExecutableSourceMaps(
instructions: ExecutableInstruction[],
sourceMap: SemanticSourceMapEntry[],
context: { procedureId: string }
): void {
for (const instruction of instructions) {
if (instruction.sourceMap) {
sourceMap.push({
kind: instruction.kind,
id: `${context.procedureId}:${instruction.kind}:${instruction.sourceMap.line ?? 0}:${instruction.sourceMap.column ?? 0}`,
procedureId: context.procedureId,
sourceMap: instruction.sourceMap
});
}
if (instruction.kind === "EXEC_IF") {
for (const branch of instruction.branches) {
collectExecutableSourceMaps(branch.body, sourceMap, context);
}
} else if (instruction.kind === "EXEC_WHILE" || instruction.kind === "EXEC_FOR") {
collectExecutableSourceMaps(instruction.body, sourceMap, context);
} else if (instruction.kind === "EXEC_SWITCH") {
for (const switchCase of instruction.cases) {
collectExecutableSourceMaps(switchCase.body, sourceMap, context);
}
}
}
}
function cloneMotionContextForSemantic(context: ReturnType<typeof buildMotionContext>): ReturnType<typeof buildMotionContext> {
return {
targets: context.targets,
speeds: context.speeds,
zones: context.zones,
tools: context.tools,
frames: context.frames,
...(context.currentSpeed ? { currentSpeed: context.currentSpeed } : {}),
...(context.currentZone ? { currentZone: context.currentZone } : {}),
...(context.currentTool ? { currentTool: context.currentTool } : {}),
...(context.currentFrame ? { currentFrame: context.currentFrame } : {})
};
}
function sourceOrder(left: MotionSourceMap | undefined, right: MotionSourceMap | undefined): number {
return (left?.line ?? Number.MAX_SAFE_INTEGER) - (right?.line ?? Number.MAX_SAFE_INTEGER) ||
(left?.column ?? Number.MAX_SAFE_INTEGER) - (right?.column ?? Number.MAX_SAFE_INTEGER);
}
function instructionKey(instruction: ExecutableInstruction): string | undefined {
const line = instruction.sourceMap?.line;
const column = instruction.sourceMap?.column;
return line ? `${instruction.kind}:${line}:${column ?? 0}` : undefined;
}
function tokenSourceMap(token: GrlProcedureDeclaration["bodyTokens"][number]): MotionSourceMap {
return {
line: token.range.start.line,
column: token.range.start.column
};
}
function rangeSourceMap(position: { line: number; column: number }): MotionSourceMap {
return {
line: position.line,
column: position.column
};
}
function diagnostic(
severity: MotionDiagnostic["severity"],
code: string,
message: string,
sourceMap?: MotionSourceMap
): MotionDiagnostic {
return {
severity,
code,
message,
...(sourceMap ? { sourceMap } : {})
};
}

View File

@@ -0,0 +1,49 @@
export {
compileGrlDataDeclaration,
compileGrlTargetDeclaration,
compileOffsetExpression,
compileSpeedExpression,
compileTargetExpression,
compileZoneExpression,
type CompiledGrlDataDeclaration,
type CompiledGrlDataValue,
type CompiledGrlTargetDeclaration
} from "./compileData.js";
export {
buildMotionContext,
compileOperation,
compilePathToPlanRequest,
compileMotionToKdlRequest,
expandRunOperation,
parseProcedureMotionInstructions,
parseProcedureRunOperationStatements,
parseProcedureRunPathStatements,
type GrlMotionContext,
type PathCompileOptions,
type MotionRequestOptions
} from "./compileMotion.js";
export {
compileOperationActionIo,
compilePathEventIo,
parseIoFlowStatements,
type IoMap
} from "./compileIo.js";
export {
parseControlFlowStatements,
parseProcedureControlFlow
} from "./compileControlFlow.js";
export {
analyzeProcFunctionSemantics,
compileFunctionSignature,
compileProcedureSignature
} from "./compileProcFunction.js";
export {
analyzeExceptionSemantics,
parseExceptionFlowStatements,
parseProcedureExceptionFlow,
type ExceptionAnalysis
} from "./compileException.js";
export {
compileSemanticProgram,
type SemanticCompileOptions
} from "./compileSemantic.js";

View File

@@ -0,0 +1,16 @@
import { createDefaultNativeKdlModuleLoader } from "./nativeModule.js";
import { createKdlWorkerRuntime } from "./runtime.js";
import { dispatchKdlRpcRequest } from "./workerRpc.js";
import type { KdlRpcRequest } from "./rpc.js";
const runtime = createKdlWorkerRuntime(createDefaultNativeKdlModuleLoader());
const workerScope = globalThis as unknown as {
onmessage: ((event: MessageEvent<KdlRpcRequest<unknown[]>>) => void) | null;
postMessage: (message: unknown, transfer?: Transferable[]) => void;
};
workerScope.onmessage = (event) => {
void dispatchKdlRpcRequest(runtime, event.data).then((response) => {
workerScope.postMessage(response);
});
};

View File

@@ -0,0 +1,318 @@
import {
KdlStructuredError,
rpcErrorToException,
type KdlRpcRequest,
type KdlRpcResponse
} from "./rpc.js";
import type {
CycleTimeResult,
JointLimits,
FkOptions,
FkResult,
IkOptions,
IkResult,
JacobianOptions,
JacobianResult,
KdlApiMethod,
KdlInitOptions,
KdlRuntimeInfo,
KdlWasmApi,
LinkPoseResult,
LimitCheckResult,
MoveCRequest,
MoveJRequest,
MoveLRequest,
NormalizedRobotModel,
OffsetSpec,
PathPlanRequest,
PathPlanResult,
PathValidationResult,
Pose,
PoseLike,
PoseNormalizeOptions,
PoseTarget,
ReachabilityResult,
RobotHandle,
RobotInfo,
SingularityResult,
TrapProfileOptions,
TrapProfileResult,
TrapSample,
TrajectoryResult,
UrdfLoadOptions
} from "./types.js";
export interface KdlWorkerLike {
postMessage(message: KdlRpcRequest<unknown[]>, transfer?: Transferable[]): void;
terminate?: () => void;
addEventListener(type: "message", listener: (event: MessageEvent<KdlRpcResponse>) => void): void;
addEventListener(type: "error", listener: (event: ErrorEvent) => void): void;
removeEventListener(type: "message", listener: (event: MessageEvent<KdlRpcResponse>) => void): void;
removeEventListener(type: "error", listener: (event: ErrorEvent) => void): void;
}
interface PendingCall {
method: KdlApiMethod;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}
export class KdlWorkerClient
implements
Pick<
KdlWasmApi,
| "init"
| "dispose"
| "loadRobotFromUrdf"
| "createRobotFromModel"
| "destroyRobot"
| "getRobotInfo"
| "getJointLimits"
| "normalizePose"
| "composePose"
| "inversePose"
| "applyToolAndFrame"
| "applyOffset"
| "makeTrapProfile"
| "sampleTrapProfile"
| "fk"
| "fkPose7"
| "fkAllLinks"
| "ik"
| "ikBatch"
| "jacobian"
| "checkSingularity"
| "checkJointLimits"
| "checkVelocityLimits"
| "checkReachability"
| "checkReachabilityBatch"
| "planMoveJ"
| "planMoveL"
| "planMoveC"
| "planPath"
| "validatePath"
| "estimateCycleTime"
| "resampleTrajectory"
>
{
private nextId = 1;
private worker: KdlWorkerLike | undefined;
private readonly pending = new Map<number, PendingCall>();
private readonly handleMessage = (event: MessageEvent<KdlRpcResponse>) => {
this.acceptResponse(event.data);
};
private readonly handleError = (event: ErrorEvent) => {
this.failWorker(event.error instanceof Error ? event.error : new Error(event.message));
};
constructor(private readonly createWorker: () => KdlWorkerLike) {}
async init(options?: KdlInitOptions): Promise<KdlRuntimeInfo> {
return this.call("init", options) as Promise<KdlRuntimeInfo>;
}
async dispose(): Promise<void> {
if (!this.worker) {
return;
}
try {
await this.call("dispose");
} finally {
this.detachWorker();
}
}
async loadRobotFromUrdf(urdfXml: string, options: UrdfLoadOptions): Promise<RobotHandle> {
return this.call("loadRobotFromUrdf", urdfXml, options) as Promise<RobotHandle>;
}
async createRobotFromModel(model: NormalizedRobotModel): Promise<RobotHandle> {
return this.call("createRobotFromModel", model) as Promise<RobotHandle>;
}
async destroyRobot(handle: RobotHandle): Promise<void> {
await this.call("destroyRobot", handle);
}
async getRobotInfo(handle: RobotHandle): Promise<RobotInfo> {
return this.call("getRobotInfo", handle) as Promise<RobotInfo>;
}
async getJointLimits(handle: RobotHandle): Promise<JointLimits[]> {
return this.call("getJointLimits", handle) as Promise<JointLimits[]>;
}
async normalizePose(input: PoseLike, options?: PoseNormalizeOptions): Promise<Pose> {
return this.call("normalizePose", input, options) as Promise<Pose>;
}
async composePose(a: Pose, b: Pose): Promise<Pose> {
return this.call("composePose", a, b) as Promise<Pose>;
}
async inversePose(pose: Pose): Promise<Pose> {
return this.call("inversePose", pose) as Promise<Pose>;
}
async applyToolAndFrame(target: PoseTarget, tool: Pose, frame: Pose): Promise<Pose> {
return this.call("applyToolAndFrame", target, tool, frame) as Promise<Pose>;
}
async applyOffset(target: PoseTarget, offset: OffsetSpec): Promise<PoseTarget> {
return this.call("applyOffset", target, offset) as Promise<PoseTarget>;
}
async makeTrapProfile(length: number, options: TrapProfileOptions): Promise<TrapProfileResult> {
return this.call("makeTrapProfile", length, options) as Promise<TrapProfileResult>;
}
async sampleTrapProfile(length: number, options: TrapProfileOptions): Promise<TrapSample[]> {
return this.call("sampleTrapProfile", length, options) as Promise<TrapSample[]>;
}
async fk(handle: RobotHandle, joints: Float64Array, options?: FkOptions): Promise<FkResult> {
return this.call("fk", handle, joints, options) as Promise<FkResult>;
}
async fkPose7(handle: RobotHandle, joints: Float64Array, out?: Float64Array, options?: FkOptions): Promise<Float64Array> {
return this.call("fkPose7", handle, joints, out, options) as Promise<Float64Array>;
}
async fkAllLinks(handle: RobotHandle, joints: Float64Array, options?: FkOptions): Promise<LinkPoseResult> {
return this.call("fkAllLinks", handle, joints, options) as Promise<LinkPoseResult>;
}
async ik(handle: RobotHandle, seed: Float64Array, target: Pose, options?: IkOptions): Promise<IkResult> {
return this.call("ik", handle, seed, target, options) as Promise<IkResult>;
}
async ikBatch(handle: RobotHandle, seeds: Float64Array[], targets: Pose[], options?: IkOptions): Promise<IkResult[]> {
return this.call("ikBatch", handle, seeds, targets, options) as Promise<IkResult[]>;
}
async jacobian(handle: RobotHandle, joints: Float64Array, options?: JacobianOptions): Promise<JacobianResult> {
return this.call("jacobian", handle, joints, options) as Promise<JacobianResult>;
}
async checkSingularity(handle: RobotHandle, joints: Float64Array): Promise<SingularityResult> {
return this.call("checkSingularity", handle, joints) as Promise<SingularityResult>;
}
async checkJointLimits(handle: RobotHandle, joints: Float64Array): Promise<LimitCheckResult> {
return this.call("checkJointLimits", handle, joints) as Promise<LimitCheckResult>;
}
async checkVelocityLimits(handle: RobotHandle, trajectory: TrajectoryResult): Promise<LimitCheckResult> {
return this.call("checkVelocityLimits", handle, trajectory) as Promise<LimitCheckResult>;
}
async checkReachability(handle: RobotHandle, target: PoseTarget, options?: IkOptions): Promise<ReachabilityResult> {
return this.call("checkReachability", handle, target, options) as Promise<ReachabilityResult>;
}
async checkReachabilityBatch(
handle: RobotHandle,
targets: PoseTarget[],
options?: IkOptions
): Promise<ReachabilityResult[]> {
return this.call("checkReachabilityBatch", handle, targets, options) as Promise<ReachabilityResult[]>;
}
async planMoveJ(handle: RobotHandle, request: MoveJRequest): Promise<TrajectoryResult> {
return this.call("planMoveJ", handle, request) as Promise<TrajectoryResult>;
}
async planMoveL(handle: RobotHandle, request: MoveLRequest): Promise<TrajectoryResult> {
return this.call("planMoveL", handle, request) as Promise<TrajectoryResult>;
}
async planMoveC(handle: RobotHandle, request: MoveCRequest): Promise<TrajectoryResult> {
return this.call("planMoveC", handle, request) as Promise<TrajectoryResult>;
}
async planPath(handle: RobotHandle, request: PathPlanRequest): Promise<PathPlanResult> {
return this.call("planPath", handle, request) as Promise<PathPlanResult>;
}
async validatePath(handle: RobotHandle, request: PathPlanRequest): Promise<PathValidationResult> {
return this.call("validatePath", handle, request) as Promise<PathValidationResult>;
}
async estimateCycleTime(input: TrajectoryResult | PathPlanResult): Promise<CycleTimeResult> {
return this.call("estimateCycleTime", input) as Promise<CycleTimeResult>;
}
async resampleTrajectory(trajectory: TrajectoryResult, sampleTime: number): Promise<TrajectoryResult> {
return this.call("resampleTrajectory", trajectory, sampleTime) as Promise<TrajectoryResult>;
}
async call(method: KdlApiMethod, ...args: unknown[]): Promise<unknown> {
const worker = this.ensureWorker();
const id = this.nextId++;
const request: KdlRpcRequest<unknown[]> = {
id,
method,
payload: args
};
return new Promise((resolve, reject) => {
this.pending.set(id, { method, resolve, reject });
worker.postMessage(request);
});
}
private ensureWorker(): KdlWorkerLike {
if (this.worker) {
return this.worker;
}
const worker = this.createWorker();
worker.addEventListener("message", this.handleMessage);
worker.addEventListener("error", this.handleError);
this.worker = worker;
return worker;
}
private acceptResponse(response: KdlRpcResponse): void {
const pending = this.pending.get(response.id);
if (!pending) {
return;
}
this.pending.delete(response.id);
if (response.ok) {
pending.resolve(response.result);
} else {
pending.reject(
rpcErrorToException(
response.error ?? {
code: "KDL_RPC_MISSING_ERROR",
message: `KDL worker returned a failed response for ${pending.method} without error details`
}
)
);
}
}
private failWorker(error: Error): void {
const structured = new KdlStructuredError("KDL_WORKER_CRASHED", error.message);
for (const pending of this.pending.values()) {
pending.reject(structured);
}
this.pending.clear();
this.detachWorker();
}
private detachWorker(): void {
if (!this.worker) {
return;
}
this.worker.removeEventListener("message", this.handleMessage);
this.worker.removeEventListener("error", this.handleError);
this.worker.terminate?.();
this.worker = undefined;
}
}

View File

@@ -0,0 +1,118 @@
import { KdlStructuredError } from "./rpc.js";
import type { KdlError } from "./types.js";
import type { NativeKdlModule } from "./nativeModule.js";
export const KDL_C_ABI_EXPORTS = [
"kdl_init",
"kdl_create_robot",
"kdl_destroy_robot",
"kdl_get_robot_info",
"kdl_fk",
"kdl_fk_all_links",
"kdl_jacobian",
"kdl_ik",
"kdl_plan_movej",
"kdl_plan_movel",
"kdl_plan_movec",
"kdl_plan_path",
"kdl_sample_trap",
"kdl_last_error"
] as const;
export type KdlCAbiExport = (typeof KDL_C_ABI_EXPORTS)[number];
export class KdlNativeAbi {
constructor(private readonly module: NativeKdlModule) {
this.assertRuntimeMethods();
}
assertExports(names: readonly KdlCAbiExport[] = KDL_C_ABI_EXPORTS): void {
for (const name of names) {
try {
if (typeof this.module.cwrap?.(name, "number", []) === "function") {
continue;
}
} catch {
// Normalized below.
}
{
throw new KdlStructuredError("KDL_C_ABI_EXPORT_MISSING", `Missing C ABI export: ${name}`);
}
}
}
callNumber(ident: KdlCAbiExport, argTypes: Array<string | null>, args: unknown[]): number {
return Number(this.module.ccall(ident, "number", argTypes, args));
}
checkReturnCode(returnCode: number): void {
if (returnCode >= 0) {
return;
}
const error = this.lastError();
throw new KdlStructuredError(error.code, error.message, error.diagnostics);
}
readJsonCall<T>(ident: KdlCAbiExport, argTypes: Array<string | null>, args: unknown[], bytes = 16_384): T {
const ptr = this.malloc(bytes);
try {
const returnCode = this.callNumber(ident, [...argTypes, "number", "number"], [...args, ptr, bytes]);
this.checkReturnCode(returnCode);
return JSON.parse(this.module.UTF8ToString?.(ptr) ?? "") as T;
} finally {
this.free(ptr);
}
}
lastError(bytes = 16_384): KdlError {
const ptr = this.malloc(bytes);
try {
const returnCode = this.callNumber("kdl_last_error", ["number", "number"], [ptr, bytes]);
if (returnCode < 0) {
return {
code: "KDL_LAST_ERROR_FAILED",
message: "kdl_last_error failed",
diagnostics: [
{
severity: "error",
code: "KDL_LAST_ERROR_FAILED",
message: "kdl_last_error failed"
}
]
};
}
return JSON.parse(this.module.UTF8ToString?.(ptr) ?? "") as KdlError;
} finally {
this.free(ptr);
}
}
private assertRuntimeMethods(): void {
const missing = [
["cwrap", this.module.cwrap],
["UTF8ToString", this.module.UTF8ToString],
["_malloc", this.module._malloc],
["_free", this.module._free]
].flatMap(([name, value]) => (typeof value === "function" ? [] : [name as string]));
if (missing.length > 0) {
throw new KdlStructuredError(
"KDL_C_ABI_RUNTIME_MISSING",
`KDL native module is missing runtime methods: ${missing.join(", ")}`
);
}
}
private malloc(bytes: number): number {
const ptr = this.module._malloc?.(bytes);
if (!ptr) {
throw new KdlStructuredError("KDL_WASM_ALLOC_FAILED", `Failed to allocate ${bytes} bytes`);
}
return ptr;
}
private free(ptr: number): void {
this.module._free?.(ptr);
}
}

View File

@@ -0,0 +1,58 @@
import type { KdlInitOptions } from "./types.js";
export interface NativeKdlModule {
ccall: (
ident: string,
returnType: string | null,
argTypes: Array<string | null>,
args: unknown[]
) => unknown;
cwrap?: (
ident: string,
returnType: string | null,
argTypes: Array<string | null>
) => (...args: unknown[]) => unknown;
UTF8ToString?: (ptr: number) => string;
stringToUTF8?: (value: string, outPtr: number, maxBytesToWrite: number) => void;
lengthBytesUTF8?: (value: string) => number;
_malloc?: (size: number) => number;
_free?: (ptr: number) => void;
HEAPF64?: Float64Array;
}
export type NativeKdlModuleLoader = (options: KdlInitOptions) => Promise<NativeKdlModule>;
interface NativeKdlModuleFactoryOptions {
locateFile?: (path: string, prefix: string) => string;
print?: (text: string) => void;
printErr?: (text: string) => void;
}
type NativeKdlModuleFactory = (
options?: NativeKdlModuleFactoryOptions
) => Promise<NativeKdlModule>;
export function createDefaultNativeKdlModuleLoader(defaultWrapperUrl?: string): NativeKdlModuleLoader {
return async (options) => {
const wrapperUrl =
options.wrapperUrl ?? defaultWrapperUrl ?? new URL("../../../build-wasm/kdl.js", import.meta.url).href;
const imported = (await import(/* @vite-ignore */ wrapperUrl)) as {
default?: NativeKdlModuleFactory;
createKdlModule?: NativeKdlModuleFactory;
};
const factory = imported.default ?? imported.createKdlModule;
if (typeof factory !== "function") {
throw new Error(`KDL WASM wrapper did not export a module factory: ${wrapperUrl}`);
}
return factory({
locateFile: (path, prefix) => {
if (path.endsWith(".wasm") && options.wasmUrl) {
return options.wasmUrl;
}
return new URL(path, prefix || wrapperUrl).href;
}
});
};
}

View File

@@ -0,0 +1,256 @@
import { KdlWorkerRuntime } from "./runtime.js";
import type { PerformanceBaselineResult, PerformanceMetric, Pose, TrajectoryResult } from "./types.js";
const SIX_AXIS_URDF = `
<robot name="performance_6_axis">
<link name="base_link"/>
<link name="link_1"/>
<link name="link_2"/>
<link name="link_3"/>
<link name="link_4"/>
<link name="link_5"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_2" type="revolute">
<parent link="link_1"/>
<child link="link_2"/>
<origin xyz="0.2 0 0" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_3" type="revolute">
<parent link="link_2"/>
<child link="link_3"/>
<origin xyz="0.2 0 0" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_4" type="revolute">
<parent link="link_3"/>
<child link="link_4"/>
<origin xyz="0.1 0 0.1" rpy="0 0 0"/>
<axis xyz="1 0 0"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_5" type="revolute">
<parent link="link_4"/>
<child link="link_5"/>
<origin xyz="0.1 0 0" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_6" type="revolute">
<parent link="link_5"/>
<child link="tool0"/>
<origin xyz="0.1 0 0" rpy="0 0 0"/>
<axis xyz="1 0 0"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
</robot>
`;
const PLANAR_URDF = `
<robot name="performance_planar">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="tool0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="1" velocity="1" acceleration="2"/>
</joint>
</robot>
`;
export interface PerformanceBaselineOptions {
fkIterations?: number;
ikIterations?: number;
reachabilityTargets?: number;
sampleTime?: number;
trajectorySeconds?: number;
}
export async function runPerformanceBaseline(
options: PerformanceBaselineOptions = {}
): Promise<PerformanceBaselineResult> {
const fkIterations = options.fkIterations ?? 1_000;
const ikIterations = options.ikIterations ?? 200;
const reachabilityTargets = options.reachabilityTargets ?? 1_000;
const sampleTime = options.sampleTime ?? 0.004;
const trajectorySeconds = options.trajectorySeconds ?? 10;
const runtime = new KdlWorkerRuntime();
const metrics: PerformanceMetric[] = [];
const initStart = performance.now();
await runtime.init({ wasmBuild: "performance-baseline" });
const sixAxisHandle = await runtime.loadRobotFromUrdf(SIX_AXIS_URDF, {
robotId: "performance_6_axis",
baseLink: "base_link",
tipLink: "tool0"
});
metrics.push(singleMetric("robot_init_6_axis", performance.now() - initStart, 1_000));
const fkInput = new Float64Array([0.1, -0.2, 0.15, 0.05, -0.1, 0.2]);
const fkOutput = new Float64Array(7);
metrics.push(await repeatedMetric("fk_pose7_typed_array", fkIterations, 1, () => {
return runtime.fkPose7(sixAxisHandle, fkInput, fkOutput);
}));
const planarHandle = await runtime.loadRobotFromUrdf(PLANAR_URDF, {
robotId: "performance_planar",
baseLink: "base_link",
tipLink: "tool0"
});
const ikTargets = makeTargets(ikIterations, 0.15, 0.65);
metrics.push(await repeatedMetric("ik_planar_average", ikIterations, 10, (index) => {
return runtime.ik(planarHandle, new Float64Array([0, 0.2]), ikTargets[index]!, {
positionTolerance: 1e-9
});
}));
const reachability = makePoseTargets(reachabilityTargets, 0.05, 0.95);
const reachabilityStart = performance.now();
const reachabilityResult = await runtime.checkReachabilityBatch(planarHandle, reachability);
const reachabilityMs = performance.now() - reachabilityStart;
metrics.push({
name: "reachability_batch_1000",
iterations: 1,
totalMs: reachabilityMs,
averageMs: reachabilityMs,
thresholdMs: 500,
points: reachabilityTargets,
ok: reachabilityMs <= 500 && reachabilityResult.length === reachabilityTargets
});
const trajectoryStart = performance.now();
const trajectory = await runtime.planMoveJ(planarHandle, {
startJoints: [0, 0],
target: {
id: "ten_second_goal",
joints: [0, 1]
},
speed: {
kind: "joint_abs",
velocity: 1 / trajectorySeconds,
acceleration: 1
},
zone: {
kind: "fine"
},
sampleTime
});
const trajectoryMs = performance.now() - trajectoryStart;
metrics.push(trajectoryMetric("trajectory_10s_4ms", trajectory, trajectoryMs, 500));
await runtime.dispose();
return {
ok: metrics.every((metric) => metric.ok),
metrics,
diagnostics: metrics.flatMap((metric) =>
metric.ok
? []
: [
{
severity: "warning" as const,
code: "KDL_PERFORMANCE_BASELINE_MISS",
message: `${metric.name} exceeded ${metric.thresholdMs ?? "unbounded"} ms`,
data: {
metric
}
}
]
)
};
}
function singleMetric(name: string, totalMs: number, thresholdMs: number): PerformanceMetric {
return {
name,
iterations: 1,
totalMs,
averageMs: totalMs,
thresholdMs,
ok: totalMs <= thresholdMs
};
}
async function repeatedMetric(
name: string,
iterations: number,
thresholdMs: number,
fn: (index: number) => unknown | Promise<unknown>
): Promise<PerformanceMetric> {
let maxMs = 0;
const start = performance.now();
for (let index = 0; index < iterations; index += 1) {
const before = performance.now();
await fn(index);
maxMs = Math.max(maxMs, performance.now() - before);
}
const totalMs = performance.now() - start;
const averageMs = totalMs / iterations;
return {
name,
iterations,
totalMs,
averageMs,
maxMs,
thresholdMs,
ok: averageMs <= thresholdMs
};
}
function trajectoryMetric(
name: string,
trajectory: TrajectoryResult,
totalMs: number,
thresholdMs: number
): PerformanceMetric {
const points = trajectory.points.length;
return {
name,
iterations: 1,
totalMs,
averageMs: totalMs,
thresholdMs,
points,
ok: trajectory.ok && points >= 2_500 && totalMs <= thresholdMs
};
}
function makeTargets(count: number, minRadius: number, maxRadius: number): Pose[] {
return Array.from({ length: count }, (_, index) => {
const ratio = count <= 1 ? 0 : index / (count - 1);
const angle = ratio * Math.PI * 2;
const radius = minRadius + (maxRadius - minRadius) * ((index % 97) / 96);
return pose(Math.cos(angle) * radius, Math.sin(angle) * radius);
});
}
function makePoseTargets(count: number, minRadius: number, maxRadius: number) {
return makeTargets(count, minRadius, maxRadius).map((poseValue, index) => ({
id: `target_${index}`,
pose: poseValue
}));
}
function pose(x: number, y: number): Pose {
return {
position: [x, y, 0],
quaternion: [0, 0, 0, 1]
};
}

View File

@@ -0,0 +1,99 @@
import { KdlStructuredError } from "./rpc.js";
import {
composePose as composePoseMath,
inversePose as inversePoseMath,
normalizeQuaternion,
rpyToQuaternion
} from "../math/poseMath.js";
import type { OffsetSpec, Pose, PoseLike, PoseTarget } from "./types.js";
export function normalizePose(input: PoseLike): Pose {
if (!input || typeof input !== "object") {
throw new KdlStructuredError("KDL_INVALID_POSE", "Pose input must be an object");
}
if ("position" in input && "quaternion" in input) {
return {
position: validateVector3(input.position, "position"),
quaternion: normalizeQuaternion(validateVector4(input.quaternion, "quaternion"))
};
}
if ("xyz" in input && "rpy" in input) {
return {
position: validateVector3(input.xyz, "xyz"),
quaternion: rpyToQuaternion(validateVector3(input.rpy, "rpy"))
};
}
if ("xyz" in input && "quat" in input) {
return {
position: validateVector3(input.xyz, "xyz"),
quaternion: normalizeQuaternion(validateVector4(input.quat, "quat"))
};
}
throw new KdlStructuredError("KDL_INVALID_POSE", "Pose input must contain position/quaternion, xyz/rpy, or xyz/quat");
}
export function composePose(a: Pose, b: Pose): Pose {
return composePoseMath(normalizePose(a), normalizePose(b));
}
export function inversePose(pose: Pose): Pose {
return inversePoseMath(normalizePose(pose));
}
export function applyToolAndFrame(target: PoseTarget, tool: Pose, frame: Pose): Pose {
return composePose(composePose(normalizePose(frame), normalizePose(target.pose)), normalizePose(tool));
}
export function applyOffset(target: PoseTarget, offset: OffsetSpec): PoseTarget {
const offsetPose = offsetToPose(offset);
const pose = offset.mode === "tool" ? composePose(target.pose, offsetPose) : composePose(offsetPose, target.pose);
return {
...target,
pose
};
}
function offsetToPose(offset: OffsetSpec): Pose {
const xyz: [number, number, number] = offset.xyz ? validateVector3(offset.xyz, "offset.xyz") : [0, 0, 0];
if (offset.rpy && offset.quaternion) {
throw new KdlStructuredError("KDL_INVALID_OFFSET", "Offset cannot specify both rpy and quaternion");
}
if (offset.rpy) {
return {
position: xyz,
quaternion: rpyToQuaternion(validateVector3(offset.rpy, "offset.rpy"))
};
}
if (offset.quaternion) {
return {
position: xyz,
quaternion: normalizeQuaternion(validateVector4(offset.quaternion, "offset.quaternion"))
};
}
return {
position: xyz,
quaternion: [0, 0, 0, 1]
};
}
function validateVector3(value: unknown, field: string): [number, number, number] {
if (!Array.isArray(value) || value.length !== 3 || value.some((entry) => !Number.isFinite(entry))) {
throw new KdlStructuredError("KDL_INVALID_POSE", `${field} must contain 3 finite numbers`);
}
return [value[0]!, value[1]!, value[2]!];
}
function validateVector4(value: unknown, field: string): [number, number, number, number] {
if (!Array.isArray(value) || value.length !== 4 || value.some((entry) => !Number.isFinite(entry))) {
throw new KdlStructuredError("KDL_INVALID_POSE", `${field} must contain 4 finite numbers`);
}
return [value[0]!, value[1]!, value[2]!, value[3]!];
}

View File

@@ -0,0 +1,78 @@
import type { KdlApiMethod, KdlError, KdlWasmApi, MotionDiagnostic } from "./types.js";
export interface KdlRpcRequest<T = unknown> {
id: number;
method: KdlApiMethod;
payload: T;
}
export interface KdlRpcErrorPayload {
code: string;
message: string;
diagnostics?: MotionDiagnostic[];
}
export interface KdlRpcResponse<T = unknown> {
id: number;
ok: boolean;
result?: T;
error?: KdlRpcErrorPayload;
}
export class KdlStructuredError extends Error implements KdlError {
readonly code: string;
readonly diagnostics: MotionDiagnostic[];
constructor(code: string, message: string, diagnostics?: MotionDiagnostic[]) {
super(message);
this.name = "KdlStructuredError";
this.code = code;
this.diagnostics = diagnostics ?? [
{
severity: "error",
code,
message
}
];
}
}
export type KdlRuntimeHandlers = Partial<{
[Method in keyof KdlWasmApi]: (...args: unknown[]) => Promise<unknown> | unknown;
}>;
export function createRpcError(
code: string,
message: string,
diagnostics?: MotionDiagnostic[]
): KdlRpcErrorPayload {
return {
code,
message,
diagnostics:
diagnostics ??
[
{
severity: "error",
code,
message
}
]
};
}
export function normalizeThrownError(error: unknown): KdlRpcErrorPayload {
if (error instanceof KdlStructuredError) {
return createRpcError(error.code, error.message, error.diagnostics);
}
if (error instanceof Error) {
return createRpcError("KDL_WORKER_ERROR", error.message);
}
return createRpcError("KDL_WORKER_ERROR", String(error));
}
export function rpcErrorToException(error: KdlRpcErrorPayload): KdlStructuredError {
return new KdlStructuredError(error.code, error.message, error.diagnostics);
}

View File

@@ -0,0 +1,329 @@
import { KdlStructuredError, type KdlRuntimeHandlers } from "./rpc.js";
import type { NativeKdlModule, NativeKdlModuleLoader } from "./nativeModule.js";
import {
applyOffset as applyOffsetToTarget,
applyToolAndFrame as applyToolAndFrameToTarget,
composePose as composeRuntimePose,
inversePose as inverseRuntimePose,
normalizePose as normalizeRuntimePose
} from "./poseApi.js";
import {
makeTrapProfile as makeRuntimeTrapProfile,
sampleTrapProfile as sampleRuntimeTrapProfile
} from "./trapProfile.js";
import {
estimateCycleTime as estimateRuntimeCycleTime,
resampleTrajectory as resampleRuntimeTrajectory
} from "./trajectoryUtils.js";
import { loadRobotFromUrdfModel } from "../robot/urdfParser.js";
import { RobotModelRegistry } from "../robot/normalizedRobotModel.js";
import type {
FkOptions,
IkOptions,
JacobianOptions,
KdlInitOptions,
KdlRuntimeInfo,
MoveCRequest,
MoveJRequest,
MoveLRequest,
NormalizedRobotModel,
OffsetSpec,
PathPlanRequest,
PathPlanResult,
Pose,
PoseLike,
PoseNormalizeOptions,
PoseTarget,
RobotHandle,
TrapProfileOptions,
TrajectoryResult,
UrdfLoadOptions
} from "./types.js";
export class KdlWorkerRuntime {
private initialized = false;
private nativeModule: NativeKdlModule | undefined;
private readonly robots = new RobotModelRegistry();
constructor(private readonly loadNativeModule?: NativeKdlModuleLoader) {}
async init(options?: KdlInitOptions): Promise<KdlRuntimeInfo> {
if (this.loadNativeModule) {
await this.initNativeModule(options ?? {});
}
this.initialized = true;
return {
version: "0.1.0",
wasmBuild: options?.wasmBuild ?? (this.nativeModule ? "wasm" : "stub"),
supportsThreads: options?.useThreads ?? false,
supportsWasmFs: false
};
}
async dispose(): Promise<void> {
this.initialized = false;
this.nativeModule = undefined;
}
assertInitialized(method: string): void {
if (!this.initialized) {
throw new KdlStructuredError(
"KDL_NOT_INITIALIZED",
`KDL runtime must be initialized before calling ${method}`
);
}
}
async loadRobotFromUrdf(urdfXml: string, options: UrdfLoadOptions): Promise<RobotHandle> {
this.assertInitialized("loadRobotFromUrdf");
const model = loadRobotFromUrdfModel(urdfXml, options);
return this.createRobotFromModel(model);
}
async createRobotFromModel(model: NormalizedRobotModel): Promise<RobotHandle> {
this.assertInitialized("createRobotFromModel");
return this.robots.create(model);
}
async destroyRobot(handle: RobotHandle): Promise<void> {
this.assertInitialized("destroyRobot");
this.robots.destroy(handle);
}
async getRobotInfo(handle: RobotHandle) {
this.assertInitialized("getRobotInfo");
return this.robots.getInfo(handle);
}
async getJointLimits(handle: RobotHandle) {
this.assertInitialized("getJointLimits");
return this.robots.getJointLimits(handle);
}
async normalizePose(input: PoseLike, _options?: PoseNormalizeOptions) {
this.assertInitialized("normalizePose");
return normalizeRuntimePose(input);
}
async composePose(a: Pose, b: Pose) {
this.assertInitialized("composePose");
return composeRuntimePose(a, b);
}
async inversePose(pose: Pose) {
this.assertInitialized("inversePose");
return inverseRuntimePose(pose);
}
async applyToolAndFrame(target: PoseTarget, tool: Pose, frame: Pose) {
this.assertInitialized("applyToolAndFrame");
return applyToolAndFrameToTarget(target, tool, frame);
}
async applyOffset(target: PoseTarget, offset: OffsetSpec) {
this.assertInitialized("applyOffset");
return applyOffsetToTarget(target, offset);
}
async makeTrapProfile(length: number, options: TrapProfileOptions) {
this.assertInitialized("makeTrapProfile");
return makeRuntimeTrapProfile(length, options);
}
async sampleTrapProfile(length: number, options: TrapProfileOptions) {
this.assertInitialized("sampleTrapProfile");
return sampleRuntimeTrapProfile(length, options);
}
async fk(handle: RobotHandle, joints: Float64Array | number[], options?: FkOptions) {
this.assertInitialized("fk");
return this.robots.fk(handle, joints, options);
}
async fkPose7(handle: RobotHandle, joints: Float64Array | number[], out?: Float64Array, options?: FkOptions) {
this.assertInitialized("fkPose7");
return this.robots.fkPose7(handle, joints, out, options);
}
async fkAllLinks(handle: RobotHandle, joints: Float64Array | number[], options?: FkOptions) {
this.assertInitialized("fkAllLinks");
return this.robots.fkAllLinks(handle, joints, options);
}
async ik(handle: RobotHandle, seed: Float64Array | number[], target: Pose, options?: IkOptions) {
this.assertInitialized("ik");
return this.robots.ik(handle, seed, target, options);
}
async ikBatch(
handle: RobotHandle,
seeds: Array<Float64Array | number[]>,
targets: Pose[],
options?: IkOptions
) {
this.assertInitialized("ikBatch");
return this.robots.ikBatch(handle, seeds, targets, options);
}
async jacobian(handle: RobotHandle, joints: Float64Array | number[], options?: JacobianOptions) {
this.assertInitialized("jacobian");
return this.robots.jacobian(handle, joints, options);
}
async checkSingularity(handle: RobotHandle, joints: Float64Array | number[]) {
this.assertInitialized("checkSingularity");
return this.robots.checkSingularity(handle, joints);
}
async checkJointLimits(handle: RobotHandle, joints: Float64Array | number[]) {
this.assertInitialized("checkJointLimits");
return this.robots.checkJointLimits(handle, joints);
}
async checkVelocityLimits(handle: RobotHandle, trajectory: TrajectoryResult) {
this.assertInitialized("checkVelocityLimits");
return this.robots.checkVelocityLimits(handle, trajectory);
}
async checkReachability(handle: RobotHandle, target: PoseTarget, options?: IkOptions) {
this.assertInitialized("checkReachability");
return this.robots.checkReachability(handle, target, options);
}
async checkReachabilityBatch(handle: RobotHandle, targets: PoseTarget[], options?: IkOptions) {
this.assertInitialized("checkReachabilityBatch");
return this.robots.checkReachabilityBatch(handle, targets, options);
}
async planMoveJ(handle: RobotHandle, request: MoveJRequest) {
this.assertInitialized("planMoveJ");
return this.robots.planMoveJ(handle, request);
}
async planMoveL(handle: RobotHandle, request: MoveLRequest) {
this.assertInitialized("planMoveL");
return this.robots.planMoveL(handle, request);
}
async planMoveC(handle: RobotHandle, request: MoveCRequest) {
this.assertInitialized("planMoveC");
return this.robots.planMoveC(handle, request);
}
async planPath(handle: RobotHandle, request: PathPlanRequest) {
this.assertInitialized("planPath");
return this.robots.planPath(handle, request);
}
async validatePath(handle: RobotHandle, request: PathPlanRequest) {
this.assertInitialized("validatePath");
return this.robots.validatePath(handle, request);
}
async estimateCycleTime(input: TrajectoryResult | PathPlanResult) {
this.assertInitialized("estimateCycleTime");
return estimateRuntimeCycleTime(input);
}
async resampleTrajectory(trajectory: TrajectoryResult, sampleTime: number) {
this.assertInitialized("resampleTrajectory");
return resampleRuntimeTrajectory(trajectory, sampleTime);
}
private async initNativeModule(options: KdlInitOptions): Promise<void> {
try {
const nativeModule = await this.loadNativeModule?.(options);
if (!nativeModule) {
throw new Error("No KDL native module was returned");
}
const result = nativeModule.ccall("kdl_init", "number", ["string"], [JSON.stringify(options)]);
if (Number(result) !== 0) {
throw new Error(`kdl_init returned ${String(result)}`);
}
this.nativeModule = nativeModule;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new KdlStructuredError(
"KDL_WASM_INIT_FAILED",
`Failed to initialize KDL WASM runtime: ${message}`
);
}
}
}
export function createKdlWorkerRuntime(loadNativeModule?: NativeKdlModuleLoader): KdlRuntimeHandlers {
const runtime = new KdlWorkerRuntime(loadNativeModule);
return {
init: (options?: unknown) => runtime.init(options as KdlInitOptions | undefined),
dispose: () => runtime.dispose(),
loadRobotFromUrdf: (urdfXml: unknown, options: unknown) =>
runtime.loadRobotFromUrdf(urdfXml as string, options as UrdfLoadOptions),
createRobotFromModel: (model: unknown) => runtime.createRobotFromModel(model as NormalizedRobotModel),
destroyRobot: (handle: unknown) => runtime.destroyRobot(handle as RobotHandle),
getRobotInfo: (handle: unknown) => runtime.getRobotInfo(handle as RobotHandle),
getJointLimits: (handle: unknown) => runtime.getJointLimits(handle as RobotHandle),
normalizePose: (input: unknown, options: unknown) =>
runtime.normalizePose(input as PoseLike, options as PoseNormalizeOptions | undefined),
composePose: (a: unknown, b: unknown) => runtime.composePose(a as Pose, b as Pose),
inversePose: (pose: unknown) => runtime.inversePose(pose as Pose),
applyToolAndFrame: (target: unknown, tool: unknown, frame: unknown) =>
runtime.applyToolAndFrame(target as PoseTarget, tool as Pose, frame as Pose),
applyOffset: (target: unknown, offset: unknown) =>
runtime.applyOffset(target as PoseTarget, offset as OffsetSpec),
makeTrapProfile: (length: unknown, options: unknown) =>
runtime.makeTrapProfile(length as number, options as TrapProfileOptions),
sampleTrapProfile: (length: unknown, options: unknown) =>
runtime.sampleTrapProfile(length as number, options as TrapProfileOptions),
fk: (handle: unknown, joints: unknown, options: unknown) =>
runtime.fk(handle as RobotHandle, joints as Float64Array | number[], options as FkOptions | undefined),
fkPose7: (handle: unknown, joints: unknown, out: unknown, options: unknown) =>
runtime.fkPose7(
handle as RobotHandle,
joints as Float64Array | number[],
out as Float64Array | undefined,
options as FkOptions | undefined
),
fkAllLinks: (handle: unknown, joints: unknown, options: unknown) =>
runtime.fkAllLinks(handle as RobotHandle, joints as Float64Array | number[], options as FkOptions | undefined),
ik: (handle: unknown, seed: unknown, target: unknown, options: unknown) =>
runtime.ik(handle as RobotHandle, seed as Float64Array | number[], target as Pose, options as IkOptions | undefined),
ikBatch: (handle: unknown, seeds: unknown, targets: unknown, options: unknown) =>
runtime.ikBatch(
handle as RobotHandle,
seeds as Array<Float64Array | number[]>,
targets as Pose[],
options as IkOptions | undefined
),
jacobian: (handle: unknown, joints: unknown, options: unknown) =>
runtime.jacobian(handle as RobotHandle, joints as Float64Array | number[], options as JacobianOptions | undefined),
checkSingularity: (handle: unknown, joints: unknown) =>
runtime.checkSingularity(handle as RobotHandle, joints as Float64Array | number[]),
checkJointLimits: (handle: unknown, joints: unknown) =>
runtime.checkJointLimits(handle as RobotHandle, joints as Float64Array | number[]),
checkVelocityLimits: (handle: unknown, trajectory: unknown) =>
runtime.checkVelocityLimits(handle as RobotHandle, trajectory as TrajectoryResult),
checkReachability: (handle: unknown, target: unknown, options: unknown) =>
runtime.checkReachability(handle as RobotHandle, target as PoseTarget, options as IkOptions | undefined),
checkReachabilityBatch: (handle: unknown, targets: unknown, options: unknown) =>
runtime.checkReachabilityBatch(handle as RobotHandle, targets as PoseTarget[], options as IkOptions | undefined),
planMoveJ: (handle: unknown, request: unknown) =>
runtime.planMoveJ(handle as RobotHandle, request as MoveJRequest),
planMoveL: (handle: unknown, request: unknown) =>
runtime.planMoveL(handle as RobotHandle, request as MoveLRequest),
planMoveC: (handle: unknown, request: unknown) =>
runtime.planMoveC(handle as RobotHandle, request as MoveCRequest),
planPath: (handle: unknown, request: unknown) =>
runtime.planPath(handle as RobotHandle, request as PathPlanRequest),
validatePath: (handle: unknown, request: unknown) =>
runtime.validatePath(handle as RobotHandle, request as PathPlanRequest),
estimateCycleTime: (input: unknown) =>
runtime.estimateCycleTime(input as TrajectoryResult | PathPlanResult),
resampleTrajectory: (trajectory: unknown, sampleTime: unknown) =>
runtime.resampleTrajectory(trajectory as TrajectoryResult, sampleTime as number)
};
}

View File

@@ -0,0 +1,163 @@
import { KdlStructuredError } from "./rpc.js";
import type {
CycleTimeResult,
MotionDiagnostic,
PathPlanResult,
Pose,
TrajectoryPoint,
TrajectoryResult
} from "./types.js";
export function estimateCycleTime(input: TrajectoryResult | PathPlanResult): CycleTimeResult {
const diagnostics: MotionDiagnostic[] = input.diagnostics ?? [];
if (isPathPlanResult(input)) {
const segmentTimes = input.segments.map((segment) =>
cycleTimeSegment(segment.motion, segment.duration, segment.points[0]?.segmentId)
);
return {
ok: input.ok,
motionTime: input.duration,
totalTime: input.duration,
segmentTimes,
diagnostics
};
}
return {
ok: input.ok,
motionTime: input.duration,
totalTime: input.duration,
segmentTimes: [cycleTimeSegment(input.motion, input.duration, input.points[0]?.segmentId)],
diagnostics
};
}
function cycleTimeSegment(
motion: TrajectoryResult["motion"],
duration: number,
segmentId?: string
): CycleTimeResult["segmentTimes"][number] {
return {
motion,
duration,
...(segmentId ? { segmentId } : {})
};
}
export function resampleTrajectory(trajectory: TrajectoryResult, sampleTime: number): TrajectoryResult {
if (!Number.isFinite(sampleTime) || sampleTime <= 0) {
throw new KdlStructuredError("KDL_INVALID_SAMPLE_TIME", "sampleTime must be a finite positive number");
}
if (trajectory.points.length === 0) {
return {
...trajectory,
sampleTime,
diagnostics: [
...trajectory.diagnostics,
{
severity: "warning",
code: "KDL_RESAMPLE_EMPTY_TRAJECTORY",
message: "Cannot resample a trajectory without points"
}
]
};
}
const duration = trajectory.duration;
const times = duration === 0 ? [0] : sampleTimes(duration, sampleTime);
const points = times.map((time, index) => {
const source = interpolatePoint(trajectory.points, time);
const previous = index > 0 ? times[index - 1]! : time;
return {
...source,
index,
time,
dt: index === 0 ? 0 : time - previous
};
});
return {
...trajectory,
sampleTime,
points,
diagnostics: [
...trajectory.diagnostics,
{
severity: "info",
code: "KDL_TRAJECTORY_RESAMPLED",
message: `Trajectory was resampled to ${sampleTime}s`
}
]
};
}
function isPathPlanResult(input: TrajectoryResult | PathPlanResult): input is PathPlanResult {
return "segments" in input;
}
function sampleTimes(duration: number, sampleTime: number): number[] {
const times: number[] = [0];
for (let time = sampleTime; time < duration - 1e-12; time += sampleTime) {
times.push(time);
}
times.push(duration);
return times;
}
function interpolatePoint(points: TrajectoryPoint[], time: number): TrajectoryPoint {
if (time <= points[0]!.time) {
return {
...points[0]!,
joints: [...points[0]!.joints],
jointVelocity: [...points[0]!.jointVelocity],
jointAcceleration: [...points[0]!.jointAcceleration]
};
}
const last = points.at(-1)!;
if (time >= last.time) {
return {
...last,
joints: [...last.joints],
jointVelocity: [...last.jointVelocity],
jointAcceleration: [...last.jointAcceleration]
};
}
const nextIndex = points.findIndex((point) => point.time >= time);
const next = points[nextIndex]!;
const prev = points[nextIndex - 1]!;
const ratio = (time - prev.time) / (next.time - prev.time);
return {
...next,
time,
s: lerp(prev.s, next.s, ratio),
sd: lerp(prev.sd, next.sd, ratio),
sdd: lerp(prev.sdd, next.sdd, ratio),
joints: lerpArray(prev.joints, next.joints, ratio),
jointVelocity: lerpArray(prev.jointVelocity, next.jointVelocity, ratio),
jointAcceleration: lerpArray(prev.jointAcceleration, next.jointAcceleration, ratio),
flange: lerpPose(prev.flange, next.flange, ratio),
tcp: lerpPose(prev.tcp, next.tcp, ratio),
diagnostics: []
};
}
function lerp(a: number, b: number, ratio: number): number {
return a + (b - a) * ratio;
}
function lerpArray(a: number[], b: number[], ratio: number): number[] {
const length = Math.max(a.length, b.length);
return Array.from({ length }, (_, index) => lerp(a[index] ?? 0, b[index] ?? 0, ratio));
}
function lerpPose(a: Pose, b: Pose, ratio: number): Pose {
return {
position: [
lerp(a.position[0], b.position[0], ratio),
lerp(a.position[1], b.position[1], ratio),
lerp(a.position[2], b.position[2], ratio)
],
quaternion: ratio < 0.5 ? a.quaternion : b.quaternion
};
}

View File

@@ -0,0 +1,222 @@
import { KdlStructuredError } from "./rpc.js";
import type { MotionDiagnostic, TrapProfileOptions, TrapProfileResult, TrapSample } from "./types.js";
export function makeTrapProfile(length: number, options: TrapProfileOptions): TrapProfileResult {
validateTrapInputs(length, options);
if (length === 0) {
return {
ok: true,
type: "triangle",
length,
duration: 0,
tAccel: 0,
tConst: 0,
tDecel: 0,
vPeak: 0,
samples: [
{
index: 0,
time: 0,
s: 0,
sd: 0,
sdd: 0
}
],
diagnostics: [
{
severity: "info",
code: "KDL_TRAP_ZERO_LENGTH",
message: "Trap profile length is zero"
}
]
};
}
const startVelocity = options.startVelocity ?? 0;
const endVelocity = options.endVelocity ?? 0;
const maxVelocity = options.maxVelocity;
const maxAcceleration = options.maxAcceleration;
const dAccelToMax = distanceForVelocityChange(startVelocity, maxVelocity, maxAcceleration);
const dDecelFromMax = distanceForVelocityChange(endVelocity, maxVelocity, maxAcceleration);
const diagnostics: MotionDiagnostic[] = [];
let type: TrapProfileResult["type"] = "trapezoid";
let vPeak = maxVelocity;
let tConst = 0;
if (dAccelToMax + dDecelFromMax <= length) {
tConst = (length - dAccelToMax - dDecelFromMax) / maxVelocity;
} else {
type = "triangle";
vPeak = Math.sqrt(Math.max(0, maxAcceleration * length + (startVelocity ** 2 + endVelocity ** 2) / 2));
if (vPeak + 1e-12 < Math.max(startVelocity, endVelocity)) {
throw new KdlStructuredError(
"KDL_INVALID_TRAP_PROFILE",
"Profile length is too short for the requested startVelocity/endVelocity"
);
}
tConst = 0;
diagnostics.push({
severity: "info",
code: "KDL_TRAP_TRIANGLE_PROFILE",
message: "Trap profile length is too short to reach maxVelocity; using triangle profile"
});
}
const tAccel = Math.max(0, (vPeak - startVelocity) / maxAcceleration);
const tDecel = Math.max(0, (vPeak - endVelocity) / maxAcceleration);
const duration = tAccel + tConst + tDecel;
const samples = sampleProfile({
length,
sampleTime: options.sampleTime,
startVelocity,
endVelocity,
maxAcceleration,
tAccel,
tConst,
tDecel,
vPeak,
duration
});
return {
ok: true,
type,
length,
duration,
tAccel,
tConst,
tDecel,
vPeak,
samples,
diagnostics
};
}
export function sampleTrapProfile(length: number, options: TrapProfileOptions): TrapSample[] {
return makeTrapProfile(length, options).samples;
}
interface ProfileSegments {
length: number;
sampleTime: number;
startVelocity: number;
endVelocity: number;
maxAcceleration: number;
tAccel: number;
tConst: number;
tDecel: number;
vPeak: number;
duration: number;
}
function validateTrapInputs(length: number, options: TrapProfileOptions): void {
if (!Number.isFinite(length) || length < 0) {
throw new KdlStructuredError("KDL_INVALID_TRAP_PROFILE", "Trap profile length must be a finite non-negative number");
}
if (!Number.isFinite(options.maxVelocity) || options.maxVelocity <= 0) {
throw new KdlStructuredError("KDL_INVALID_TRAP_PROFILE", "maxVelocity must be a finite positive number");
}
if (!Number.isFinite(options.maxAcceleration) || options.maxAcceleration <= 0) {
throw new KdlStructuredError("KDL_INVALID_TRAP_PROFILE", "maxAcceleration must be a finite positive number");
}
if (!Number.isFinite(options.sampleTime) || options.sampleTime <= 0) {
throw new KdlStructuredError("KDL_INVALID_TRAP_PROFILE", "sampleTime must be a finite positive number");
}
const startVelocity = options.startVelocity ?? 0;
const endVelocity = options.endVelocity ?? 0;
if (length === 0 && (startVelocity > 0 || endVelocity > 0)) {
throw new KdlStructuredError(
"KDL_INVALID_TRAP_PROFILE",
"Zero-length trap profile requires zero startVelocity and endVelocity"
);
}
if (!Number.isFinite(startVelocity) || startVelocity < 0 || startVelocity > options.maxVelocity) {
throw new KdlStructuredError(
"KDL_INVALID_TRAP_PROFILE",
"startVelocity must be finite, non-negative, and no greater than maxVelocity"
);
}
if (!Number.isFinite(endVelocity) || endVelocity < 0 || endVelocity > options.maxVelocity) {
throw new KdlStructuredError(
"KDL_INVALID_TRAP_PROFILE",
"endVelocity must be finite, non-negative, and no greater than maxVelocity"
);
}
}
function distanceForVelocityChange(fromVelocity: number, toVelocity: number, acceleration: number): number {
return Math.max(0, (toVelocity ** 2 - fromVelocity ** 2) / (2 * acceleration));
}
function sampleProfile(profile: ProfileSegments): TrapSample[] {
if (profile.duration === 0) {
return [
{
index: 0,
time: 0,
s: 0,
sd: 0,
sdd: 0
}
];
}
const times: number[] = [0];
for (let time = profile.sampleTime; time < profile.duration - 1e-12; time += profile.sampleTime) {
times.push(time);
}
times.push(profile.duration);
return times.map((time, index) => {
const sample = sampleAtTime(profile, time);
return {
index,
time,
s: index === 0 ? 0 : index === times.length - 1 ? 1 : clamp01(sample.distance / profile.length),
sd: sample.velocity / profile.length,
sdd: sample.acceleration / profile.length
};
});
}
function sampleAtTime(profile: ProfileSegments, time: number): {
distance: number;
velocity: number;
acceleration: number;
} {
const accelDistance =
profile.startVelocity * profile.tAccel + 0.5 * profile.maxAcceleration * profile.tAccel ** 2;
const constDistance = profile.vPeak * profile.tConst;
const accelEnd = profile.tAccel;
const constEnd = profile.tAccel + profile.tConst;
if (time <= accelEnd) {
return {
distance: profile.startVelocity * time + 0.5 * profile.maxAcceleration * time ** 2,
velocity: profile.startVelocity + profile.maxAcceleration * time,
acceleration: profile.maxAcceleration
};
}
if (time <= constEnd) {
const localTime = time - profile.tAccel;
return {
distance: accelDistance + profile.vPeak * localTime,
velocity: profile.vPeak,
acceleration: 0
};
}
const localTime = Math.min(time - constEnd, profile.tDecel);
return {
distance: accelDistance + constDistance + profile.vPeak * localTime - 0.5 * profile.maxAcceleration * localTime ** 2,
velocity: Math.max(profile.endVelocity, profile.vPeak - profile.maxAcceleration * localTime),
acceleration: -profile.maxAcceleration
};
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}

View File

@@ -0,0 +1,494 @@
export type RobotHandle = number;
export interface MotionSourceMap {
file?: string;
line?: number;
column?: number;
module?: string;
}
export interface MotionDiagnostic {
severity: "info" | "warning" | "error";
code: string;
message: string;
time?: number;
pointIndex?: number;
segmentId?: string;
targetId?: string;
sourceMap?: MotionSourceMap;
data?: Record<string, unknown>;
}
export interface KdlError {
code: string;
message: string;
diagnostics: MotionDiagnostic[];
}
export interface KdlInitOptions {
wrapperUrl?: string;
wasmUrl?: string;
useThreads?: boolean;
wasmBuild?: string;
}
export interface KdlRuntimeInfo {
version: string;
kdlVersion?: string;
wasmBuild: string;
supportsThreads: boolean;
supportsWasmFs: boolean;
}
export interface Pose {
position: [number, number, number];
quaternion: [number, number, number, number];
}
export type PoseLike =
| Pose
| { xyz: [number, number, number]; rpy: [number, number, number] }
| { xyz: [number, number, number]; quat: [number, number, number, number] };
export interface RobotConfiguration {
shoulder?: -1 | 0 | 1;
elbow?: -1 | 0 | 1;
wrist?: -1 | 0 | 1;
turnNumbers?: number[];
}
export interface PoseTarget {
id?: string;
pose: Pose;
config?: RobotConfiguration;
tool?: Pose;
frame?: Pose;
extAxis?: number[];
sourceMap?: MotionSourceMap;
}
export interface JointTarget {
id?: string;
joints: number[];
extAxis?: number[];
sourceMap?: MotionSourceMap;
}
export type SpeedSpec =
| { kind: "joint_percent"; value: number }
| { kind: "joint_abs"; velocity: number; acceleration?: number }
| { kind: "linear"; velocity: number; acceleration?: number; angularVelocity?: number };
export type ZoneSpec =
| { kind: "fine" }
| { kind: "distance"; value: number }
| { kind: "cnt"; value: number }
| { kind: "continuous" };
export interface JointLimits {
name: string;
lower: number;
upper: number;
velocity: number;
acceleration: number;
jerk?: number;
}
export interface RobotInfo {
handle: RobotHandle;
robotId: string;
name: string;
baseLink: string;
tipLink: string;
dof: number;
jointNames: string[];
limits: JointLimits[];
}
export type JointType = "revolute" | "continuous" | "prismatic" | "fixed";
export interface LinkModel {
name: string;
}
export interface JointModel {
name: string;
type: JointType;
parent: string;
child: string;
origin: {
xyz: [number, number, number];
rpy: [number, number, number];
};
axis: [number, number, number];
limit?: JointLimits;
}
export interface NormalizedRobotModel {
robotId: string;
baseLink: string;
tipLink: string;
name: string;
links: LinkModel[];
joints: JointModel[];
activeJointNames: string[];
limits: JointLimits[];
source: {
type: "urdf";
urdfHash: string;
};
}
export type JsonObject = Record<string, unknown>;
export interface JointLimitOverride {
name: string;
lower?: number;
upper?: number;
velocity?: number;
acceleration?: number;
jerk?: number;
}
export interface UrdfLoadOptions {
robotId: string;
baseLink: string;
tipLink: string;
tool?: Pose;
base?: Pose;
jointOrder?: string[];
overrideLimits?: JointLimitOverride[];
}
export type PoseNormalizeOptions = JsonObject;
export interface OffsetSpec {
mode: "frame" | "tool" | "world";
frameId?: string;
xyz?: [number, number, number];
rpy?: [number, number, number];
quaternion?: [number, number, number, number];
}
export interface FkOptions {
tool?: Pose;
frame?: Pose;
includeFlange?: boolean;
}
export interface FkResult {
ok: boolean;
flange: Pose;
tcp: Pose;
joints: number[];
diagnostics: MotionDiagnostic[];
}
export type Pose7Array = Float64Array | [number, number, number, number, number, number, number] | number[];
export interface LinkPoseResult {
ok: boolean;
linkPoses: Array<{ link: string; pose: Pose }>;
diagnostics: MotionDiagnostic[];
}
export type JacobianOptions = JsonObject;
export interface JacobianResult {
ok: boolean;
rows: number;
cols: number;
data: Float64Array | number[];
diagnostics: MotionDiagnostic[];
}
export interface IkOptions {
tool?: Pose;
frame?: Pose;
qMin?: number[];
qMax?: number[];
maxIterations?: number;
positionTolerance?: number;
orientationTolerance?: number;
seeds?: number[][];
preferredConfig?: RobotConfiguration;
allowApproximate?: boolean;
}
export interface IkResult {
ok: boolean;
joints?: number[];
iterations: number;
residualPosition?: number;
residualOrientation?: number;
configuration?: RobotConfiguration;
reason?: "unreachable" | "joint_limit" | "singularity" | "max_iteration" | "invalid_model";
diagnostics: MotionDiagnostic[];
}
export interface LimitCheckResult {
ok: boolean;
diagnostics: MotionDiagnostic[];
maxJointVelocityRatio?: number;
maxJointAccelerationRatio?: number;
}
export interface SingularityResult {
ok: boolean;
nearSingularity: boolean;
manipulability?: number;
conditionNumber?: number;
diagnostics: MotionDiagnostic[];
}
export interface ReachabilityResult {
ok: boolean;
reachable: boolean;
targetId?: string;
joints?: number[];
residualPosition?: number;
residualOrientation?: number;
nearestPose?: Pose;
diagnostics: MotionDiagnostic[];
}
export interface TrapProfileOptions {
maxVelocity: number;
maxAcceleration: number;
sampleTime: number;
startVelocity?: number;
endVelocity?: number;
}
export interface TrapSample {
index: number;
time: number;
s: number;
sd: number;
sdd: number;
}
export interface TrapProfileResult {
ok: boolean;
type: "trapezoid" | "triangle";
length: number;
duration: number;
tAccel: number;
tConst: number;
tDecel: number;
vPeak: number;
samples: TrapSample[];
diagnostics: MotionDiagnostic[];
}
export interface MoveJRequest {
startJoints: number[];
target: JointTarget | PoseTarget;
speed: SpeedSpec;
zone: ZoneSpec;
tool?: Pose;
frame?: Pose;
sampleTime: number;
speedOverride?: number;
sourceMap?: MotionSourceMap;
}
export interface MoveLRequest {
startJoints: number[];
target: PoseTarget;
speed: SpeedSpec;
zone: ZoneSpec;
tool?: Pose;
frame?: Pose;
sampleTime: number;
orientationMode?: "fixed" | "slerp" | "tool_z_lock";
ik?: IkOptions;
speedOverride?: number;
sourceMap?: MotionSourceMap;
}
export interface MoveCRequest {
startJoints: number[];
via: PoseTarget;
target: PoseTarget;
speed: SpeedSpec;
zone: ZoneSpec;
tool?: Pose;
frame?: Pose;
sampleTime: number;
orientationMode?: "fixed" | "slerp";
arcMode?: "via" | "center" | "radius";
circleDirection?: "short" | "long" | "cw" | "ccw";
ik?: IkOptions;
speedOverride?: number;
sourceMap?: MotionSourceMap;
}
export type MotionKind = "MOVEJ" | "MOVEL" | "MOVEC";
export interface TrajectoryEvent {
id?: string;
time: number;
pointIndex: number;
kind: string;
sourceMap?: MotionSourceMap;
data?: JsonObject;
}
export interface TrajectoryPoint {
index: number;
time: number;
dt: number;
s: number;
sd: number;
sdd: number;
joints: number[];
jointVelocity: number[];
jointAcceleration: number[];
flange: Pose;
tcp: Pose;
tcpVelocity?: [number, number, number, number, number, number];
tcpAcceleration?: [number, number, number, number, number, number];
motion: MotionKind;
segmentId?: string;
targetId?: string;
sourceMap?: MotionSourceMap;
diagnostics: MotionDiagnostic[];
}
export interface TrajectoryResult {
ok: boolean;
motion: MotionKind;
duration: number;
sampleTime: number;
points: TrajectoryPoint[];
events: TrajectoryEvent[];
diagnostics: MotionDiagnostic[];
meta?: JsonObject;
}
export interface MotionSegmentRequest {
id: string;
motion: MotionKind;
target?: JointTarget | PoseTarget;
via?: PoseTarget;
targetId?: string;
speed: SpeedSpec;
zone: ZoneSpec;
tool?: Pose;
frame?: Pose;
sourceMap?: MotionSourceMap;
source?: JsonObject;
}
export interface PathEventRequest {
id?: string;
timing: "before" | "after" | "at";
pointId: string;
distance?: number;
kind: string;
sourceMap?: MotionSourceMap;
data?: JsonObject;
}
export interface PathPlanRequest {
pathId?: string;
startJoints: number[];
segments: MotionSegmentRequest[];
events?: PathEventRequest[];
sampleTime: number;
speedOverride?: number;
stopOnError?: boolean;
source?: JsonObject;
}
export interface PathPlanResult {
ok: boolean;
duration: number;
segments: TrajectoryResult[];
points: TrajectoryPoint[];
diagnostics: MotionDiagnostic[];
}
export interface SegmentValidationReport {
segmentId: string;
ok: boolean;
motion: MotionKind;
duration?: number;
maxJointVelocityRatio?: number;
maxJointAccelerationRatio?: number;
maxCartesianError?: number;
diagnostics: MotionDiagnostic[];
}
export interface PathValidationResult {
ok: boolean;
reachable: boolean;
cycleTime?: number;
segmentReports: SegmentValidationReport[];
diagnostics: MotionDiagnostic[];
}
export interface CycleTimeSegment {
segmentId?: string;
motion: MotionKind;
duration: number;
}
export interface CycleTimeResult {
ok: boolean;
motionTime: number;
waitTime?: number;
ioTime?: number;
totalTime: number;
segmentTimes: CycleTimeSegment[];
diagnostics: MotionDiagnostic[];
}
export interface PerformanceMetric {
name: string;
iterations: number;
totalMs: number;
averageMs: number;
thresholdMs?: number;
maxMs?: number;
points?: number;
ok: boolean;
}
export interface PerformanceBaselineResult {
ok: boolean;
metrics: PerformanceMetric[];
diagnostics: MotionDiagnostic[];
}
export interface KdlWasmApi {
init(options?: KdlInitOptions): Promise<KdlRuntimeInfo>;
dispose(): Promise<void>;
loadRobotFromUrdf(urdfXml: string, options: UrdfLoadOptions): Promise<RobotHandle>;
createRobotFromModel(model: NormalizedRobotModel): Promise<RobotHandle>;
destroyRobot(handle: RobotHandle): Promise<void>;
getRobotInfo(handle: RobotHandle): Promise<RobotInfo>;
getJointLimits(handle: RobotHandle): Promise<JointLimits[]>;
normalizePose(input: PoseLike, options?: PoseNormalizeOptions): Promise<Pose>;
composePose(a: Pose, b: Pose): Promise<Pose>;
inversePose(pose: Pose): Promise<Pose>;
applyToolAndFrame(target: PoseTarget, tool: Pose, frame: Pose): Promise<Pose>;
applyOffset(target: PoseTarget, offset: OffsetSpec): Promise<PoseTarget>;
fk(handle: RobotHandle, joints: Float64Array, options?: FkOptions): Promise<FkResult>;
fkPose7(handle: RobotHandle, joints: Float64Array, out?: Float64Array, options?: FkOptions): Promise<Float64Array>;
fkAllLinks(handle: RobotHandle, joints: Float64Array, options?: FkOptions): Promise<LinkPoseResult>;
jacobian(handle: RobotHandle, joints: Float64Array, options?: JacobianOptions): Promise<JacobianResult>;
ik(handle: RobotHandle, seed: Float64Array, target: Pose, options?: IkOptions): Promise<IkResult>;
ikBatch(handle: RobotHandle, seeds: Float64Array[], targets: Pose[], options?: IkOptions): Promise<IkResult[]>;
checkJointLimits(handle: RobotHandle, joints: Float64Array): Promise<LimitCheckResult>;
checkVelocityLimits(handle: RobotHandle, trajectory: TrajectoryResult): Promise<LimitCheckResult>;
checkSingularity(handle: RobotHandle, joints: Float64Array): Promise<SingularityResult>;
checkReachability(handle: RobotHandle, target: PoseTarget, options?: IkOptions): Promise<ReachabilityResult>;
checkReachabilityBatch(handle: RobotHandle, targets: PoseTarget[], options?: IkOptions): Promise<ReachabilityResult[]>;
makeTrapProfile(length: number, options: TrapProfileOptions): Promise<TrapProfileResult>;
sampleTrapProfile(length: number, options: TrapProfileOptions): Promise<TrapSample[]>;
planMoveJ(handle: RobotHandle, request: MoveJRequest): Promise<TrajectoryResult>;
planMoveL(handle: RobotHandle, request: MoveLRequest): Promise<TrajectoryResult>;
planMoveC(handle: RobotHandle, request: MoveCRequest): Promise<TrajectoryResult>;
planPath(handle: RobotHandle, request: PathPlanRequest): Promise<PathPlanResult>;
validatePath(handle: RobotHandle, request: PathPlanRequest): Promise<PathValidationResult>;
estimateCycleTime(input: TrajectoryResult | PathPlanResult): Promise<CycleTimeResult>;
resampleTrajectory(trajectory: TrajectoryResult, sampleTime: number): Promise<TrajectoryResult>;
}
export type KdlApiMethod = keyof KdlWasmApi;

View File

@@ -0,0 +1,49 @@
import {
createRpcError,
normalizeThrownError,
type KdlRpcRequest,
type KdlRpcResponse,
type KdlRuntimeHandlers
} from "./rpc.js";
export async function dispatchKdlRpcRequest(
runtime: KdlRuntimeHandlers,
request: KdlRpcRequest<unknown[]>
): Promise<KdlRpcResponse> {
if (!Number.isInteger(request.id)) {
return {
id: Number.isFinite(request.id) ? request.id : -1,
ok: false,
error: createRpcError("KDL_RPC_INVALID_ID", "RPC request id must be an integer")
};
}
const handler = runtime[request.method];
if (typeof handler !== "function") {
return {
id: request.id,
ok: false,
error: createRpcError(
"KDL_METHOD_NOT_IMPLEMENTED",
`${String(request.method)} is not implemented by the KDL worker runtime`
)
};
}
const args = Array.isArray(request.payload) ? request.payload : [request.payload];
try {
const result = await handler(...args);
return {
id: request.id,
ok: true,
result
};
} catch (error) {
return {
id: request.id,
ok: false,
error: normalizeThrownError(error)
};
}
}

View File

@@ -0,0 +1,238 @@
import type { Pose } from "../kdl/types.js";
export type Mat4 = [
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number
];
export function identityMat4(): Mat4 {
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
export function multiplyMat4(a: Mat4, b: Mat4): Mat4 {
const out = new Array<number>(16).fill(0) as Mat4;
for (let row = 0; row < 4; row += 1) {
for (let col = 0; col < 4; col += 1) {
out[row * 4 + col] =
a[row * 4 + 0]! * b[col + 0]! +
a[row * 4 + 1]! * b[col + 4]! +
a[row * 4 + 2]! * b[col + 8]! +
a[row * 4 + 3]! * b[col + 12]!;
}
}
return out;
}
export function translationMat4(xyz: [number, number, number]): Mat4 {
const [x, y, z] = xyz;
return [1, 0, 0, x, 0, 1, 0, y, 0, 0, 1, z, 0, 0, 0, 1];
}
export function rotationFromRpyMat4(rpy: [number, number, number]): Mat4 {
const [roll, pitch, yaw] = rpy;
const cr = Math.cos(roll);
const sr = Math.sin(roll);
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
return [
cy * cp,
cy * sp * sr - sy * cr,
cy * sp * cr + sy * sr,
0,
sy * cp,
sy * sp * sr + cy * cr,
sy * sp * cr - cy * sr,
0,
-sp,
cp * sr,
cp * cr,
0,
0,
0,
0,
1
];
}
export function axisAngleMat4(axis: [number, number, number], angle: number): Mat4 {
const [nx, ny, nz] = normalizeVector(axis);
const c = Math.cos(angle);
const s = Math.sin(angle);
const t = 1 - c;
return [
t * nx * nx + c,
t * nx * ny - s * nz,
t * nx * nz + s * ny,
0,
t * nx * ny + s * nz,
t * ny * ny + c,
t * ny * nz - s * nx,
0,
t * nx * nz - s * ny,
t * ny * nz + s * nx,
t * nz * nz + c,
0,
0,
0,
0,
1
];
}
export function mat4ToPose(matrix: Mat4): Pose {
return {
position: [matrix[3], matrix[7], matrix[11]],
quaternion: normalizeQuaternion(rotationMat4ToQuaternion(matrix))
};
}
export function poseToMat4(pose: Pose): Mat4 {
const [x, y, z, w] = normalizeQuaternion(pose.quaternion);
const xx = x * x;
const yy = y * y;
const zz = z * z;
const xy = x * y;
const xz = x * z;
const yz = y * z;
const wx = w * x;
const wy = w * y;
const wz = w * z;
const [px, py, pz] = pose.position;
return [
1 - 2 * (yy + zz),
2 * (xy - wz),
2 * (xz + wy),
px,
2 * (xy + wz),
1 - 2 * (xx + zz),
2 * (yz - wx),
py,
2 * (xz - wy),
2 * (yz + wx),
1 - 2 * (xx + yy),
pz,
0,
0,
0,
1
];
}
export function composePose(a: Pose, b: Pose): Pose {
return mat4ToPose(multiplyMat4(poseToMat4(a), poseToMat4(b)));
}
export function inversePose(pose: Pose): Pose {
const matrix = poseToMat4(pose);
const r00 = matrix[0];
const r01 = matrix[1];
const r02 = matrix[2];
const tx = matrix[3];
const r10 = matrix[4];
const r11 = matrix[5];
const r12 = matrix[6];
const ty = matrix[7];
const r20 = matrix[8];
const r21 = matrix[9];
const r22 = matrix[10];
const tz = matrix[11];
return mat4ToPose([
r00,
r10,
r20,
-(r00 * tx + r10 * ty + r20 * tz),
r01,
r11,
r21,
-(r01 * tx + r11 * ty + r21 * tz),
r02,
r12,
r22,
-(r02 * tx + r12 * ty + r22 * tz),
0,
0,
0,
1
]);
}
export function rpyToQuaternion(rpy: [number, number, number]): [number, number, number, number] {
return normalizeQuaternion(rotationMat4ToQuaternion(rotationFromRpyMat4(rpy)));
}
export function normalizeQuaternion(input: [number, number, number, number]): [number, number, number, number] {
const [x, y, z, w] = input;
const length = Math.hypot(x, y, z, w);
if (length === 0) {
return [0, 0, 0, 1];
}
return [x / length, y / length, z / length, w / length];
}
export function jointMotionMat4(type: string, axis: [number, number, number], value: number): Mat4 {
if (type === "revolute" || type === "continuous") {
return axisAngleMat4(axis, value);
}
if (type === "prismatic") {
const [x, y, z] = normalizeVector(axis);
return translationMat4([x * value, y * value, z * value]);
}
return identityMat4();
}
function rotationMat4ToQuaternion(matrix: Mat4): [number, number, number, number] {
const m00 = matrix[0];
const m01 = matrix[1];
const m02 = matrix[2];
const m10 = matrix[4];
const m11 = matrix[5];
const m12 = matrix[6];
const m20 = matrix[8];
const m21 = matrix[9];
const m22 = matrix[10];
const trace = m00 + m11 + m22;
if (trace > 0) {
const s = Math.sqrt(trace + 1) * 2;
return [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, 0.25 * s];
}
if (m00 > m11 && m00 > m22) {
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
return [0.25 * s, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
}
if (m11 > m22) {
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
return [(m01 + m10) / s, 0.25 * s, (m12 + m21) / s, (m02 - m20) / s];
}
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
return [(m02 + m20) / s, (m12 + m21) / s, 0.25 * s, (m10 - m01) / s];
}
function normalizeVector(axis: [number, number, number]): [number, number, number] {
const [x, y, z] = axis;
const length = Math.hypot(x, y, z);
if (length === 0) {
return [1, 0, 0];
}
return [x / length, y / length, z / length];
}

View File

@@ -0,0 +1,2 @@
export { RobotModelRegistry, validateNormalizedRobotModel, type RobotRegistryRecord } from "./normalizedRobotModel.js";
export { loadRobotFromUrdfModel } from "./urdfParser.js";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
import { createHash } from "node:crypto";
import { XMLParser } from "fast-xml-parser";
import { KdlStructuredError } from "../kdl/rpc.js";
import type {
JointLimitOverride,
JointLimits,
JointModel,
JointType,
LinkModel,
NormalizedRobotModel,
UrdfLoadOptions
} from "../kdl/types.js";
type XmlNode = Record<string, unknown>;
const SUPPORTED_JOINT_TYPES = new Set<JointType>(["revolute", "continuous", "prismatic", "fixed"]);
export function loadRobotFromUrdfModel(urdfXml: string, options: UrdfLoadOptions): NormalizedRobotModel {
const robot = parseUrdfRoot(urdfXml);
const robotName = stringAttr(robot["@_name"]) ?? options.robotId;
const links = asArray<XmlNode>(robot.link).map(parseLink);
const joints = asArray<XmlNode>(robot.joint).map(parseJoint);
const linkNames = new Set(links.map((link) => link.name));
if (!linkNames.has(options.baseLink)) {
throw invalidModel(`URDF baseLink does not exist: ${options.baseLink}`);
}
if (!linkNames.has(options.tipLink)) {
throw invalidModel(`URDF tipLink does not exist: ${options.tipLink}`);
}
const chain = buildChain(joints, options.baseLink, options.tipLink);
const activeJointNames = resolveActiveJointOrder(chain, options.jointOrder);
const limits = activeJointNames.map((name) => {
const joint = joints.find((candidate) => candidate.name === name);
if (!joint) {
throw invalidModel(`Joint order references an unknown joint: ${name}`);
}
return limitForJoint(joint, options.overrideLimits ?? []);
});
return {
robotId: options.robotId,
name: robotName,
baseLink: options.baseLink,
tipLink: options.tipLink,
links,
joints,
activeJointNames,
limits,
source: {
type: "urdf",
urdfHash: createHash("sha256").update(urdfXml).digest("hex")
}
};
}
function parseUrdfRoot(urdfXml: string): XmlNode {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
trimValues: true,
parseAttributeValue: false,
parseTagValue: false,
allowBooleanAttributes: true
});
const parsed = parser.parse(urdfXml) as XmlNode;
const robot = parsed.robot;
if (!isObject(robot)) {
throw invalidModel("URDF document must contain a robot root element");
}
return robot;
}
function parseLink(node: XmlNode): LinkModel {
const name = stringAttr(node["@_name"]);
if (!name) {
throw invalidModel("URDF link is missing name");
}
return { name };
}
function parseJoint(node: XmlNode): JointModel {
const name = stringAttr(node["@_name"]);
const type = stringAttr(node["@_type"]);
if (!name || !type) {
throw invalidModel("URDF joint is missing name or type");
}
if (!SUPPORTED_JOINT_TYPES.has(type as JointType)) {
throw invalidModel(`Unsupported joint type for ${name}: ${type}`);
}
const parent = parseLinkRef(node.parent, "parent", name);
const child = parseLinkRef(node.child, "child", name);
const originNode = isObject(node.origin) ? node.origin : {};
const axisNode = isObject(node.axis) ? node.axis : {};
const jointType = type as JointType;
return {
name,
type: jointType,
parent,
child,
origin: {
xyz: parseTriple(stringAttr(originNode["@_xyz"]), [0, 0, 0], `joint ${name} origin xyz`),
rpy: parseTriple(stringAttr(originNode["@_rpy"]), [0, 0, 0], `joint ${name} origin rpy`)
},
axis: parseTriple(stringAttr(axisNode["@_xyz"]), [1, 0, 0], `joint ${name} axis xyz`),
...(jointType === "fixed" ? {} : { limit: parseJointLimit(node.limit, name, jointType) })
};
}
function parseJointLimit(node: unknown, jointName: string, jointType: JointType): JointLimits {
const limitNode = isObject(node) ? node : {};
const continuous = jointType === "continuous";
const lower = continuous ? -Infinity : numberAttr(limitNode["@_lower"], `joint ${jointName} lower limit`);
const upper = continuous ? Infinity : numberAttr(limitNode["@_upper"], `joint ${jointName} upper limit`);
return {
name: jointName,
lower,
upper,
velocity: optionalNumberAttr(limitNode["@_velocity"], `joint ${jointName} velocity limit`) ?? Infinity,
acceleration: optionalNumberAttr(limitNode["@_acceleration"], `joint ${jointName} acceleration limit`) ?? Infinity,
...optionalJerk(limitNode, jointName)
};
}
function parseLinkRef(node: unknown, field: "parent" | "child", jointName: string): string {
if (!isObject(node)) {
throw invalidModel(`URDF joint ${jointName} is missing ${field}`);
}
const link = stringAttr(node["@_link"]);
if (!link) {
throw invalidModel(`URDF joint ${jointName} ${field} is missing link`);
}
return link;
}
function buildChain(joints: JointModel[], baseLink: string, tipLink: string): JointModel[] {
const byParent = new Map<string, JointModel[]>();
for (const joint of joints) {
const children = byParent.get(joint.parent) ?? [];
children.push(joint);
byParent.set(joint.parent, children);
}
const queue: Array<{ link: string; chain: JointModel[] }> = [{ link: baseLink, chain: [] }];
const visited = new Set<string>([baseLink]);
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
break;
}
if (current.link === tipLink) {
return current.chain;
}
for (const joint of byParent.get(current.link) ?? []) {
if (visited.has(joint.child)) {
continue;
}
visited.add(joint.child);
queue.push({ link: joint.child, chain: [...current.chain, joint] });
}
}
throw invalidModel(`URDF baseLink ${baseLink} is not connected to tipLink ${tipLink}`);
}
function resolveActiveJointOrder(chain: JointModel[], jointOrder?: string[]): string[] {
const defaultOrder = chain
.filter((joint) => joint.type !== "fixed")
.map((joint) => joint.name);
if (!jointOrder || jointOrder.length === 0) {
return defaultOrder;
}
const chainActive = new Set(defaultOrder);
for (const jointName of jointOrder) {
if (!chainActive.has(jointName)) {
throw invalidModel(`jointOrder contains a joint outside the base-tip chain: ${jointName}`);
}
}
if (jointOrder.length !== defaultOrder.length) {
throw invalidModel("jointOrder must contain every active joint in the base-tip chain exactly once");
}
return [...jointOrder];
}
function limitForJoint(joint: JointModel, overrides: JointLimitOverride[]): JointLimits {
const base = joint.limit;
if (!base) {
throw invalidModel(`Active joint ${joint.name} is missing limits`);
}
const override = overrides.find((candidate) => candidate.name === joint.name);
if (!override) {
return base;
}
return {
name: joint.name,
lower: override.lower ?? base.lower,
upper: override.upper ?? base.upper,
velocity: override.velocity ?? base.velocity,
acceleration: override.acceleration ?? base.acceleration,
...mergedOptionalJerk(override, base)
};
}
function optionalJerk(limitNode: XmlNode, jointName: string): Pick<JointLimits, "jerk"> | Record<string, never> {
const jerk = optionalNumberAttr(limitNode["@_jerk"], `joint ${jointName} jerk limit`);
return jerk === undefined ? {} : { jerk };
}
function mergedOptionalJerk(
override: JointLimitOverride,
base: JointLimits
): Pick<JointLimits, "jerk"> | Record<string, never> {
const jerk = override.jerk ?? base.jerk;
return jerk === undefined ? {} : { jerk };
}
function parseTriple(value: string | undefined, fallback: [number, number, number], context: string): [number, number, number] {
if (!value) {
return fallback;
}
const parts = value.trim().split(/\s+/).map((part) => Number(part));
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
throw invalidModel(`Invalid ${context}: ${value}`);
}
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
function numberAttr(value: unknown, context: string): number {
const numberValue = optionalNumberAttr(value, context);
if (numberValue === undefined) {
throw invalidModel(`Missing ${context}`);
}
return numberValue;
}
function optionalNumberAttr(value: unknown, context: string): number | undefined {
if (value === undefined || value === null || value === "") {
return undefined;
}
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) {
throw invalidModel(`Invalid ${context}: ${String(value)}`);
}
return numberValue;
}
function stringAttr(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function asArray<T>(value: unknown): T[] {
if (value === undefined || value === null) {
return [];
}
return Array.isArray(value) ? (value as T[]) : [value as T];
}
function isObject(value: unknown): value is XmlNode {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function invalidModel(message: string): KdlStructuredError {
return new KdlStructuredError("KDL_INVALID_MODEL", message);
}

View File

@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import type { GrlProcedureDeclaration } from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import { parseProcedureControlFlow } from "../../src/grl/semantic/index.js";
function procedure(source: string): GrlProcedureDeclaration {
return parseGrl(source).module.declarations.find(
(decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration"
)!;
}
describe("GRL control-flow compilation", () => {
it("compiles if, elseif, else, while, for, switch, labels, and jumps", () => {
const proc = procedure(`language grl 0.1
module Main
proc main()
label retry
if ready == true
wait io.di[1] == true
elseif fault == true
jump recovery
else
jump retry
end
while all(io.di[1] == true, io.di[2] == false)
continue
end
for i = 1 to 3 step 1
movej home
end
switch mode
case 1
break
case 2
jump done
default
jump recovery
end
label recovery
label done
end
end
`);
const flow = parseProcedureControlFlow(proc);
expect(flow).toEqual([
expect.objectContaining({ kind: "LABEL", name: "retry", scopePath: [] }),
expect.objectContaining({
kind: "IF",
branches: [
expect.objectContaining({
branchKind: "if",
condition: expect.objectContaining({ text: "ready == true" }),
body: [expect.objectContaining({ kind: "RAW_STATEMENT", text: "wait io . di [ 1 ] == true" })]
}),
expect.objectContaining({
branchKind: "elseif",
condition: expect.objectContaining({ text: "fault == true" }),
body: [expect.objectContaining({ kind: "JUMP", label: "recovery" })]
}),
expect.objectContaining({
branchKind: "else",
body: [expect.objectContaining({ kind: "JUMP", label: "retry" })]
})
]
}),
expect.objectContaining({
kind: "WHILE",
condition: expect.objectContaining({
text: "all ( io . di [ 1 ] == true , io . di [ 2 ] == false )"
}),
body: [expect.objectContaining({ kind: "CONTINUE" })]
}),
expect.objectContaining({
kind: "FOR",
iterator: "i",
from: expect.objectContaining({ text: "1" }),
to: expect.objectContaining({ text: "3" }),
step: expect.objectContaining({ text: "1" }),
body: [expect.objectContaining({ kind: "RAW_STATEMENT", text: "movej home" })]
}),
expect.objectContaining({
kind: "SWITCH",
expression: expect.objectContaining({ text: "mode" }),
cases: [
expect.objectContaining({ caseKind: "case", value: 1, body: [expect.objectContaining({ kind: "BREAK" })] }),
expect.objectContaining({ caseKind: "case", value: 2, body: [expect.objectContaining({ kind: "JUMP", label: "done" })] }),
expect.objectContaining({ caseKind: "default", body: [expect.objectContaining({ kind: "JUMP", label: "recovery" })] })
]
}),
expect.objectContaining({ kind: "LABEL", name: "recovery", scopePath: [] }),
expect.objectContaining({ kind: "LABEL", name: "done", scopePath: [] })
]);
});
it("reports non-boolean control conditions", () => {
const proc = procedure(`language grl 0.1
module Main
proc main()
if 1
end
end
end
`);
expect(() => parseProcedureControlFlow(proc)).toThrowError(
expect.objectContaining({ code: "GRL_CONTROL_CONDITION_NOT_BOOL" })
);
});
it("reports break and continue outside valid blocks", () => {
const breakProc = procedure(`language grl 0.1
module Main
proc main()
break
end
end
`);
const continueProc = procedure(`language grl 0.1
module Main
proc main()
switch mode
case 1
continue
end
end
end
`);
expect(() => parseProcedureControlFlow(breakProc)).toThrowError(
expect.objectContaining({ code: "GRL_BREAK_OUTSIDE_FLOW" })
);
expect(() => parseProcedureControlFlow(continueProc)).toThrowError(
expect.objectContaining({ code: "GRL_CONTINUE_OUTSIDE_LOOP" })
);
});
it("reports duplicate or non-constant switch cases", () => {
const duplicateProc = procedure(`language grl 0.1
module Main
proc main()
switch mode
case 1
break
case 1
break
end
end
end
`);
const nonConstantProc = procedure(`language grl 0.1
module Main
proc main()
switch mode
case mode + 1
break
end
end
end
`);
expect(() => parseProcedureControlFlow(duplicateProc)).toThrowError(
expect.objectContaining({ code: "GRL_SWITCH_CASE_DUPLICATE" })
);
expect(() => parseProcedureControlFlow(nonConstantProc)).toThrowError(
expect.objectContaining({ code: "GRL_SWITCH_CASE_NOT_CONSTANT" })
);
});
it("reports labels that cannot be reached by jump", () => {
const intoBlockProc = procedure(`language grl 0.1
module Main
proc main()
jump inner
if ready == true
label inner
end
end
end
`);
const missingLabelProc = procedure(`language grl 0.1
module Main
proc main()
jump missing
end
end
`);
expect(() => parseProcedureControlFlow(intoBlockProc)).toThrowError(
expect.objectContaining({ code: "GRL_JUMP_INTO_BLOCK" })
);
expect(() => parseProcedureControlFlow(missingLabelProc)).toThrowError(
expect.objectContaining({ code: "GRL_LABEL_NOT_FOUND" })
);
});
});

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import type { GrlDataDeclaration, GrlTargetDeclaration } from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import {
compileGrlDataDeclaration,
compileGrlTargetDeclaration,
compileOffsetExpression
} from "../../src/grl/semantic/index.js";
function dataDeclarations(source: string): GrlDataDeclaration[] {
return parseGrl(source).module.declarations.filter(
(declaration): declaration is GrlDataDeclaration => declaration.kind === "DataDeclaration"
);
}
function targetDeclarations(source: string): GrlTargetDeclaration[] {
return parseGrl(source).module.declarations.filter(
(declaration): declaration is GrlTargetDeclaration => declaration.kind === "TargetDeclaration"
);
}
describe("GRL data declarations and target compilation", () => {
it("compiles tool and frame declarations into shared structures", () => {
const [toolDecl, frameDecl] = dataDeclarations(`language grl 0.1
module Main
persistent tool gripper = tool {
tcp: pose(0 mm, 0 mm, 180 mm, 0 deg, 0 deg, 0 deg),
mass: 2.5 kg,
cog: [0 mm, 0 mm, 80 mm]
}
persistent frame fixture = frame {
origin: pose(800 mm, 0 mm, 200 mm, 0 deg, 0 deg, 0 deg)
}
end
`);
expect(toolDecl).toMatchObject({
storage: "persistent",
typeName: "tool",
name: "gripper",
initializer: { kind: "ObjectExpression", typeName: "tool" }
});
expect(compileGrlDataDeclaration(toolDecl!)).toMatchObject({
name: "gripper",
value: {
tcp: {
position: [0, 0, 0.18],
quaternion: [0, 0, 0, 1]
},
mass: 2.5,
cog: [0, 0, 0.08]
}
});
expect(compileGrlDataDeclaration(frameDecl!)).toMatchObject({
name: "fixture",
value: {
origin: {
position: [0.8, 0, 0.2],
quaternion: [0, 0, 0, 1]
}
}
});
});
it("compiles speed and zone declarations", () => {
const declarations = dataDeclarations(`language grl 0.1
module Main
const speed v_joint = joint(80 %)
const speed v_pick = linear(300 mm/s)
const speed v_slow = linear(100 mm/s, acc 500 mm/s2)
const zone z_fine = fine
const zone z10 = z(10 mm)
const zone z_cnt = cnt(30)
const zone z_cont = continuous
end
`);
const compiled = declarations.map(compileGrlDataDeclaration);
expect(compiled).toMatchObject([
{ name: "v_joint", value: { kind: "joint_percent", value: 0.8 } },
{ name: "v_pick", value: { kind: "linear", velocity: 0.3 } },
{ name: "v_slow", value: { kind: "linear", velocity: 0.1, acceleration: 0.5 } },
{ name: "z_fine", value: { kind: "fine" } },
{ name: "z10", value: { kind: "distance", value: 0.01 } },
{ name: "z_cnt", value: { kind: "cnt", value: 30 } },
{ name: "z_cont", value: { kind: "continuous" } }
]);
});
it("compiles joint_target and pose_target declarations", () => {
const [home, pick] = targetDeclarations(`language grl 0.1
module Main
target home = joint_target {
joints: [0 deg, -30 deg, 60 deg, 0 deg, 60 deg, 0 deg]
}
target pick = pose_target {
pose: pose(500 mm, 120 mm, 300 mm, 180 deg, 0 deg, 90 deg),
config: robot_config(0, 0, 1),
tool: gripper,
frame: fixture
}
end
`);
expect(compileGrlTargetDeclaration(home!)).toMatchObject({
name: "home",
target: {
joints: [0, -Math.PI / 6, Math.PI / 3, 0, Math.PI / 3, 0]
}
});
const compiledPick = compileGrlTargetDeclaration(pick!);
expect(compiledPick.name).toBe("pick");
expect("pose" in compiledPick.target).toBe(true);
if ("pose" in compiledPick.target) {
expect(compiledPick.target.pose.position).toEqual([0.5, 0.12, 0.3]);
expect(compiledPick.target.config).toEqual({ shoulder: 0, elbow: 0, wrist: 1 });
}
});
it("parses and compiles offset expressions", () => {
const [declFrame, declTool] = dataDeclarations(`language grl 0.1
module Main
var pose_target p2 = pick offset x 20 mm y -10 mm z 50 mm
var pose_target p3 = pick offset_in tool z -50 mm
end
`);
expect(declFrame?.initializer).toMatchObject({
kind: "OffsetExpression",
mode: "frame",
axes: [
{ axis: "x" },
{ axis: "y" },
{ axis: "z" }
]
});
if (declFrame?.initializer.kind === "OffsetExpression") {
expect(compileOffsetExpression(declFrame.initializer)).toEqual({
mode: "frame",
xyz: [0.02, -0.01, 0.05]
});
}
if (declTool?.initializer.kind === "OffsetExpression") {
expect(compileOffsetExpression(declTool.initializer)).toEqual({
mode: "tool",
xyz: [0, 0, -0.05]
});
}
});
});

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import type { GrlProcedureDeclaration, GrlRawTopLevelDeclaration } from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import {
analyzeExceptionSemantics,
parseProcedureExceptionFlow
} from "../../src/grl/semantic/index.js";
const PROGRAM = `language grl 0.1
module Main
trap recover_trap()
raise E_STOP
end
task background cycle 10 ms
call monitor()
end
proc main()
alarm E_STOP "Emergency stop" severity fatal
try
raise E_STOP
catch E_STOP
alarm RECOVER "Recovering" severity warning
finally
alarm CLEANUP "Cleanup"
end
enable interrupt guard
disable interrupt guard
end
end
`;
function declarations() {
return parseGrl(PROGRAM).module.declarations;
}
describe("GRL alarm, raise, try/catch, interrupt, and task semantics", () => {
it("keeps trap and task as parsed raw declarations for P1 diagnostics", () => {
const raw = declarations().filter(
(decl): decl is GrlRawTopLevelDeclaration => decl.kind === "RawTopLevelDeclaration"
);
expect(raw[0]?.declarationType).toBe("trap");
expect(raw[0]?.tokens[0]).toMatchObject({ raw: "trap" });
expect(raw[0]?.tokens[1]).toMatchObject({ raw: "recover_trap" });
expect(raw[1]?.declarationType).toBe("task");
expect(raw[1]?.tokens[0]).toMatchObject({ raw: "task" });
expect(raw[1]?.tokens[1]).toMatchObject({ raw: "background" });
});
it("compiles alarm, raise, try/catch/finally, and interrupt diagnostics from procedure body", () => {
const procedure = declarations().find(
(decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration"
)!;
expect(parseProcedureExceptionFlow(procedure)).toEqual([
expect.objectContaining({
kind: "ALARM",
alarmId: "E_STOP",
message: "Emergency stop",
severity: "fatal"
}),
expect.objectContaining({
kind: "TRY",
body: [expect.objectContaining({ kind: "RAISE", alarmId: "E_STOP" })],
catches: [
expect.objectContaining({
alarmId: "E_STOP",
body: [
expect.objectContaining({
kind: "ALARM",
alarmId: "RECOVER",
message: "Recovering",
severity: "warning"
})
]
})
],
finally: expect.objectContaining({
body: [expect.objectContaining({ kind: "ALARM", alarmId: "CLEANUP", message: "Cleanup" })]
})
}),
expect.objectContaining({ kind: "UNSUPPORTED_RUNTIME", feature: "interrupt" }),
expect.objectContaining({ kind: "UNSUPPORTED_RUNTIME", feature: "interrupt" })
]);
});
it("reports P1 trap/task semantics as explicit unsupported diagnostics", () => {
const analysis = analyzeExceptionSemantics(declarations());
expect(analysis.unsupported).toEqual([
expect.objectContaining({ kind: "UNSUPPORTED_RUNTIME", feature: "trap" }),
expect.objectContaining({ kind: "UNSUPPORTED_RUNTIME", feature: "task" })
]);
expect(analysis.diagnostics).toEqual([
expect.objectContaining({ severity: "warning", code: "GRL_P1_UNIMPLEMENTED" }),
expect.objectContaining({ severity: "warning", code: "GRL_P1_UNIMPLEMENTED" })
]);
});
it("reports missing alarm ids and try blocks without handlers", () => {
const missingAlarmId = parseGrl(`language grl 0.1
module Main
proc main()
alarm
end
end
`).module.declarations.find((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")!;
const tryWithoutHandler = parseGrl(`language grl 0.1
module Main
proc main()
try
raise E_STOP
end
end
end
`).module.declarations.find((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")!;
expect(() => parseProcedureExceptionFlow(missingAlarmId)).toThrowError(
expect.objectContaining({ code: "GRL_ALARM_ID_MISSING" })
);
expect(() => parseProcedureExceptionFlow(tryWithoutHandler)).toThrowError(
expect.objectContaining({ code: "GRL_TRY_HANDLER_MISSING" })
);
});
});

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { generateGrlProgram, type GrlProgramGenerationSpec } from "../../../src/grl/generator/index.js";
import { postProcessAllBrands } from "../../../src/grl/post/index.js";
import type { GrlOperationDeclaration, GrlPathDeclaration, GrlTargetDeclaration } from "../../../src/grl/ast/index.js";
import { parseGrl } from "../../../src/grl/parser/index.js";
import { compileSemanticProgram } from "../../../src/grl/semantic/index.js";
const SPEC: GrlProgramGenerationSpec = {
moduleName: "GeneratedCell",
speeds: {
v_linear: "linear(200 mm/s)",
v_joint: "joint(50 %)"
},
zones: {
z10: "z(10 mm)",
zf: "fine"
},
targets: [
{ name: "pick", kind: "pose", values: [500, 0, 0, 0, 0, 0] },
{ name: "home", kind: "joint", values: [0] },
{ name: "place", kind: "pose", values: [600, 0, 0, 0, 0, 0] }
],
path: {
name: "generated_path",
source: {
type: "cad_curve",
id: "edge_001",
sample_distance: 5
},
defaults: {
speed: "v_linear",
zone: "z10"
},
points: [
{ motion: "movej", target: "home", speed: "v_joint", zone: "zf" },
{ motion: "movel", target: "pick" },
{ id: "place_point", motion: "movel", target: "place", zone: "zf" }
]
},
operation: {
name: "generated_op",
kind: "handling",
path: "generated_path",
startAction: "io.do[1] = true",
endAction: "io.do[1] = false"
}
};
describe("GRL generator and roundtrip", () => {
it("generates stable expanded GRL with target/path/operation first", () => {
const first = generateGrlProgram(SPEC, "expanded");
const second = generateGrlProgram(SPEC, "expanded");
expect(first).toEqual(second);
expect(first.stableIds).toEqual({
targets: ["home", "pick", "place"],
points: ["p00", "p01", "place_point"],
path: "generated_path",
operation: "generated_op"
});
expect(first.text).toBe(`language grl 0.1
module GeneratedCell
const speed v_joint = joint(50 %)
const speed v_linear = linear(200 mm/s)
const zone z10 = z(10 mm)
const zone zf = fine
target home = joint_target {
joints: [0 deg]
}
target pick = pose_target {
pose: pose(500 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
target place = pose_target {
pose: pose(600 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
path generated_path {
source {
id: "edge_001"
sample_distance: 5
type: cad_curve
}
defaults {
speed: v_linear
zone: z10
}
point p00 movej home speed v_joint zone zf
point p01 movel pick
point place_point movel place zone zf
}
operation generated_op {
kind: handling
path: generated_path
start_action:
io.do[1] = true
end_action:
io.do[1] = false
}
proc main()
run_operation generated_op
end
end`);
});
it("supports compact output that remains parseable", () => {
const compact = generateGrlProgram(SPEC, "compact");
expect(compact.text).toContain("path generated_path { source { id: \"edge_001\"");
expect(parseGrl(compact.text).module.name).toBe("GeneratedCell");
});
it("roundtrips through parser, semantic IR, and postprocessors", () => {
const generated = generateGrlProgram(SPEC, "expanded");
const ast = parseGrl(generated.text);
const declarations = ast.module.declarations;
const targets = declarations.filter((decl): decl is GrlTargetDeclaration => decl.kind === "TargetDeclaration");
const path = declarations.find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
const operation = declarations.find((decl): decl is GrlOperationDeclaration => decl.kind === "OperationDeclaration")!;
const ir = compileSemanticProgram(ast, {
startJoints: [0],
sampleTime: 0.004
});
const post = postProcessAllBrands(ir);
expect(targets.map((target) => target.name)).toEqual(["home", "pick", "place"]);
expect(path.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "PathSourceBlock" }),
expect.objectContaining({ kind: "PathDefaultsBlock" }),
expect.objectContaining({ kind: "PathPoint", id: "p00" }),
expect.objectContaining({ kind: "PathPoint", id: "p01" }),
expect.objectContaining({ kind: "PathPoint", id: "place_point" })
])
);
expect(operation).toMatchObject({
name: "generated_op",
operationKind: "handling",
pathName: "generated_path"
});
expect(ir.symbols).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "path", name: "generated_path" }),
expect.objectContaining({ kind: "operation", name: "generated_op" })
])
);
expect(post.outputs.abb.text).toContain("MODULE GeneratedCell");
expect(post.outputs.fanuc.text).toContain("/PROG MAIN");
expect(post.outputs.kuka.text).toContain("DEF Main()");
});
});

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import type {
GrlDataDeclaration,
GrlOperationDeclaration,
GrlPathDeclaration,
GrlProcedureDeclaration,
GrlTargetDeclaration
} from "../../src/grl/ast/index.js";
import type { PathEventInstruction } from "../../src/grl/ir/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import {
buildMotionContext,
compileOperation,
compileOperationActionIo,
compilePathEventIo,
compilePathToPlanRequest,
parseIoFlowStatements,
type IoMap
} from "../../src/grl/semantic/index.js";
const PROGRAM = `language grl 0.1
module Main
const speed v = joint(50 %)
const zone zf = fine
target home = joint_target { joints: [0 deg] }
path io_path {
defaults { speed: v, zone: zf }
point p0 movej home
event at p0 distance 0 mm pulse io.do[20] duration 100 ms
}
operation io_op {
kind: handling
path: io_path
start_action:
wait io.di[4] == true timeout 500 ms on_timeout alarm "part missing"
end_action:
pulse io.do[5] duration 250 ms
}
proc main()
io.do[1] = true
io.go[2] = 16
io.alias.grip_close = false
wait all(io.di[1] == true, io.di[2] == false) timeout 2 s on_timeout alarm "Clamp close timeout"
wait any(rising(io.di[3]), falling(io.di[4]), changed(io.ai[1]))
wait io.di[5] == true timeout 1 s on_timeout call recover
pulse io.do[3] duration 200 ms
end
end
`;
const IO_MAP: IoMap = {
aliases: {
grip_close: { domain: "do", index: 6, raw: "io.do[6]" }
},
allowedRanges: {
ai: { min: 1, max: 8 },
di: { min: 1, max: 16 },
do: { min: 1, max: 32 },
go: { min: 1, max: 4 }
}
};
function declarations() {
return parseGrl(PROGRAM).module.declarations;
}
function motionContext(decls = declarations()) {
return buildMotionContext(
decls.filter(
(decl): decl is GrlDataDeclaration | GrlTargetDeclaration =>
decl.kind === "DataDeclaration" || decl.kind === "TargetDeclaration"
)
);
}
function pathsByName(paths: GrlPathDeclaration[]) {
return new Map(paths.map((path) => [path.name, path]));
}
describe("GRL IO, wait, and pulse compilation", () => {
it("compiles procedure IO writes, wait conditions, timeout actions, and pulse traces", () => {
const procedure = declarations().find(
(decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration"
)!;
const instructions = parseIoFlowStatements(procedure.bodyTokens, IO_MAP);
expect(instructions).toHaveLength(7);
expect(instructions[0]).toMatchObject({
kind: "IO_WRITE",
target: { domain: "do", index: 1, raw: "io.do[1]" },
value: true
});
expect(instructions[1]).toMatchObject({
kind: "IO_WRITE",
target: { domain: "go", index: 2, raw: "io.go[2]" },
value: 16
});
expect(instructions[2]).toMatchObject({
kind: "IO_WRITE",
target: { domain: "do", index: 6, raw: "io.do[6]" },
value: false
});
expect(instructions[3]).toMatchObject({
kind: "WAIT",
condition: "all ( io . di [ 1 ] == true , io . di [ 2 ] == false )",
timeout: 2,
onTimeout: { kind: "alarm", value: "Clamp close timeout" }
});
expect(instructions[4]).toMatchObject({
kind: "WAIT",
condition: "any ( rising ( io . di [ 3 ] ) , falling ( io . di [ 4 ] ) , changed ( io . ai [ 1 ] ) )"
});
expect(instructions[5]).toMatchObject({
kind: "WAIT",
condition: "io . di [ 5 ] == true",
timeout: 1,
onTimeout: { kind: "call", value: "recover" }
});
expect(instructions[6]).toMatchObject({
kind: "PULSE",
target: { domain: "do", index: 3, raw: "io.do[3]" },
duration: 0.2,
trace: [
{ time: 0, action: "set", target: { domain: "do", index: 3 }, value: true },
{ time: 0.2, action: "reset", target: { domain: "do", index: 3 }, value: false }
]
});
});
it("validates IO addresses against configured ranges", () => {
const procedure = parseGrl(`language grl 0.1
module Main
proc main()
io.do[99] = true
end
end
`).module.declarations.find((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")!;
expect(() =>
parseIoFlowStatements(procedure.bodyTokens, { allowedRanges: { do: { min: 1, max: 16 } } })
).toThrowError(expect.objectContaining({ code: "GRL_IO_ADDRESS_NOT_FOUND" }));
});
it("expands path event IO metadata into pulse IR without entering KDL motion segments", () => {
const decls = declarations();
const path = decls.find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
const compiled = compilePathToPlanRequest(path, motionContext(decls), {
startJoints: [0],
sampleTime: 0.004
});
expect(compiled.request.segments).toHaveLength(1);
expect(compiled.request.events).toHaveLength(1);
expect(compilePathEventIo(compiled.events[0]!, IO_MAP)).toEqual([
expect.objectContaining({
kind: "PULSE",
target: { domain: "do", index: 20, raw: "io.do[20]" },
duration: 0.1
})
]);
});
it("expands operation action metadata into wait and pulse IR with preserved units", () => {
const decls = declarations();
const operation = decls.find(
(decl): decl is GrlOperationDeclaration => decl.kind === "OperationDeclaration"
)!;
const paths = decls.filter((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration");
const compiled = compileOperation(operation, pathsByName(paths));
expect(compileOperationActionIo(compiled.startActions[0]!, IO_MAP)).toEqual([
expect.objectContaining({
kind: "WAIT",
condition: "io . di [ 4 ] == true",
timeout: 0.5,
onTimeout: { kind: "alarm", value: "part missing" }
})
]);
expect(compileOperationActionIo(compiled.endActions[0]!, IO_MAP)).toEqual([
expect.objectContaining({
kind: "PULSE",
target: { domain: "do", index: 5, raw: "io.do[5]" },
duration: 0.25
})
]);
});
it("lexes statement fallback metadata so unit literals and booleans remain typed", () => {
const event: PathEventInstruction = {
timing: "at",
pointId: "p0",
kind: "pulse",
data: { statement: "pulse io.do[7] duration 125 ms" }
};
expect(compilePathEventIo(event, IO_MAP)).toEqual([
expect.objectContaining({
kind: "PULSE",
target: { domain: "do", index: 7, raw: "io.do[7]" },
duration: 0.125
})
]);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { GRL_KEYWORDS, lexGrl, normalizeUnitLiteral, normalizeUnitValue } from "../../src/grl/lexer/index.js";
import type { GrlNumberToken } from "../../src/grl/lexer/index.js";
function numbers(source: string): GrlNumberToken[] {
return lexGrl(source).filter((token): token is GrlNumberToken => token.kind === "number");
}
describe("GRL lexer", () => {
it("recognizes comments, keywords, identifiers, and source positions", () => {
const tokens = lexGrl(`// generated\nlanguage grl 0.1\nmodule Main\n proc main()\n end\nend\n`);
expect(tokens[0]).toMatchObject({
kind: "comment",
style: "line",
value: " generated",
range: {
start: { line: 1, column: 1 },
end: { line: 1, column: 13 }
}
});
expect(tokens.filter((token) => token.kind === "keyword").map((token) => token.raw)).toEqual([
"language",
"module",
"proc",
"end",
"end"
]);
expect(tokens.find((token) => token.raw === "Main")).toMatchObject({
kind: "identifier",
range: {
start: { line: 3, column: 8 }
}
});
expect(tokens.at(-1)).toMatchObject({ kind: "eof" });
});
it("normalizes numeric literals with GRL units into SI values", () => {
const found = numbers("100 mm 0.25 m 180 deg 3.14159 rad 300 mm/s 50 % 200 ms 2.5 kg 500 mm/s2");
expect(found.map((token) => token.unit?.raw)).toEqual([
"mm",
"m",
"deg",
"rad",
"mm/s",
"%",
"ms",
"kg",
"mm/s2"
]);
expect(found[0]?.unit?.normalizedValue).toBeCloseTo(0.1);
expect(found[1]?.unit?.normalizedValue).toBeCloseTo(0.25);
expect(found[2]?.unit?.normalizedValue).toBeCloseTo(Math.PI);
expect(found[3]?.unit?.normalizedValue).toBeCloseTo(3.14159);
expect(found[4]?.unit?.normalizedValue).toBeCloseTo(0.3);
expect(found[5]?.unit?.normalizedValue).toBeCloseTo(0.5);
expect(found[6]?.unit?.normalizedValue).toBeCloseTo(0.2);
expect(found[7]?.unit?.normalizedValue).toBeCloseTo(2.5);
expect(found[8]?.unit?.normalizedValue).toBeCloseTo(0.5);
});
it("keeps unit raw text on number tokens", () => {
const [token] = numbers("linear(300 mm/s)");
expect(token).toMatchObject({
kind: "number",
raw: "300 mm/s",
value: 300,
unit: {
raw: "mm/s",
kind: "linear_velocity",
siUnit: "m/s"
}
});
});
it("exposes the full reserved keyword set from the specification", () => {
expect(GRL_KEYWORDS).toContain("movej");
expect(GRL_KEYWORDS).toContain("run_operation");
expect(GRL_KEYWORDS).toContain("post_hint");
expect(GRL_KEYWORDS).toContain("continuous");
expect(GRL_KEYWORDS).toHaveLength(85);
});
it("provides direct unit helpers for parser and semantic layers", () => {
expect(normalizeUnitLiteral("deg/s")).toMatchObject({
kind: "angular_velocity",
siUnit: "rad/s"
});
expect(normalizeUnitValue(90, "deg/s")).toBeCloseTo(Math.PI / 2);
expect(() => normalizeUnitLiteral("inch")).toThrow("Unknown GRL unit");
});
});

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { GrlDataDeclaration, GrlProcedureDeclaration, GrlTargetDeclaration } from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import {
buildMotionContext,
compileMotionToKdlRequest,
parseProcedureMotionInstructions
} from "../../src/grl/semantic/index.js";
const PROGRAM = `language grl 0.1
module Main
persistent tool gripper = tool {
tcp: pose(0 mm, 0 mm, 100 mm, 0 deg, 0 deg, 0 deg),
mass: 1 kg
}
persistent frame fixture = frame {
origin: pose(800 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
const speed v_joint = joint(60 %)
const speed v_linear = linear(300 mm/s)
const zone z10 = z(10 mm)
target home = joint_target {
joints: [0 deg, 0 deg]
}
target pick = pose_target {
pose: pose(500 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg),
tool: gripper,
frame: fixture
}
target mid = pose_target {
pose: pose(550 mm, 50 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
target arc_end = pose_target {
pose: pose(600 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
proc main()
set_tool gripper
set_frame fixture
set_speed v_linear
set_zone z10
movej home speed v_joint zone fine
movel pick
movec via mid target arc_end speed linear(150 mm/s) zone fine
end
end
`;
function declarations() {
return parseGrl(PROGRAM).module.declarations;
}
describe("GRL motion instruction compilation", () => {
it("parses movej, movel, and movec from procedure body tokens", () => {
const decls = declarations();
const context = buildMotionContext(
decls.filter(
(decl): decl is GrlDataDeclaration | GrlTargetDeclaration =>
decl.kind === "DataDeclaration" || decl.kind === "TargetDeclaration"
)
);
const procedure = decls.find((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")!;
const instructions = parseProcedureMotionInstructions(procedure, context);
expect(instructions.map((instruction) => instruction.kind)).toEqual(["MOVEJ", "MOVEL", "MOVEC"]);
expect(instructions[0]).toMatchObject({
kind: "MOVEJ",
speed: { kind: "joint_percent", value: 0.6 },
zone: { kind: "fine" },
target: { joints: [0, 0] },
sourceMap: { line: 32 }
});
expect(instructions[1]).toMatchObject({
kind: "MOVEL",
speed: { kind: "linear", velocity: 0.3 },
zone: { kind: "distance", value: 0.01 }
});
expect(instructions[1]?.tool?.position).toEqual([0, 0, 0.1]);
expect(instructions[1]?.frame?.position).toEqual([0.8, 0, 0]);
expect(instructions[2]).toMatchObject({
kind: "MOVEC",
speed: { kind: "linear", velocity: 0.15 },
zone: { kind: "fine" }
});
});
it("compiles motion instructions to KDL request shapes", () => {
const decls = declarations();
const context = buildMotionContext(
decls.filter(
(decl): decl is GrlDataDeclaration | GrlTargetDeclaration =>
decl.kind === "DataDeclaration" || decl.kind === "TargetDeclaration"
)
);
const procedure = decls.find((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")!;
const [movej, movel, movec] = parseProcedureMotionInstructions(procedure, context);
expect(compileMotionToKdlRequest(movej!, { startJoints: [0, 0], sampleTime: 0.004 })).toMatchObject({
startJoints: [0, 0],
target: { joints: [0, 0] },
speed: { kind: "joint_percent", value: 0.6 },
zone: { kind: "fine" },
sampleTime: 0.004
});
expect(compileMotionToKdlRequest(movel!, { startJoints: [0, 0], sampleTime: 0.004 })).toMatchObject({
startJoints: [0, 0],
target: { pose: { position: [0.5, 0, 0] } },
speed: { kind: "linear", velocity: 0.3 },
zone: { kind: "distance", value: 0.01 },
tool: { position: [0, 0, 0.1] },
frame: { position: [0.8, 0, 0] },
sampleTime: 0.004
});
expect(compileMotionToKdlRequest(movec!, { startJoints: [0, 0], sampleTime: 0.004 })).toMatchObject({
startJoints: [0, 0],
via: { pose: { position: [0.55, 0.05, 0] } },
target: { pose: { position: [0.6, 0, 0] } },
speed: { kind: "linear", velocity: 0.15 },
zone: { kind: "fine" },
sampleTime: 0.004
});
});
});

View File

@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import type {
GrlOperationDeclaration,
GrlPathDeclaration,
GrlProcedureDeclaration
} from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import {
compileOperation,
expandRunOperation,
parseProcedureRunOperationStatements
} from "../../src/grl/semantic/index.js";
const PROGRAM = `language grl 0.1
module Main
const speed v = joint(50 %)
const zone zf = fine
target home = joint_target { joints: [0 deg] }
path weld_path {
defaults { speed: v, zone: zf }
point p0 movej home
}
operation weld_op_01 {
kind: arc_welding
path: weld_path
process {
weld_id: "WELD_1"
voltage: 24.0
current: 180.0
weave: none
}
start_action:
io.do[20] = true
end_action:
io.do[20] = false
}
proc main()
run_operation weld_op_01
end
end
`;
function declarations() {
return parseGrl(PROGRAM).module.declarations;
}
function pathsByName(paths: GrlPathDeclaration[]) {
return new Map(paths.map((path) => [path.name, path]));
}
describe("GRL operation compilation", () => {
it("parses operation kind, path, process, and action blocks", () => {
const operation = declarations().find(
(decl): decl is GrlOperationDeclaration => decl.kind === "OperationDeclaration"
)!;
expect(operation).toMatchObject({
kind: "OperationDeclaration",
name: "weld_op_01",
operationKind: "arc_welding",
pathName: "weld_path",
items: [
{ kind: "OperationProcessBlock" },
{ kind: "OperationActionBlock", actionKind: "start_action" },
{ kind: "OperationActionBlock", actionKind: "end_action" }
]
});
});
it("compiles operation process metadata and action statements", () => {
const decls = declarations();
const operation = decls.find((decl): decl is GrlOperationDeclaration => decl.kind === "OperationDeclaration")!;
const paths = decls.filter((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration");
const compiled = compileOperation(operation, pathsByName(paths));
expect(compiled).toMatchObject({
operationId: "weld_op_01",
kind: "arc_welding",
pathId: "weld_path",
process: {
weld_id: "WELD_1",
voltage: 24,
current: 180,
weave: "none"
},
startActions: [
{
kind: "ACTION",
actionKind: "start_action",
operationId: "weld_op_01",
statement: "io . do [ 20 ] = true"
}
],
endActions: [
{
kind: "ACTION",
actionKind: "end_action",
operationId: "weld_op_01",
statement: "io . do [ 20 ] = false"
}
]
});
});
it("extracts run_operation and expands to start action, path, and end action", () => {
const decls = declarations();
const procedure = decls.find((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")!;
const operation = decls.find((decl): decl is GrlOperationDeclaration => decl.kind === "OperationDeclaration")!;
const paths = decls.filter((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration");
const compiled = compileOperation(operation, pathsByName(paths));
const [run] = parseProcedureRunOperationStatements(procedure);
expect(run).toEqual({
kind: "RUN_OPERATION",
operationId: "weld_op_01",
sourceMap: {
line: 25,
column: 5
}
});
expect(expandRunOperation(run!, new Map([[compiled.operationId, compiled]]))).toEqual([
expect.objectContaining({ kind: "ACTION", actionKind: "start_action" }),
{
kind: "RUN_PATH",
pathId: "weld_path",
sourceMap: {
line: 25,
column: 5
}
},
expect.objectContaining({ kind: "ACTION", actionKind: "end_action" })
]);
});
it("reports operations that reference missing paths and missing run_operation targets", () => {
const missingPathOperation = parseGrl(`language grl 0.1
module Main
operation bad_op {
kind: handling
path: missing_path
}
end
`).module.declarations.find((decl): decl is GrlOperationDeclaration => decl.kind === "OperationDeclaration")!;
expect(() => compileOperation(missingPathOperation, new Map())).toThrowError(
expect.objectContaining({ code: "GRL_OPERATION_PATH_NOT_FOUND" })
);
expect(() =>
expandRunOperation({ kind: "RUN_OPERATION", operationId: "missing_op" }, new Map())
).toThrowError(expect.objectContaining({ code: "GRL_OPERATION_NOT_FOUND" }));
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { GrlParseError, parseGrl } from "../../src/grl/parser/index.js";
describe("GRL parser", () => {
it("parses the minimal language/module/proc skeleton with source ranges", () => {
const ast = parseGrl(`language grl 0.1
module Main
proc main()
// body comment should not affect parser
end
end
`);
expect(ast).toMatchObject({
kind: "Program",
language: {
kind: "LanguageDeclaration",
language: "grl",
version: "0.1",
range: {
start: { line: 1, column: 1 },
end: { line: 1, column: 17 }
}
},
module: {
kind: "ModuleDeclaration",
name: "Main",
declarations: [
{
kind: "ProcedureDeclaration",
name: "main",
params: []
}
]
}
});
expect(ast.module.range.start).toMatchObject({ line: 3, column: 1 });
expect(ast.module.range.end).toMatchObject({ line: 7, column: 4 });
});
it("parses imports, data declarations, targets, and procedure body tokens", () => {
const ast = parseGrl(`language grl 0.1
module Main
import CommonTools
const speed v_pick = linear(300 mm/s)
target home = joint_target {
joints: [0 deg, 0 deg]
}
proc main()
movej home
end
end
`);
expect(ast.module.declarations.map((decl) => decl.kind)).toEqual([
"ImportDeclaration",
"DataDeclaration",
"TargetDeclaration",
"ProcedureDeclaration"
]);
expect(ast.module.declarations[0]).toMatchObject({
kind: "ImportDeclaration",
moduleName: "CommonTools"
});
expect(ast.module.declarations[1]).toMatchObject({
kind: "DataDeclaration",
storage: "const",
typeName: "speed",
name: "v_pick",
initializer: {
kind: "CallExpression",
callee: "linear"
}
});
expect(ast.module.declarations[2]).toMatchObject({
kind: "TargetDeclaration",
name: "home",
target: {
kind: "ObjectExpression",
typeName: "joint_target"
}
});
expect(ast.module.declarations[3]).toMatchObject({
kind: "ProcedureDeclaration",
bodyTokens: [
{
kind: "keyword",
raw: "movej"
},
{
kind: "identifier",
raw: "home"
}
]
});
});
it("reports stable line and column on invalid syntax", () => {
expect(() => parseGrl("language grl\nmodule Main\nend\n")).toThrow(GrlParseError);
expect(() => parseGrl("language grl\nmodule Main\nend\n")).toThrow("Expected GRL language version at 2:1");
});
});

View File

@@ -0,0 +1,251 @@
import { describe, expect, it } from "vitest";
import type {
GrlDataDeclaration,
GrlPathDeclaration,
GrlProcedureDeclaration,
GrlTargetDeclaration
} from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import {
buildMotionContext,
compilePathToPlanRequest,
parseProcedureRunPathStatements
} from "../../src/grl/semantic/index.js";
const PROGRAM = `language grl 0.1
module Main
persistent tool gripper = tool {
tcp: pose(0 mm, 0 mm, 100 mm, 0 deg, 0 deg, 0 deg)
}
persistent frame fixture = frame {
origin: pose(800 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
const speed v_joint = joint(60 %)
const speed v_linear = linear(300 mm/s)
const zone z10 = z(10 mm)
target home = joint_target {
joints: [0 deg, 0 deg]
}
target pick = pose_target {
pose: pose(500 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
target mid = pose_target {
pose: pose(550 mm, 50 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
target arc_end = pose_target {
pose: pose(600 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
path pick_path {
source {
type: cad_curve
id: "edge_032"
sample_distance: 5 mm
}
defaults {
tool: gripper,
frame: fixture,
speed: v_linear,
zone: z10
}
point approach movej home speed v_joint zone fine
point p1 movel pick offset z 100 mm
point p2 movec via mid target arc_end speed linear(150 mm/s) zone fine
event before p1 io.do[10] = true
event after p2 io.do[10] = false
event at p1 distance -20 mm pulse io.do[20] duration 100 ms
}
proc main()
run_path pick_path
end
end
`;
function declarations() {
return parseGrl(PROGRAM).module.declarations;
}
function motionContext(decls = declarations()) {
return buildMotionContext(
decls.filter(
(decl): decl is GrlDataDeclaration | GrlTargetDeclaration =>
decl.kind === "DataDeclaration" || decl.kind === "TargetDeclaration"
)
);
}
describe("GRL path compilation", () => {
it("parses path defaults, source metadata, points, and events as AST nodes", () => {
const path = declarations().find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
expect(path).toMatchObject({
kind: "PathDeclaration",
name: "pick_path",
items: [
{ kind: "PathSourceBlock" },
{ kind: "PathDefaultsBlock" },
{ kind: "PathPoint", id: "approach" },
{ kind: "PathPoint", id: "p1" },
{ kind: "PathPoint", id: "p2" },
{ kind: "PathEvent", timing: "before", pointId: "p1" },
{ kind: "PathEvent", timing: "after", pointId: "p2" },
{ kind: "PathEvent", timing: "at", pointId: "p1" }
]
});
expect(path.items[0]).toMatchObject({
properties: [
{ key: "type", value: { kind: "IdentifierExpression", name: "cad_curve" } },
{ key: "id", value: { kind: "StringLiteral", value: "edge_032" } },
{ key: "sample_distance", value: { kind: "NumberLiteral" } }
]
});
});
it("compiles a path to PathPlanRequest with defaults, source map, source metadata, and events", () => {
const decls = declarations();
const path = decls.find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
const compiled = compilePathToPlanRequest(path, motionContext(decls), {
startJoints: [0, 0],
sampleTime: 0.004
});
expect(compiled.pathId).toBe("pick_path");
expect(compiled.request).toMatchObject({
pathId: "pick_path",
startJoints: [0, 0],
sampleTime: 0.004,
source: {
type: "cad_curve",
id: "edge_032",
sample_distance: 0.005
},
segments: [
{
id: "approach",
motion: "MOVEJ",
targetId: "home",
speed: { kind: "joint_percent", value: 0.6 },
zone: { kind: "fine" }
},
{
id: "p1",
motion: "MOVEL",
targetId: "pick",
speed: { kind: "linear", velocity: 0.3 },
zone: { kind: "distance", value: 0.01 },
tool: { position: [0, 0, 0.1] },
frame: { position: [0.8, 0, 0] },
sourceMap: { line: 37 }
},
{
id: "p2",
motion: "MOVEC",
targetId: "arc_end",
speed: { kind: "linear", velocity: 0.15 },
zone: { kind: "fine" }
}
],
events: [
{
timing: "before",
pointId: "p1",
kind: "io",
data: { statement: "io . do [ 10 ] = true" }
},
{
timing: "after",
pointId: "p2",
kind: "io"
},
{
timing: "at",
pointId: "p1",
distance: -0.02,
kind: "pulse"
}
]
});
expect(compiled.request.segments[1]?.target).toMatchObject({
pose: {
position: [0.5, 0, 0.1]
}
});
});
it("extracts run_path statements from procedure body tokens", () => {
const procedure = declarations().find((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")!;
expect(parseProcedureRunPathStatements(procedure)).toEqual([
{
kind: "RUN_PATH",
pathId: "pick_path",
sourceMap: {
line: 44,
column: 5
}
}
]);
});
it("reports empty paths and duplicate point names", () => {
const emptyPath = parseGrl(`language grl 0.1
module Main
path empty_path {
}
end
`).module.declarations.find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
expect(() =>
compilePathToPlanRequest(emptyPath, motionContext([]), { startJoints: [], sampleTime: 0.004 })
).toThrowError(expect.objectContaining({ code: "GRL_PATH_EMPTY" }));
const duplicatePath = parseGrl(`language grl 0.1
module Main
const speed v = joint(50 %)
const zone zf = fine
target home = joint_target { joints: [0 deg] }
path dup_path {
defaults { speed: v, zone: zf }
point p movej home
point p movej home
}
end
`).module.declarations.find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
const duplicateContext = motionContext(parseGrl(`language grl 0.1
module Main
const speed v = joint(50 %)
const zone zf = fine
target home = joint_target { joints: [0 deg] }
end
`).module.declarations);
expect(() =>
compilePathToPlanRequest(duplicatePath, duplicateContext, { startJoints: [0], sampleTime: 0.004 })
).toThrowError(expect.objectContaining({ code: "GRL_PATH_POINT_DUPLICATE" }));
});
it("reports events that reference missing points", () => {
const path = parseGrl(`language grl 0.1
module Main
const speed v = joint(50 %)
const zone zf = fine
target home = joint_target { joints: [0 deg] }
path bad_event {
defaults { speed: v, zone: zf }
point p movej home
event after missing io.do[1] = true
}
end
`).module.declarations.find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
const context = motionContext(parseGrl(`language grl 0.1
module Main
const speed v = joint(50 %)
const zone zf = fine
target home = joint_target { joints: [0 deg] }
end
`).module.declarations);
expect(() =>
compilePathToPlanRequest(path, context, { startJoints: [0], sampleTime: 0.004 })
).toThrowError(expect.objectContaining({ code: "GRL_PATH_EVENT_POINT_NOT_FOUND" }));
});
});

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "vitest";
import type { GrlFunctionDeclaration, GrlProcedureDeclaration } from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import { analyzeProcFunctionSemantics } from "../../src/grl/semantic/index.js";
function declarations(source: string) {
return parseGrl(source).module.declarations;
}
describe("GRL proc, func, call, return, and scope semantics", () => {
it("parses function declarations and analyzes proc/func signatures, calls, returns, and warnings", () => {
const decls = declarations(`language grl 0.1
module Main
var int global_count = 0
proc read_sensor(out bool ok)
ok = true
end
proc main(in bool start, out bool done, inout int count)
call read_sensor(done)
call helper(count)
call self_check()
return
end
proc self_check()
call self_check()
end
func int helper(inout int value)
var int global_count = 1
return value
end
end
`);
const func = decls.find((decl): decl is GrlFunctionDeclaration => decl.kind === "FunctionDeclaration")!;
expect(func).toMatchObject({
kind: "FunctionDeclaration",
returnType: "int",
name: "helper",
bodyTokens: [
{ raw: "var" },
{ raw: "int" },
{ raw: "global_count" },
{ raw: "=" },
{ raw: "1" },
{ raw: "return" },
{ raw: "value" }
]
});
const analysis = analyzeProcFunctionSemantics(decls);
expect(analysis.procedures).toEqual([
expect.objectContaining({
name: "read_sensor",
parameters: [expect.objectContaining({ name: "ok", typeName: "bool", direction: "out" })]
}),
expect.objectContaining({
name: "main",
parameters: [
expect.objectContaining({ name: "start", typeName: "bool", direction: "in" }),
expect.objectContaining({ name: "done", typeName: "bool", direction: "out" }),
expect.objectContaining({ name: "count", typeName: "int", direction: "inout" })
]
}),
expect.objectContaining({ name: "self_check", parameters: [] })
]);
expect(analysis.functions).toEqual([
expect.objectContaining({
name: "helper",
returnType: "int",
parameters: [expect.objectContaining({ name: "value", typeName: "int", direction: "inout" })]
})
]);
expect(analysis.calls).toEqual([
expect.objectContaining({ kind: "CALL", target: "read_sensor", args: [expect.objectContaining({ text: "done" })] }),
expect.objectContaining({ kind: "CALL", target: "helper", args: [expect.objectContaining({ text: "count" })] }),
expect.objectContaining({ kind: "CALL", target: "self_check", args: [] }),
expect.objectContaining({ kind: "CALL", target: "self_check", args: [] })
]);
expect(analysis.returns).toEqual([
expect.objectContaining({ kind: "RETURN" }),
expect.objectContaining({ kind: "RETURN", value: expect.objectContaining({ text: "value" }) })
]);
expect(analysis.diagnostics).toEqual([
expect.objectContaining({ severity: "warning", code: "GRL_RECURSIVE_CALL" }),
expect.objectContaining({ severity: "warning", code: "GRL_NAME_SHADOWS_OUTER_SCOPE" })
]);
});
it("reports out parameters that are not assigned on all normal return paths", () => {
const decls = declarations(`language grl 0.1
module Main
proc main(out bool done)
if ready == true
done = true
end
return
end
end
`);
expect(() => analyzeProcFunctionSemantics(decls)).toThrowError(
expect.objectContaining({ code: "GRL_OUT_PARAM_NOT_ASSIGNED" })
);
});
it("reports out and inout call arguments that are not lvalues", () => {
const decls = declarations(`language grl 0.1
module Main
proc set_done(out bool done)
done = true
end
proc main()
call set_done(true)
end
end
`);
expect(() => analyzeProcFunctionSemantics(decls)).toThrowError(
expect.objectContaining({ code: "GRL_ARGUMENT_NOT_LVALUE" })
);
});
it("reports missing or incompatible function returns", () => {
const missingReturn = declarations(`language grl 0.1
module Main
func int bad(in bool ready)
if ready == true
return 1
end
end
end
`);
const wrongReturn = declarations(`language grl 0.1
module Main
func bool bad()
return 1
end
end
`);
expect(() => analyzeProcFunctionSemantics(missingReturn)).toThrowError(
expect.objectContaining({ code: "GRL_FUNC_MISSING_RETURN" })
);
expect(() => analyzeProcFunctionSemantics(wrongReturn)).toThrowError(
expect.objectContaining({ code: "GRL_RETURN_TYPE_MISMATCH" })
);
});
it("reports illegal function side effects and procedure return values", () => {
const functionSideEffect = declarations(`language grl 0.1
module Main
func bool bad()
wait io.di[1] == true
return true
end
end
`);
const procedureReturnValue = declarations(`language grl 0.1
module Main
proc main()
return true
end
end
`);
expect(() => analyzeProcFunctionSemantics(functionSideEffect)).toThrowError(
expect.objectContaining({ code: "GRL_FUNC_SIDE_EFFECT" })
);
expect(() => analyzeProcFunctionSemantics(procedureReturnValue)).toThrowError(
expect.objectContaining({ code: "GRL_RETURN_VALUE_IN_PROC" })
);
});
it("reports call target and argument type errors", () => {
const missingCall = declarations(`language grl 0.1
module Main
proc main()
call missing()
end
end
`);
const typeMismatch = declarations(`language grl 0.1
module Main
proc expects_int(in int value)
return
end
proc main()
call expects_int("bad")
end
end
`);
expect(() => analyzeProcFunctionSemantics(missingCall)).toThrowError(
expect.objectContaining({ code: "GRL_CALL_TARGET_NOT_FOUND" })
);
expect(() => analyzeProcFunctionSemantics(typeMismatch)).toThrowError(
expect.objectContaining({ code: "GRL_CALL_ARGUMENT_TYPE" })
);
});
});

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { parseGrl } from "../../src/grl/parser/index.js";
import { compileSemanticProgram } from "../../src/grl/semantic/index.js";
const PROGRAM = `language grl 0.1
module Main
const speed vj = joint(50 %)
const speed vl = linear(200 mm/s)
const zone zf = fine
target home = joint_target { joints: [0 deg] }
target pick = pose_target { pose: pose(500 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg) }
path pick_path {
defaults { speed: vl, zone: zf }
point p0 movej home speed vj zone fine
point p1 movel pick
event before p1 io.do[1] = true
}
operation pick_op {
kind: handling
path: pick_path
start_action:
io.do[2] = true
end_action:
io.do[2] = false
}
proc set_done(out bool done)
done = true
end
proc main(out bool done)
set_speed vl
set_zone zf
movej home speed vj zone fine
io.do[3] = true
wait io.di[1] == true timeout 1 s
if done == false
pulse io.do[4] duration 100 ms
else
alarm DONE "Already done"
end
call set_done(done)
run_path pick_path
run_operation pick_op
return
end
end
`;
describe("GRL semantic analyzer, executable IR, and source map", () => {
it("compiles a complete program into unified executable IR and KDL bridge requests", () => {
const ir = compileSemanticProgram(parseGrl(PROGRAM), {
startJoints: [0],
sampleTime: 0.004
});
expect(ir.moduleName).toBe("Main");
expect(ir.semanticChecks).toHaveLength(22);
expect(ir.symbols).toEqual([
expect.objectContaining({ kind: "data", name: "vj", typeName: "speed" }),
expect.objectContaining({ kind: "data", name: "vl", typeName: "speed" }),
expect.objectContaining({ kind: "data", name: "zf", typeName: "zone" }),
expect.objectContaining({ kind: "target", name: "home" }),
expect.objectContaining({ kind: "target", name: "pick" }),
expect.objectContaining({ kind: "path", name: "pick_path" }),
expect.objectContaining({ kind: "operation", name: "pick_op" }),
expect.objectContaining({ kind: "procedure", name: "set_done" }),
expect.objectContaining({ kind: "procedure", name: "main" })
]);
expect(ir.paths).toHaveLength(1);
expect(ir.operations).toHaveLength(1);
expect(ir.kdlBridge.pathRequests).toEqual([
expect.objectContaining({
pathId: "pick_path",
segments: [
expect.objectContaining({ id: "p0", motion: "MOVEJ" }),
expect.objectContaining({ id: "p1", motion: "MOVEL" })
]
})
]);
expect(ir.kdlBridge.motionRequests).toEqual([
expect.objectContaining({
startJoints: [0],
speed: { kind: "joint_percent", value: 0.5 },
zone: { kind: "fine" },
sampleTime: 0.004
})
]);
const main = ir.procedures.find((procedure) => procedure.name === "main")!;
expect(main.instructions).toEqual([
expect.objectContaining({ kind: "MOVEJ" }),
expect.objectContaining({ kind: "IO_WRITE", target: expect.objectContaining({ domain: "do", index: 3 }) }),
expect.objectContaining({ kind: "WAIT", timeout: 1 }),
expect.objectContaining({
kind: "EXEC_IF",
branches: [
expect.objectContaining({
branchKind: "if",
body: expect.arrayContaining([expect.objectContaining({ kind: "PULSE", duration: 0.1 })])
}),
expect.objectContaining({
branchKind: "else",
body: expect.arrayContaining([expect.objectContaining({ kind: "ALARM", alarmId: "DONE" })])
})
]
}),
expect.objectContaining({ kind: "CALL", target: "set_done" }),
expect.objectContaining({ kind: "RUN_PATH", pathId: "pick_path" }),
expect.objectContaining({ kind: "RUN_OPERATION", operationId: "pick_op" }),
expect.objectContaining({ kind: "RETURN" })
]);
});
it("exposes source map entries for GRL procedure lines, path points, and operation actions", () => {
const ir = compileSemanticProgram(parseGrl(PROGRAM), {
startJoints: [0],
sampleTime: 0.004
});
expect(ir.sourceMap).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "path_point", pathId: "pick_path", pointId: "p0" }),
expect.objectContaining({ kind: "path_point", pathId: "pick_path", pointId: "p1" }),
expect.objectContaining({ kind: "operation_action", operationId: "pick_op" }),
expect.objectContaining({ kind: "MOVEJ", procedureId: "main", sourceMap: expect.objectContaining({ line: 28 }) }),
expect.objectContaining({ kind: "EXEC_IF", procedureId: "main" }),
expect.objectContaining({ kind: "RUN_OPERATION", procedureId: "main" })
])
);
});
it("reports duplicate symbols through semantic diagnostics", () => {
const ir = compileSemanticProgram(parseGrl(`language grl 0.1
module Main
const speed v = joint(10 %)
const speed v = joint(20 %)
proc main()
end
end
`), {
startJoints: [],
sampleTime: 0.004
});
expect(ir.diagnostics).toEqual([
expect.objectContaining({ severity: "error", code: "GRL_SYMBOL_DUPLICATE" })
]);
});
});

View File

@@ -0,0 +1,224 @@
import { access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { KDL_C_ABI_EXPORTS, KdlNativeAbi } from "../../src/kdl/nativeAbi.js";
import type { NativeKdlModule } from "../../src/kdl/nativeModule.js";
import { loadRobotFromUrdfModel } from "../../src/robot/urdfParser.js";
const BUILD_DIR = new URL("../../../build-wasm/", import.meta.url);
const WRAPPER_URL = new URL("kdl.js", BUILD_DIR);
type NativeFactory = (options?: {
locateFile?: (path: string, prefix: string) => string;
}) => Promise<NativeKdlModule>;
async function loadNativeModule(): Promise<NativeKdlModule> {
await access(fileURLToPath(WRAPPER_URL));
const imported = (await import(/* @vite-ignore */ WRAPPER_URL.href)) as {
default?: NativeFactory;
createKdlModule?: NativeFactory;
};
const factory = imported.default ?? imported.createKdlModule;
if (!factory) {
throw new Error("kdl.js did not export createKdlModule");
}
return factory({
locateFile: (path) => fileURLToPath(new URL(path, BUILD_DIR))
});
}
const NATIVE_SOLVER_URDF = `
<robot name="native_solver">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2" acceleration="4"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="tool0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="1" velocity="0.5" acceleration="1"/>
</joint>
</robot>
`;
function writeFloat64Array(native: NativeKdlModule, values: number[]): number {
const bytes = values.length * Float64Array.BYTES_PER_ELEMENT;
const ptr = native._malloc?.(bytes);
if (!ptr) {
throw new Error(`Failed to allocate ${bytes} bytes`);
}
native.HEAPF64?.set(values, ptr / Float64Array.BYTES_PER_ELEMENT);
return ptr;
}
function readFloat64Array(native: NativeKdlModule, ptr: number, length: number): number[] {
return Array.from(native.HEAPF64?.subarray(
ptr / Float64Array.BYTES_PER_ELEMENT,
ptr / Float64Array.BYTES_PER_ELEMENT + length
) ?? []);
}
describe("KDL C ABI", () => {
it("exports the stable P0 ABI names", async () => {
const abi = new KdlNativeAbi(await loadNativeModule());
expect(() => abi.assertExports()).not.toThrow();
expect(KDL_C_ABI_EXPORTS).toEqual([
"kdl_init",
"kdl_create_robot",
"kdl_destroy_robot",
"kdl_get_robot_info",
"kdl_fk",
"kdl_fk_all_links",
"kdl_jacobian",
"kdl_ik",
"kdl_plan_movej",
"kdl_plan_movel",
"kdl_plan_movec",
"kdl_plan_path",
"kdl_sample_trap",
"kdl_last_error"
]);
});
it("initializes, caches model handles, returns JSON info, and destroys handles", async () => {
const abi = new KdlNativeAbi(await loadNativeModule());
const model = loadRobotFromUrdfModel(NATIVE_SOLVER_URDF, {
robotId: "abi",
baseLink: "base_link",
tipLink: "tool0"
});
expect(abi.callNumber("kdl_init", ["string"], ["{}"])).toBe(0);
const handle = abi.callNumber("kdl_create_robot", ["string"], [JSON.stringify(model)]);
expect(handle).toBeGreaterThan(0);
const info = abi.readJsonCall<{ handle: number; nativeState: string; dof: number }>(
"kdl_get_robot_info",
["number"],
[handle]
);
expect(info).toMatchObject({
handle,
dof: 2,
nativeState: "kdl_chain"
});
expect(abi.callNumber("kdl_destroy_robot", ["number"], [handle])).toBe(0);
});
it("constructs a native KDL chain and returns real FK and Jacobian data", async () => {
const native = await loadNativeModule();
const abi = new KdlNativeAbi(native);
const model = loadRobotFromUrdfModel(NATIVE_SOLVER_URDF, {
robotId: "native",
baseLink: "base_link",
tipLink: "tool0"
});
expect(abi.callNumber("kdl_init", ["string"], ["{}"])).toBe(0);
const handle = abi.callNumber("kdl_create_robot", ["string"], [JSON.stringify(model)]);
expect(handle).toBeGreaterThan(0);
expect(abi.readJsonCall("kdl_get_robot_info", ["number"], [handle])).toMatchObject({
handle,
dof: 2,
jointNames: ["joint_1", "joint_2"],
nativeState: "kdl_chain"
});
const joints = writeFloat64Array(native, [Math.PI / 2, 0.4]);
const pose = native._malloc?.(7 * Float64Array.BYTES_PER_ELEMENT);
const jacobian = native._malloc?.(12 * Float64Array.BYTES_PER_ELEMENT);
expect(pose).toBeTruthy();
expect(jacobian).toBeTruthy();
try {
expect(abi.callNumber("kdl_fk", ["number", "number", "number", "number"], [handle, joints, 2, pose])).toBe(0);
const pose7 = readFloat64Array(native, pose!, 7);
expect(pose7[0]).toBeCloseTo(0);
expect(pose7[1]).toBeCloseTo(0.4);
expect(pose7[2]).toBeCloseTo(0);
expect(pose7[5]).toBeCloseTo(Math.SQRT1_2);
expect(pose7[6]).toBeCloseTo(Math.SQRT1_2);
expect(abi.callNumber("kdl_jacobian", ["number", "number", "number", "number"], [handle, joints, 2, jacobian])).toBe(0);
const jac = readFloat64Array(native, jacobian!, 12);
expect(jac[0]).toBeCloseTo(-0.4, 4);
expect(jac[1]).toBeCloseTo(0, 4);
expect(jac[2]).toBeCloseTo(0, 4);
expect(jac[3]).toBeCloseTo(1, 4);
expect(jac[10]).toBeCloseTo(1, 4);
} finally {
native._free?.(joints);
if (pose) {
native._free?.(pose);
}
if (jacobian) {
native._free?.(jacobian);
}
abi.callNumber("kdl_destroy_robot", ["number"], [handle]);
}
});
it("normalizes C ABI failures through kdl_last_error", async () => {
const abi = new KdlNativeAbi(await loadNativeModule());
expect(abi.callNumber("kdl_init", ["string"], ["{}"])).toBe(0);
const returnCode = abi.callNumber("kdl_ik", ["number", "number", "number", "number", "string", "number"], [1, 0, 0, 0, "{}", 0]);
expect(returnCode).toBe(-1);
expect(abi.lastError()).toMatchObject({
code: "KDL_NOT_IMPLEMENTED",
diagnostics: [
{
severity: "error",
code: "KDL_NOT_IMPLEMENTED"
}
]
});
expect(() => abi.checkReturnCode(returnCode)).toThrowError(
expect.objectContaining({
code: "KDL_NOT_IMPLEMENTED"
})
);
});
it("reports JSON output buffer errors without raw strings", async () => {
const native = await loadNativeModule();
const abi = new KdlNativeAbi(native);
const model = loadRobotFromUrdfModel(NATIVE_SOLVER_URDF, {
robotId: "abi",
baseLink: "base_link",
tipLink: "tool0"
});
expect(abi.callNumber("kdl_init", ["string"], ["{}"])).toBe(0);
const handle = abi.callNumber("kdl_create_robot", ["string"], [JSON.stringify(model)]);
const ptr = native._malloc?.(4);
expect(ptr).toBeTruthy();
try {
const returnCode = abi.callNumber("kdl_get_robot_info", ["number", "number", "number"], [handle, ptr, 4]);
expect(returnCode).toBe(-1);
expect(abi.lastError()).toMatchObject({
code: "KDL_BUFFER_TOO_SMALL",
diagnostics: [
{
severity: "error",
code: "KDL_BUFFER_TOO_SMALL"
}
]
});
} finally {
if (ptr) {
native._free?.(ptr);
}
}
});
});

View File

@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { JacobianResult, PoseTarget, ReachabilityResult } from "../../src/kdl/types.js";
const PLANAR_URDF = `
<robot name="planar_checks">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2" acceleration="4"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="tool0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="1" velocity="0.5" acceleration="1"/>
</joint>
</robot>
`;
async function createRobot() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [
PLANAR_URDF,
{
robotId: "checks",
baseLink: "base_link",
tipLink: "tool0"
}
]
});
expect(response.ok).toBe(true);
return { runtime, handle: response.result as number };
}
function poseTarget(id: string, x: number, y: number): PoseTarget {
return {
id,
pose: {
position: [x, y, 0],
quaternion: [0, 0, 0, 1]
}
};
}
describe("Jacobian, singularity, limits, and reachability checks", () => {
it("computes a 6xdof Jacobian with expected linear components", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "jacobian",
payload: [handle, [Math.PI / 2, 0.4]]
});
expect(response.ok).toBe(true);
const jacobian = response.result as JacobianResult;
expect(jacobian.rows).toBe(6);
expect(jacobian.cols).toBe(2);
expect(jacobian.data).toHaveLength(12);
expect(jacobian.data[0]).toBeCloseTo(-0.4, 4);
expect(jacobian.data[1]).toBeCloseTo(0, 4);
expect(jacobian.data[2]).toBeCloseTo(0, 4);
expect(jacobian.data[3]).toBeCloseTo(1, 4);
});
it("reports singularity warning for collapsed planar reach", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "checkSingularity",
payload: [handle, [0, 0]]
});
expect(response.result).toMatchObject({
ok: true,
nearSingularity: true,
diagnostics: [
{
severity: "warning",
code: "KDL_SINGULARITY"
}
]
});
});
it("checks joint and velocity limits with structured diagnostics", async () => {
const { runtime, handle } = await createRobot();
const jointResponse = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "checkJointLimits",
payload: [handle, [0, 2]]
});
expect(jointResponse.result).toMatchObject({
ok: false,
diagnostics: [
{
severity: "error",
code: "KDL_JOINT_LIMIT"
}
]
});
const velocityResponse = await dispatchKdlRpcRequest(runtime, {
id: 6,
method: "checkVelocityLimits",
payload: [
handle,
{
points: [
{
jointVelocity: [1, 0.75],
jointAcceleration: [1, 1.5]
}
]
}
]
});
expect(velocityResponse.result).toMatchObject({
ok: false,
maxJointVelocityRatio: 1.5,
maxJointAccelerationRatio: 1.5,
diagnostics: [
{
severity: "error",
code: "KDL_VELOCITY_LIMIT",
pointIndex: 0
},
{
severity: "error",
code: "KDL_ACCEL_LIMIT",
pointIndex: 0
}
]
});
});
it("checks reachability and preserves batch order", async () => {
const { runtime, handle } = await createRobot();
const reachable = await dispatchKdlRpcRequest(runtime, {
id: 7,
method: "checkReachability",
payload: [handle, poseTarget("ok", 0, 0.3), { positionTolerance: 1e-9 }]
});
expect(reachable.result).toMatchObject({
ok: true,
reachable: true,
targetId: "ok",
joints: [Math.PI / 2, 0.3]
});
const batch = await dispatchKdlRpcRequest(runtime, {
id: 8,
method: "checkReachabilityBatch",
payload: [handle, [poseTarget("a", 0.2, 0), poseTarget("b", 2, 0)], {}]
});
const results = batch.result as ReachabilityResult[];
expect(results.map((result) => result.targetId)).toEqual(["a", "b"]);
expect(results[0]?.reachable).toBe(true);
expect(results[1]).toMatchObject({
reachable: false,
diagnostics: [
{
severity: "error",
code: "KDL_JOINT_LIMIT"
}
]
});
});
});

View File

@@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
const SIMPLE_URDF = `
<robot name="simple_fk">
<link name="base_link"/>
<link name="link_1"/>
<link name="link_2"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="link_2"/>
<origin xyz="0 0 0.2" rpy="0 0 0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="0.4" velocity="0.3" acceleration="1.2"/>
</joint>
<joint name="tool_fixed" type="fixed">
<parent link="link_2"/>
<child link="tool0"/>
<origin xyz="0 0 0.05" rpy="0 0 0"/>
</joint>
</robot>
`;
async function createRuntimeRobot() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [
SIMPLE_URDF,
{
robotId: "fk",
baseLink: "base_link",
tipLink: "tool0"
}
]
});
expect(response.ok).toBe(true);
return { runtime, handle: response.result as number };
}
describe("FK and fkAllLinks", () => {
it("computes flange and tcp poses for the zero joint state", async () => {
const { runtime, handle } = await createRuntimeRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "fk",
payload: [handle, [0, 0]]
});
expect(response.ok).toBe(true);
const result = response.result as {
ok: boolean;
joints: number[];
diagnostics: unknown[];
flange: { position: number[]; quaternion: number[] };
tcp: { position: number[]; quaternion: number[] };
};
expect(result.ok).toBe(true);
expect(result.joints).toEqual([0, 0]);
expect(result.diagnostics).toEqual([]);
expect(result.flange.position[0]).toBeCloseTo(0);
expect(result.flange.position[1]).toBeCloseTo(0);
expect(result.flange.position[2]).toBeCloseTo(0.35);
expect(result.flange.quaternion).toEqual([0, 0, 0, 1]);
expect(result.tcp.position[0]).toBeCloseTo(0);
expect(result.tcp.position[1]).toBeCloseTo(0);
expect(result.tcp.position[2]).toBeCloseTo(0.35);
expect(result.tcp.quaternion).toEqual([0, 0, 0, 1]);
});
it("applies revolute and prismatic joint motion in chain order", async () => {
const { runtime, handle } = await createRuntimeRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "fk",
payload: [handle, [Math.PI / 2, 0.2]]
});
expect(response.ok).toBe(true);
const result = response.result as { flange: { position: number[]; quaternion: number[] } };
expect(result.flange.position[0]).toBeCloseTo(0);
expect(result.flange.position[1]).toBeCloseTo(0.2);
expect(result.flange.position[2]).toBeCloseTo(0.35);
expect(result.flange.quaternion[2]).toBeCloseTo(Math.SQRT1_2);
expect(result.flange.quaternion[3]).toBeCloseTo(Math.SQRT1_2);
});
it("returns link poses in base-to-tip order", async () => {
const { runtime, handle } = await createRuntimeRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "fkAllLinks",
payload: [handle, [0, 0.1]]
});
expect(response.ok).toBe(true);
const result = response.result as {
linkPoses: Array<{ link: string; pose: { position: number[] } }>;
};
expect(result.linkPoses.map((entry) => entry.link)).toEqual(["base_link", "link_1", "link_2", "tool0"]);
expect(result.linkPoses[0]?.pose.position).toEqual([0, 0, 0]);
expect(result.linkPoses[3]?.pose.position[0]).toBeCloseTo(0.1);
expect(result.linkPoses[3]?.pose.position[2]).toBeCloseTo(0.35);
});
it("applies tool offset to tcp without changing flange", async () => {
const { runtime, handle } = await createRuntimeRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "fk",
payload: [
handle,
[0, 0],
{
tool: {
position: [0, 0, 0.1],
quaternion: [0, 0, 0, 1]
}
}
]
});
expect(response.ok).toBe(true);
const result = response.result as {
flange: { position: number[] };
tcp: { position: number[] };
};
expect(result.flange.position[2]).toBeCloseTo(0.35);
expect(result.tcp.position[2]).toBeCloseTo(0.45);
});
it("writes tcp pose into a reusable Float64Array", async () => {
const { runtime, handle } = await createRuntimeRobot();
const out = new Float64Array(7);
const response = await dispatchKdlRpcRequest(runtime, {
id: 51,
method: "fkPose7",
payload: [handle, new Float64Array([Math.PI / 2, 0.2]), out]
});
expect(response.ok).toBe(true);
expect(response.result).toBe(out);
expect(out[0]).toBeCloseTo(0);
expect(out[1]).toBeCloseTo(0.2);
expect(out[2]).toBeCloseTo(0.35);
expect(out[5]).toBeCloseTo(Math.SQRT1_2);
expect(out[6]).toBeCloseTo(Math.SQRT1_2);
});
it("returns a structured error for undersized fkPose7 output buffers", async () => {
const { runtime, handle } = await createRuntimeRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 52,
method: "fkPose7",
payload: [handle, [0, 0], new Float64Array(6)]
});
expect(response).toMatchObject({
ok: false,
error: {
code: "KDL_OUTPUT_DIMENSION_MISMATCH"
}
});
});
it("returns a structured dimension diagnostic", async () => {
const { runtime, handle } = await createRuntimeRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 6,
method: "fk",
payload: [handle, [0]]
});
expect(response).toMatchObject({
ok: false,
error: {
code: "KDL_JOINT_DIMENSION_MISMATCH",
diagnostics: [
{
severity: "error",
code: "KDL_JOINT_DIMENSION_MISMATCH"
}
]
}
});
});
});

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { IkResult, Pose } from "../../src/kdl/types.js";
const PLANAR_URDF = `
<robot name="planar_ik">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<origin xyz="0 0 0" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="tool0"/>
<origin xyz="0 0 0" rpy="0 0 0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="1" velocity="0.3" acceleration="1.2"/>
</joint>
</robot>
`;
const UNSUPPORTED_URDF = `
<robot name="unsupported_ik">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<axis xyz="0 1 0"/>
<limit lower="-3.14" upper="3.14" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_2" type="revolute">
<parent link="link_1"/>
<child link="tool0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.14" upper="3.14" velocity="2.5" acceleration="5"/>
</joint>
</robot>
`;
function pose(x: number, y: number, z = 0): Pose {
return {
position: [x, y, z],
quaternion: [0, 0, 0, 1]
};
}
async function createRobot(urdf = PLANAR_URDF) {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [
urdf,
{
robotId: "ik",
baseLink: "base_link",
tipLink: "tool0"
}
]
});
expect(response.ok).toBe(true);
return { runtime, handle: response.result as number };
}
describe("IK and ikBatch", () => {
it("solves a reachable planar target and FK back-substitution is within tolerance", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "ik",
payload: [handle, [0, 0], pose(0, 0.4), { positionTolerance: 1e-9 }]
});
expect(response.ok).toBe(true);
const result = response.result as IkResult;
expect(result.ok).toBe(true);
expect(result.joints?.[0]).toBeCloseTo(Math.PI / 2);
expect(result.joints?.[1]).toBeCloseTo(0.4);
expect(result.residualPosition).toBeLessThan(1e-9);
const fk = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "fk",
payload: [handle, result.joints]
});
const fkResult = fk.result as { tcp: { position: number[] } };
expect(fkResult.tcp.position[0]).toBeCloseTo(0);
expect(fkResult.tcp.position[1]).toBeCloseTo(0.4);
});
it("keeps ikBatch results in input order", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "ikBatch",
payload: [
handle,
[
[0, 0],
[0, 0]
],
[pose(0.2, 0), pose(0, 0.3)],
{}
]
});
expect(response.ok).toBe(true);
const results = response.result as IkResult[];
expect(results).toHaveLength(2);
expect(results[0]?.joints?.[0]).toBeCloseTo(0);
expect(results[0]?.joints?.[1]).toBeCloseTo(0.2);
expect(results[1]?.joints?.[0]).toBeCloseTo(Math.PI / 2);
expect(results[1]?.joints?.[1]).toBeCloseTo(0.3);
});
it("returns joint_limit reason when the candidate exceeds limits", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 6,
method: "ik",
payload: [handle, [0, 0], pose(2, 0), {}]
});
expect(response.ok).toBe(true);
expect(response.result).toMatchObject({
ok: false,
reason: "joint_limit",
diagnostics: [
{
severity: "error",
code: "KDL_JOINT_LIMIT"
}
]
});
});
it("returns invalid_model reason for unsupported IK chains", async () => {
const { runtime, handle } = await createRobot(UNSUPPORTED_URDF);
const response = await dispatchKdlRpcRequest(runtime, {
id: 7,
method: "ik",
payload: [handle, [0, 0], pose(0.2, 0), {}]
});
expect(response.ok).toBe(true);
expect(response.result).toMatchObject({
ok: false,
reason: "invalid_model",
diagnostics: [
{
severity: "error",
code: "KDL_IK_UNSUPPORTED_MODEL"
}
]
});
});
});

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { PathPlanRequest, PathPlanResult, PathValidationResult } from "../../src/kdl/types.js";
const PLANAR_URDF = `
<robot name="path_planar">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2" acceleration="10"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="tool0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="1" velocity="1" acceleration="10"/>
</joint>
</robot>
`;
async function createRobot() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [
PLANAR_URDF,
{
robotId: "path",
baseLink: "base_link",
tipLink: "tool0"
}
]
});
expect(response.ok).toBe(true);
return { runtime, handle: response.result as number };
}
function pathRequest(overrides: Partial<PathPlanRequest> = {}): PathPlanRequest {
return {
startJoints: [0, 0],
sampleTime: 0.05,
segments: [
{
id: "move-home",
motion: "MOVEJ",
target: {
id: "joint_goal",
joints: [Math.PI / 2, 0.2]
},
speed: { kind: "joint_abs", velocity: 1, acceleration: 4 },
zone: { kind: "fine" },
sourceMap: { line: 10, column: 5 }
},
{
id: "line-out",
motion: "MOVEL",
target: {
id: "line_goal",
pose: {
position: [0, 0.4, 0],
quaternion: [0, 0, 0, 1]
}
},
speed: { kind: "linear", velocity: 0.2, acceleration: 1 },
zone: { kind: "fine" },
sourceMap: { line: 11, column: 5 }
}
],
...overrides
};
}
describe("planPath and validatePath", () => {
it("plans multiple motion segments and merges points with segment metadata", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "planPath",
payload: [handle, pathRequest()]
});
expect(response.ok).toBe(true);
const result = response.result as PathPlanResult;
expect(result.ok).toBe(true);
expect(result.segments).toHaveLength(2);
expect(result.points.length).toBeGreaterThan(result.segments[0]!.points.length);
expect(result.points[0]).toMatchObject({
index: 0,
time: 0,
segmentId: "move-home",
targetId: "joint_goal",
sourceMap: { line: 10 }
});
expect(result.points.at(-1)).toMatchObject({
segmentId: "line-out",
targetId: "line_goal",
sourceMap: { line: 11 }
});
expect(result.points.at(-1)?.tcp.position[0]).toBeCloseTo(0, 5);
expect(result.points.at(-1)?.tcp.position[1]).toBeCloseTo(0.4, 5);
expect(result.duration).toBeCloseTo(result.segments[0]!.duration + result.segments[1]!.duration);
for (let index = 1; index < result.points.length; index += 1) {
expect(result.points[index]!.time).toBeGreaterThan(result.points[index - 1]!.time);
expect(result.points[index]!.index).toBe(index);
}
});
it("validates a path and returns per-segment reports", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "validatePath",
payload: [handle, pathRequest()]
});
expect(response.ok).toBe(true);
const result = response.result as PathValidationResult;
expect(result.ok).toBe(true);
expect(result.reachable).toBe(true);
expect(result.cycleTime).toBeGreaterThan(0);
expect(result.segmentReports.map((report) => report.segmentId)).toEqual(["move-home", "line-out"]);
expect(result.segmentReports[0]).toMatchObject({
ok: true,
motion: "MOVEJ"
});
expect(result.segmentReports[1]).toMatchObject({
ok: true,
motion: "MOVEL",
maxCartesianError: 0
});
});
it("returns KDL_PATH_EMPTY for empty path requests", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "planPath",
payload: [
handle,
pathRequest({
segments: []
})
]
});
expect(response.ok).toBe(true);
expect(response.result).toMatchObject({
ok: false,
duration: 0,
segments: [],
points: [],
diagnostics: [
{
severity: "error",
code: "KDL_PATH_EMPTY"
}
]
});
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { runPerformanceBaseline } from "../../src/kdl/performanceBaseline.js";
describe("KDL performance baseline", () => {
it("records TypedArray and batch baseline metrics", async () => {
const result = await runPerformanceBaseline();
const metrics = Object.fromEntries(result.metrics.map((metric) => [metric.name, metric]));
expect(result.ok).toBe(true);
expect(result.diagnostics).toEqual([]);
expect(metrics.robot_init_6_axis?.totalMs).toBeLessThanOrEqual(1_000);
expect(metrics.fk_pose7_typed_array?.averageMs).toBeLessThanOrEqual(1);
expect(metrics.ik_planar_average?.averageMs).toBeLessThanOrEqual(10);
expect(metrics.reachability_batch_1000).toMatchObject({
points: 1_000,
ok: true
});
expect(metrics.trajectory_10s_4ms).toMatchObject({
ok: true
});
expect(metrics.trajectory_10s_4ms?.points).toBeGreaterThanOrEqual(2_500);
});
});

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { MoveCRequest, TrajectoryResult } from "../../src/kdl/types.js";
const PLANAR_URDF = `
<robot name="movec_planar">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="4" acceleration="20"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="tool0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="1" velocity="2" acceleration="20"/>
</joint>
</robot>
`;
async function createRobot() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [
PLANAR_URDF,
{
robotId: "movec",
baseLink: "base_link",
tipLink: "tool0"
}
]
});
expect(response.ok).toBe(true);
return { runtime, handle: response.result as number };
}
function request(overrides: Partial<MoveCRequest>): MoveCRequest {
return {
startJoints: [0, 0.5],
via: {
id: "via",
pose: {
position: [0.5, 0.5, 0],
quaternion: [0, 0, 0, 1]
}
},
target: {
id: "arc_goal",
pose: {
position: [0, 0.5, 0],
quaternion: [0, 0, 0, 1]
}
},
speed: {
kind: "linear",
velocity: 0.25,
acceleration: 1
},
zone: {
kind: "fine"
},
sampleTime: 0.05,
...overrides
};
}
describe("planMoveC", () => {
it("plans a circular TCP arc with circle metadata", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "planMoveC",
payload: [handle, request({})]
});
expect(response.ok).toBe(true);
const trajectory = response.result as TrajectoryResult;
expect(trajectory.ok).toBe(true);
expect(trajectory.motion).toBe("MOVEC");
expect(trajectory.points.length).toBeGreaterThan(2);
expect(trajectory.points[0]).toMatchObject({
index: 0,
time: 0,
s: 0,
motion: "MOVEC",
targetId: "arc_goal"
});
expect(trajectory.points.at(-1)?.s).toBe(1);
expect(trajectory.points.at(-1)?.tcp.position[0]).toBeCloseTo(0, 5);
expect(trajectory.points.at(-1)?.tcp.position[1]).toBeCloseTo(0.5, 5);
const circle = trajectory.meta?.circle as {
center: number[];
radius: number;
angle: number;
length: number;
direction: "cw" | "ccw";
maxArcError: number;
};
expect(circle.center[0]).toBeCloseTo(0.25);
expect(circle.center[1]).toBeCloseTo(0.25);
expect(circle.radius).toBeCloseTo(Math.SQRT1_2 / 2);
expect(circle.angle).toBeCloseTo(Math.PI);
expect(circle.length).toBeCloseTo((Math.SQRT1_2 / 2) * Math.PI);
expect(circle.direction).toBe("ccw");
expect(circle.maxArcError).toBeLessThan(1e-6);
});
it("returns KDL_ARC_DEGENERATE for collinear points", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "planMoveC",
payload: [
handle,
request({
via: {
id: "line_mid",
pose: {
position: [0.25, 0, 0],
quaternion: [0, 0, 0, 1]
}
},
target: {
id: "line_end",
pose: {
position: [0.75, 0, 0],
quaternion: [0, 0, 0, 1]
}
}
})
]
});
expect(response.ok).toBe(true);
expect(response.result).toMatchObject({
ok: false,
motion: "MOVEC",
points: [],
diagnostics: [
{
severity: "error",
code: "KDL_ARC_DEGENERATE"
}
]
});
});
it("reports zone approximation and joint-speed approximation warnings", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "planMoveC",
payload: [
handle,
request({
speed: {
kind: "joint_abs",
velocity: 0.25,
acceleration: 1
},
zone: {
kind: "distance",
value: 0.01
}
})
]
});
const trajectory = response.result as TrajectoryResult;
expect(trajectory.ok).toBe(true);
expect(trajectory.diagnostics).toContainEqual(
expect.objectContaining({
severity: "warning",
code: "KDL_MOVEC_JOINT_SPEED_APPROX"
})
);
expect(trajectory.diagnostics).toContainEqual(
expect.objectContaining({
severity: "warning",
code: "KDL_ZONE_APPROX_FINE"
})
);
});
});

View File

@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { MoveJRequest, PoseTarget, TrajectoryResult } from "../../src/kdl/types.js";
const PLANAR_URDF = `
<robot name="movej_planar">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="1" acceleration="2"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="tool0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="1" velocity="0.5" acceleration="1"/>
</joint>
</robot>
`;
async function createRobot() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [
PLANAR_URDF,
{
robotId: "movej",
baseLink: "base_link",
tipLink: "tool0"
}
]
});
expect(response.ok).toBe(true);
return { runtime, handle: response.result as number };
}
function baseRequest(overrides: Partial<MoveJRequest>): MoveJRequest {
return {
startJoints: [0, 0],
target: {
id: "joint_goal",
joints: [0.5, 0.25]
},
speed: {
kind: "joint_abs",
velocity: 0.5,
acceleration: 1
},
zone: {
kind: "fine"
},
sampleTime: 0.1,
...overrides
};
}
describe("planMoveJ", () => {
it("plans a synchronized joint trajectory to a joint target", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "planMoveJ",
payload: [handle, baseRequest({})]
});
expect(response.ok).toBe(true);
const trajectory = response.result as TrajectoryResult;
expect(trajectory.ok).toBe(true);
expect(trajectory.motion).toBe("MOVEJ");
expect(trajectory.points.length).toBeGreaterThan(2);
expect(trajectory.points[0]).toMatchObject({
index: 0,
time: 0,
s: 0,
joints: [0, 0],
motion: "MOVEJ",
targetId: "joint_goal"
});
expect(trajectory.points.at(-1)?.s).toBe(1);
expect(trajectory.points.at(-1)?.joints[0]).toBeCloseTo(0.5);
expect(trajectory.points.at(-1)?.joints[1]).toBeCloseTo(0.25);
expect(trajectory.points.at(-1)?.jointVelocity[0]).toBeCloseTo(0);
expect(trajectory.points.at(-1)?.tcp.position[0]).toBeCloseTo(0.25 * Math.cos(0.5));
expect(trajectory.points.at(-1)?.tcp.position[1]).toBeCloseTo(0.25 * Math.sin(0.5));
expect(trajectory.meta).toMatchObject({
targetType: "joint",
qStart: [0, 0],
qEnd: [0.5, 0.25]
});
});
it("uses IK for pose targets and warns when zone is approximated as fine", async () => {
const { runtime, handle } = await createRobot();
const target: PoseTarget = {
id: "pose_goal",
pose: {
position: [0, 0.3, 0],
quaternion: [0, 0, 0, 1]
}
};
const response = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "planMoveJ",
payload: [
handle,
baseRequest({
target,
zone: { kind: "distance", value: 0.01 }
})
]
});
const trajectory = response.result as TrajectoryResult;
expect(trajectory.ok).toBe(true);
expect(trajectory.points.at(-1)?.joints[0]).toBeCloseTo(Math.PI / 2);
expect(trajectory.points.at(-1)?.joints[1]).toBeCloseTo(0.3);
expect(trajectory.diagnostics).toContainEqual(
expect.objectContaining({
severity: "warning",
code: "KDL_ZONE_APPROX_FINE"
})
);
expect(trajectory.meta).toMatchObject({
targetType: "pose"
});
});
it("returns a failed trajectory result for endpoint joint limit violations", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "planMoveJ",
payload: [
handle,
baseRequest({
target: {
id: "bad_goal",
joints: [0, 2]
}
})
]
});
expect(response.ok).toBe(true);
expect(response.result).toMatchObject({
ok: false,
motion: "MOVEJ",
points: [],
diagnostics: [
{
severity: "error",
code: "KDL_JOINT_LIMIT"
}
]
});
});
it("keeps velocity and acceleration within joint limits", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 6,
method: "planMoveJ",
payload: [
handle,
baseRequest({
speed: { kind: "joint_percent", value: 1 },
target: {
id: "limit_goal",
joints: [1, 0.5]
}
})
]
});
const trajectory = response.result as TrajectoryResult;
expect(trajectory.ok).toBe(true);
for (const point of trajectory.points) {
expect(Math.abs(point.jointVelocity[0]!)).toBeLessThanOrEqual(1 + 1e-9);
expect(Math.abs(point.jointVelocity[1]!)).toBeLessThanOrEqual(0.5 + 1e-9);
expect(Math.abs(point.jointAcceleration[0]!)).toBeLessThanOrEqual(2 + 1e-9);
expect(Math.abs(point.jointAcceleration[1]!)).toBeLessThanOrEqual(1 + 1e-9);
}
});
});

View File

@@ -0,0 +1,175 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { MoveLRequest, TrajectoryResult } from "../../src/kdl/types.js";
const PLANAR_URDF = `
<robot name="movel_planar">
<link name="base_link"/>
<link name="link_1"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<axis xyz="0 0 1"/>
<limit lower="-3.141592653589793" upper="3.141592653589793" velocity="2" acceleration="10"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="tool0"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="1" velocity="1" acceleration="10"/>
</joint>
</robot>
`;
async function createRobot() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [
PLANAR_URDF,
{
robotId: "movel",
baseLink: "base_link",
tipLink: "tool0"
}
]
});
expect(response.ok).toBe(true);
return { runtime, handle: response.result as number };
}
function request(overrides: Partial<MoveLRequest>): MoveLRequest {
return {
startJoints: [Math.PI / 2, 0.2],
target: {
id: "line_goal",
pose: {
position: [0, 0.6, 0],
quaternion: [0, 0, 0, 1]
}
},
speed: {
kind: "linear",
velocity: 0.2,
acceleration: 1
},
zone: {
kind: "fine"
},
sampleTime: 0.05,
...overrides
};
}
describe("planMoveL", () => {
it("plans a TCP straight-line trajectory with continuous IK seeds", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "planMoveL",
payload: [handle, request({})]
});
expect(response.ok).toBe(true);
const trajectory = response.result as TrajectoryResult;
expect(trajectory.ok).toBe(true);
expect(trajectory.motion).toBe("MOVEL");
expect(trajectory.points.length).toBeGreaterThan(2);
expect(trajectory.points[0]).toMatchObject({
index: 0,
time: 0,
s: 0,
motion: "MOVEL",
targetId: "line_goal"
});
expect(trajectory.points.at(-1)?.s).toBe(1);
expect(trajectory.points.at(-1)?.tcp.position[0]).toBeCloseTo(0, 6);
expect(trajectory.points.at(-1)?.tcp.position[1]).toBeCloseTo(0.6, 6);
expect(trajectory.meta).toMatchObject({
targetId: "line_goal",
orientationMode: "fixed"
});
expect(trajectory.meta?.length as number).toBeCloseTo(0.4);
for (const point of trajectory.points) {
expect(point.tcp.position[0]).toBeCloseTo(0, 5);
expect(point.tcp.position[2]).toBeCloseTo(0, 5);
expect(point.tcp.position[1]).toBeGreaterThanOrEqual(0.2 - 1e-9);
expect(point.tcp.position[1]).toBeLessThanOrEqual(0.6 + 1e-9);
}
});
it("returns a failed trajectory when a sampled pose is unreachable", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "planMoveL",
payload: [
handle,
request({
target: {
id: "far_goal",
pose: {
position: [0, 2, 0],
quaternion: [0, 0, 0, 1]
}
}
})
]
});
const trajectory = response.result as TrajectoryResult;
expect(trajectory.ok).toBe(false);
expect(trajectory.motion).toBe("MOVEL");
expect(trajectory.diagnostics).toContainEqual(
expect.objectContaining({
severity: "error",
code: "KDL_JOINT_LIMIT"
})
);
expect(trajectory.meta).toMatchObject({
targetId: "far_goal"
});
});
it("reports zone approximation and joint-speed approximation warnings", async () => {
const { runtime, handle } = await createRobot();
const response = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "planMoveL",
payload: [
handle,
request({
speed: {
kind: "joint_abs",
velocity: 0.2,
acceleration: 1
},
zone: {
kind: "distance",
value: 0.01
}
})
]
});
const trajectory = response.result as TrajectoryResult;
expect(trajectory.ok).toBe(true);
expect(trajectory.diagnostics).toContainEqual(
expect.objectContaining({
severity: "warning",
code: "KDL_MOVEL_JOINT_SPEED_APPROX"
})
);
expect(trajectory.diagnostics).toContainEqual(
expect.objectContaining({
severity: "warning",
code: "KDL_ZONE_APPROX_FINE"
})
);
});
});

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { Pose, PoseTarget } from "../../src/kdl/types.js";
async function createRuntime() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
return runtime;
}
function pose(x: number, y: number, z: number): Pose {
return {
position: [x, y, z],
quaternion: [0, 0, 0, 1]
};
}
describe("pose transform and offset API", () => {
it("normalizes pose inputs from rpy and quaternion forms", async () => {
const runtime = await createRuntime();
const rpyResponse = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "normalizePose",
payload: [{ xyz: [1, 2, 3], rpy: [0, 0, Math.PI / 2] }]
});
const quatResponse = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "normalizePose",
payload: [{ xyz: [0, 0, 0], quat: [0, 0, 0, 2] }]
});
expect(rpyResponse.ok).toBe(true);
expect((rpyResponse.result as Pose).position).toEqual([1, 2, 3]);
expect((rpyResponse.result as Pose).quaternion[2]).toBeCloseTo(Math.SQRT1_2);
expect((rpyResponse.result as Pose).quaternion[3]).toBeCloseTo(Math.SQRT1_2);
expect(quatResponse.result).toMatchObject({
position: [0, 0, 0],
quaternion: [0, 0, 0, 1]
});
});
it("composes poses and computes an inverse pose", async () => {
const runtime = await createRuntime();
const composeResponse = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "composePose",
payload: [pose(1, 0, 0), pose(0, 2, 0)]
});
expect(composeResponse.result).toMatchObject({
position: [1, 2, 0],
quaternion: [0, 0, 0, 1]
});
const inverseResponse = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "inversePose",
payload: [pose(1, 2, 3)]
});
expect(inverseResponse.result).toMatchObject({
position: [-1, -2, -3],
quaternion: [0, 0, 0, 1]
});
const identityResponse = await dispatchKdlRpcRequest(runtime, {
id: 6,
method: "composePose",
payload: [pose(1, 2, 3), inverseResponse.result]
});
expect((identityResponse.result as Pose).position[0]).toBeCloseTo(0);
expect((identityResponse.result as Pose).position[1]).toBeCloseTo(0);
expect((identityResponse.result as Pose).position[2]).toBeCloseTo(0);
});
it("applies frame, target, and tool using the same order as FK", async () => {
const runtime = await createRuntime();
const target: PoseTarget = {
id: "pick",
pose: pose(0.5, 0.1, 0.2)
};
const response = await dispatchKdlRpcRequest(runtime, {
id: 7,
method: "applyToolAndFrame",
payload: [target, pose(0, 0, 0.18), pose(0.8, 0, 0.2)]
});
const result = response.result as Pose;
expect(result.position[0]).toBeCloseTo(1.3);
expect(result.position[1]).toBeCloseTo(0.1);
expect(result.position[2]).toBeCloseTo(0.58);
expect(result.quaternion).toEqual([0, 0, 0, 1]);
});
it("applies offset in frame/world by left composition and tool by right composition", async () => {
const runtime = await createRuntime();
const target: PoseTarget = {
id: "pick",
pose: {
position: [1, 2, 3],
quaternion: [0, 0, Math.SQRT1_2, Math.SQRT1_2]
},
frame: pose(10, 0, 0)
};
const frameOffset = await dispatchKdlRpcRequest(runtime, {
id: 8,
method: "applyOffset",
payload: [target, { mode: "frame", xyz: [0.1, 0, 0] }]
});
const worldOffset = await dispatchKdlRpcRequest(runtime, {
id: 9,
method: "applyOffset",
payload: [target, { mode: "world", xyz: [0, 0.2, 0] }]
});
const toolOffset = await dispatchKdlRpcRequest(runtime, {
id: 10,
method: "applyOffset",
payload: [target, { mode: "tool", xyz: [0.1, 0, 0] }]
});
expect((frameOffset.result as PoseTarget).id).toBe("pick");
expect((frameOffset.result as PoseTarget).frame).toEqual(target.frame);
expect((frameOffset.result as PoseTarget).pose.position[0]).toBeCloseTo(1.1);
expect((frameOffset.result as PoseTarget).pose.position[1]).toBeCloseTo(2);
expect((worldOffset.result as PoseTarget).pose.position[0]).toBeCloseTo(1);
expect((worldOffset.result as PoseTarget).pose.position[1]).toBeCloseTo(2.2);
expect((toolOffset.result as PoseTarget).pose.position[0]).toBeCloseTo(1);
expect((toolOffset.result as PoseTarget).pose.position[1]).toBeCloseTo(2.1);
});
it("returns structured diagnostics for invalid pose inputs", async () => {
const runtime = await createRuntime();
const response = await dispatchKdlRpcRequest(runtime, {
id: 11,
method: "normalizePose",
payload: [{ xyz: [1, 2], rpy: [0, 0, 0] }]
});
expect(response).toMatchObject({
ok: false,
error: {
code: "KDL_INVALID_POSE",
diagnostics: [
{
severity: "error",
code: "KDL_INVALID_POSE"
}
]
}
});
});
});

View File

@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import { KdlWorkerClient, type KdlWorkerLike } from "../../src/kdl/kdlClient.js";
import type { NativeKdlModule } from "../../src/kdl/nativeModule.js";
import { KdlStructuredError, type KdlRpcRequest, type KdlRpcResponse } from "../../src/kdl/rpc.js";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
class FakeWorker implements KdlWorkerLike {
readonly sent: Array<KdlRpcRequest<unknown[]>> = [];
terminated = false;
private messageListeners = new Set<(event: MessageEvent<KdlRpcResponse>) => void>();
private errorListeners = new Set<(event: ErrorEvent) => void>();
postMessage(message: KdlRpcRequest<unknown[]>): void {
this.sent.push(message);
}
terminate(): void {
this.terminated = true;
}
addEventListener(type: "message", listener: (event: MessageEvent<KdlRpcResponse>) => void): void;
addEventListener(type: "error", listener: (event: ErrorEvent) => void): void;
addEventListener(type: "message" | "error", listener: unknown): void {
if (type === "message") {
this.messageListeners.add(listener as (event: MessageEvent<KdlRpcResponse>) => void);
return;
}
this.errorListeners.add(listener as (event: ErrorEvent) => void);
}
removeEventListener(type: "message", listener: (event: MessageEvent<KdlRpcResponse>) => void): void;
removeEventListener(type: "error", listener: (event: ErrorEvent) => void): void;
removeEventListener(type: "message" | "error", listener: unknown): void {
if (type === "message") {
this.messageListeners.delete(listener as (event: MessageEvent<KdlRpcResponse>) => void);
return;
}
this.errorListeners.delete(listener as (event: ErrorEvent) => void);
}
emitResponse(response: KdlRpcResponse): void {
const event = { data: response } as MessageEvent<KdlRpcResponse>;
for (const listener of this.messageListeners) {
listener(event);
}
}
emitError(message: string): void {
const event = { message, error: new Error(message) } as ErrorEvent;
for (const listener of this.errorListeners) {
listener(event);
}
}
}
describe("KDL Worker RPC", () => {
it("loads the native WASM module during init when a loader is configured", async () => {
const calls: unknown[][] = [];
const native: NativeKdlModule = {
ccall: (...args) => {
calls.push(args);
return 0;
}
};
const runtime = createKdlWorkerRuntime(async () => native);
const response = await dispatchKdlRpcRequest(runtime, {
id: 1,
method: "init",
payload: [{ wasmBuild: "native-test" }]
});
expect(response.ok).toBe(true);
expect(response.result).toMatchObject({ wasmBuild: "native-test" });
expect(calls).toEqual([["kdl_init", "number", ["string"], ['{"wasmBuild":"native-test"}']]]);
});
it("normalizes native WASM initialization failures", async () => {
const runtime = createKdlWorkerRuntime(async () => {
throw new Error("cannot load kdl.js");
});
const response = await dispatchKdlRpcRequest(runtime, {
id: 11,
method: "init",
payload: [{}]
});
expect(response.ok).toBe(false);
expect(response.error).toMatchObject({
code: "KDL_WASM_INIT_FAILED",
diagnostics: [
{
severity: "error",
code: "KDL_WASM_INIT_FAILED"
}
]
});
});
it("dispatches init and dispose through structured responses", async () => {
const runtime = createKdlWorkerRuntime();
const initResponse = await dispatchKdlRpcRequest(runtime, {
id: 1,
method: "init",
payload: [{ wasmBuild: "test", useThreads: true }]
});
expect(initResponse.ok).toBe(true);
expect(initResponse.result).toMatchObject({
version: "0.1.0",
wasmBuild: "test",
supportsThreads: true
});
const disposeResponse = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "dispose",
payload: []
});
expect(disposeResponse).toMatchObject({ id: 2, ok: true });
});
it("returns a structured error when an implemented method is called before init", async () => {
const response = await dispatchKdlRpcRequest(createKdlWorkerRuntime(), {
id: 7,
method: "fk",
payload: [1, new Float64Array([0])]
});
expect(response.ok).toBe(false);
expect(response.error).toMatchObject({
code: "KDL_NOT_INITIALIZED",
diagnostics: [
{
severity: "error",
code: "KDL_NOT_INITIALIZED"
}
]
});
});
it("uses unique request ids and resolves responses by id", async () => {
const workers: FakeWorker[] = [];
const client = new KdlWorkerClient(() => {
const worker = new FakeWorker();
workers.push(worker);
return worker;
});
const first = client.call("init", { wasmBuild: "a" });
const second = client.call("dispose");
expect(workers).toHaveLength(1);
expect(workers[0]?.sent.map((request) => request.id)).toEqual([1, 2]);
workers[0]?.emitResponse({ id: 2, ok: true });
workers[0]?.emitResponse({
id: 1,
ok: true,
result: {
version: "0.1.0",
wasmBuild: "a",
supportsThreads: false,
supportsWasmFs: false
}
});
await expect(second).resolves.toBeUndefined();
await expect(first).resolves.toMatchObject({ wasmBuild: "a" });
});
it("rejects pending requests on worker failure and can create a fresh worker", async () => {
const workers: FakeWorker[] = [];
const client = new KdlWorkerClient(() => {
const worker = new FakeWorker();
workers.push(worker);
return worker;
});
const pending = client.init();
workers[0]?.emitError("boom");
await expect(pending).rejects.toMatchObject({
code: "KDL_WORKER_CRASHED"
});
expect(workers[0]?.terminated).toBe(true);
const restarted = client.init({ wasmBuild: "restart" });
expect(workers).toHaveLength(2);
workers[1]?.emitResponse({
id: 2,
ok: true,
result: {
version: "0.1.0",
wasmBuild: "restart",
supportsThreads: false,
supportsWasmFs: false
}
});
await expect(restarted).resolves.toMatchObject({ wasmBuild: "restart" });
});
});

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { CycleTimeResult, PathPlanResult, TrajectoryResult } from "../../src/kdl/types.js";
function pose(x: number, y: number, z: number) {
return {
position: [x, y, z] as [number, number, number],
quaternion: [0, 0, 0, 1] as [number, number, number, number]
};
}
function trajectory(overrides: Partial<TrajectoryResult> = {}): TrajectoryResult {
return {
ok: true,
motion: "MOVEJ",
duration: 1,
sampleTime: 0.5,
events: [],
diagnostics: [],
points: [
{
index: 0,
time: 0,
dt: 0,
s: 0,
sd: 0,
sdd: 0,
joints: [0, 0],
jointVelocity: [0, 0],
jointAcceleration: [0, 0],
flange: pose(0, 0, 0),
tcp: pose(0, 0, 0),
motion: "MOVEJ",
segmentId: "s1",
diagnostics: []
},
{
index: 1,
time: 1,
dt: 1,
s: 1,
sd: 0,
sdd: 0,
joints: [1, 2],
jointVelocity: [0, 0],
jointAcceleration: [0, 0],
flange: pose(1, 0, 0),
tcp: pose(1, 2, 0),
motion: "MOVEJ",
segmentId: "s1",
diagnostics: []
}
],
...overrides
};
}
async function createRuntime() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
return runtime;
}
describe("cycle-time, resample, and diagnostics utilities", () => {
it("estimates cycle time for a trajectory and a path plan", async () => {
const runtime = await createRuntime();
const singleResponse = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "estimateCycleTime",
payload: [trajectory()]
});
expect(singleResponse.result).toMatchObject({
ok: true,
motionTime: 1,
totalTime: 1,
segmentTimes: [
{
segmentId: "s1",
motion: "MOVEJ",
duration: 1
}
],
diagnostics: []
});
const path: PathPlanResult = {
ok: true,
duration: 3,
segments: [
trajectory(),
trajectory({
motion: "MOVEL",
duration: 2,
points: trajectory().points.map((point) => ({ ...point, motion: "MOVEL", segmentId: "s2" }))
})
],
points: [],
diagnostics: []
};
const pathResponse = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "estimateCycleTime",
payload: [path]
});
expect(pathResponse.result).toMatchObject({
ok: true,
motionTime: 3,
totalTime: 3,
segmentTimes: [
{ segmentId: "s1", motion: "MOVEJ", duration: 1 },
{ segmentId: "s2", motion: "MOVEL", duration: 2 }
]
});
});
it("resamples a trajectory with stable time and point ordering", async () => {
const runtime = await createRuntime();
const response = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "resampleTrajectory",
payload: [trajectory(), 0.25]
});
expect(response.ok).toBe(true);
const result = response.result as TrajectoryResult;
expect(result.sampleTime).toBe(0.25);
expect(result.points.map((point) => point.time)).toEqual([0, 0.25, 0.5, 0.75, 1]);
expect(result.points.map((point) => point.index)).toEqual([0, 1, 2, 3, 4]);
expect(result.points[2]?.joints).toEqual([0.5, 1]);
expect(result.points[2]?.tcp.position).toEqual([0.5, 1, 0]);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
severity: "info",
code: "KDL_TRAJECTORY_RESAMPLED"
})
);
});
it("keeps structured diagnostics for warning and error cases", async () => {
const runtime = await createRuntime();
const emptyResponse = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "resampleTrajectory",
payload: [
trajectory({
points: []
}),
0.1
]
});
expect(emptyResponse.result).toMatchObject({
diagnostics: [
{
severity: "warning",
code: "KDL_RESAMPLE_EMPTY_TRAJECTORY"
}
]
});
const invalidResponse = await dispatchKdlRpcRequest(runtime, {
id: 6,
method: "resampleTrajectory",
payload: [trajectory(), 0]
});
expect(invalidResponse).toMatchObject({
ok: false,
error: {
code: "KDL_INVALID_SAMPLE_TIME",
diagnostics: [
{
severity: "error",
code: "KDL_INVALID_SAMPLE_TIME"
}
]
}
});
});
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import type { TrapProfileResult, TrapSample } from "../../src/kdl/types.js";
async function createRuntime() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
return runtime;
}
function expectMonotonic(samples: TrapSample[]) {
for (let index = 1; index < samples.length; index += 1) {
expect(samples[index]!.time).toBeGreaterThan(samples[index - 1]!.time);
expect(samples[index]!.s).toBeGreaterThanOrEqual(samples[index - 1]!.s);
}
}
describe("trapezoid velocity profile API", () => {
it("creates a trapezoid profile when the path can reach max velocity", async () => {
const runtime = await createRuntime();
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "makeTrapProfile",
payload: [
2,
{
maxVelocity: 1,
maxAcceleration: 1,
sampleTime: 0.25
}
]
});
expect(response.ok).toBe(true);
const profile = response.result as TrapProfileResult;
expect(profile).toMatchObject({
ok: true,
type: "trapezoid",
length: 2,
duration: 3,
tAccel: 1,
tConst: 1,
tDecel: 1,
vPeak: 1,
diagnostics: []
});
expect(profile.samples[0]).toMatchObject({ index: 0, time: 0, s: 0 });
expect(profile.samples.at(-1)).toMatchObject({ time: 3, s: 1 });
expect(profile.samples.find((sample) => sample.time === 1)?.s).toBeCloseTo(0.25);
expect(profile.samples.find((sample) => sample.time === 1)?.sd).toBeCloseTo(0.5);
expectMonotonic(profile.samples);
});
it("falls back to a triangle profile for short paths", async () => {
const runtime = await createRuntime();
const response = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "makeTrapProfile",
payload: [
0.5,
{
maxVelocity: 2,
maxAcceleration: 1,
sampleTime: 0.1
}
]
});
expect(response.ok).toBe(true);
const profile = response.result as TrapProfileResult;
expect(profile.type).toBe("triangle");
expect(profile.tConst).toBe(0);
expect(profile.vPeak).toBeCloseTo(Math.sqrt(0.5));
expect(profile.duration).toBeCloseTo(2 * Math.sqrt(0.5));
expect(profile.samples[0]?.s).toBe(0);
expect(profile.samples.at(-1)?.s).toBe(1);
expect(profile.diagnostics).toMatchObject([
{
severity: "info",
code: "KDL_TRAP_TRIANGLE_PROFILE"
}
]);
expectMonotonic(profile.samples);
});
it("returns samples from sampleTrapProfile with strict endpoint samples", async () => {
const runtime = await createRuntime();
const response = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "sampleTrapProfile",
payload: [
1,
{
maxVelocity: 1,
maxAcceleration: 2,
sampleTime: 0.2
}
]
});
expect(response.ok).toBe(true);
const samples = response.result as TrapSample[];
expect(samples[0]).toMatchObject({ index: 0, time: 0, s: 0 });
expect(samples.at(-1)?.s).toBe(1);
expect(samples.at(-1)?.time).toBeCloseTo(1.5);
expectMonotonic(samples);
});
it("returns a structured diagnostic for invalid trap profile inputs", async () => {
const runtime = await createRuntime();
const response = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "makeTrapProfile",
payload: [
1,
{
maxVelocity: 0,
maxAcceleration: 1,
sampleTime: 0.01
}
]
});
expect(response).toMatchObject({
ok: false,
error: {
code: "KDL_INVALID_TRAP_PROFILE",
diagnostics: [
{
severity: "error",
code: "KDL_INVALID_TRAP_PROFILE"
}
]
}
});
});
});

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import { KdlStructuredError } from "../../src/kdl/rpc.js";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import { loadRobotFromUrdfModel } from "../../src/robot/urdfParser.js";
const SIMPLE_URDF = `
<robot name="simple6">
<link name="base_link"/>
<link name="link_1"/>
<link name="link_2"/>
<link name="tool0"/>
<joint name="joint_1" type="revolute">
<parent link="base_link"/>
<child link="link_1"/>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.14" upper="3.14" velocity="2.5" acceleration="5"/>
</joint>
<joint name="joint_2" type="prismatic">
<parent link="link_1"/>
<child link="link_2"/>
<origin xyz="0 0 0.2" rpy="0 0 1.57"/>
<axis xyz="1 0 0"/>
<limit lower="0" upper="0.4" velocity="0.3" acceleration="1.2"/>
</joint>
<joint name="tool_fixed" type="fixed">
<parent link="link_2"/>
<child link="tool0"/>
<origin xyz="0 0 0.05" rpy="0 0 0"/>
</joint>
</robot>
`;
describe("URDF to NormalizedRobotModel", () => {
it("parses links, joints, origins, axes, limits, stable active joint names, and source hash", () => {
const model = loadRobotFromUrdfModel(SIMPLE_URDF, {
robotId: "r1",
baseLink: "base_link",
tipLink: "tool0"
});
expect(model).toMatchObject({
robotId: "r1",
name: "simple6",
baseLink: "base_link",
tipLink: "tool0",
activeJointNames: ["joint_1", "joint_2"],
source: { type: "urdf" }
});
expect(model.source.urdfHash).toHaveLength(64);
expect(model.links.map((link) => link.name)).toEqual(["base_link", "link_1", "link_2", "tool0"]);
expect(model.joints[0]).toMatchObject({
name: "joint_1",
type: "revolute",
parent: "base_link",
child: "link_1",
origin: { xyz: [0, 0, 0.1], rpy: [0, 0, 0] },
axis: [0, 0, 1]
});
expect(model.limits).toEqual([
{ name: "joint_1", lower: -3.14, upper: 3.14, velocity: 2.5, acceleration: 5 },
{ name: "joint_2", lower: 0, upper: 0.4, velocity: 0.3, acceleration: 1.2 }
]);
});
it("applies joint order and limit overrides", () => {
const model = loadRobotFromUrdfModel(SIMPLE_URDF, {
robotId: "r1",
baseLink: "base_link",
tipLink: "tool0",
jointOrder: ["joint_2", "joint_1"],
overrideLimits: [{ name: "joint_2", velocity: 0.2 }]
});
expect(model.activeJointNames).toEqual(["joint_2", "joint_1"]);
expect(model.limits[0]).toMatchObject({ name: "joint_2", velocity: 0.2 });
});
it("returns structured diagnostics for disconnected base and tip links", () => {
let thrown: unknown;
try {
loadRobotFromUrdfModel(SIMPLE_URDF, {
robotId: "r1",
baseLink: "tool0",
tipLink: "base_link"
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(KdlStructuredError);
expect(thrown).toMatchObject({
code: "KDL_INVALID_MODEL",
diagnostics: [
{
severity: "error",
code: "KDL_INVALID_MODEL"
}
]
});
});
it("rejects unsupported joint types", () => {
const urdf = SIMPLE_URDF.replace('type="prismatic"', 'type="floating"');
let thrown: unknown;
try {
loadRobotFromUrdfModel(urdf, {
robotId: "r1",
baseLink: "base_link",
tipLink: "tool0"
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(KdlStructuredError);
expect(thrown).toMatchObject({
code: "KDL_INVALID_MODEL",
message: expect.stringContaining("Unsupported joint type")
});
});
it("supports RobotHandle lifecycle through the worker runtime", async () => {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const createResponse = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [
SIMPLE_URDF,
{
robotId: "r1",
baseLink: "base_link",
tipLink: "tool0"
}
]
});
expect(createResponse).toMatchObject({ ok: true, result: 1 });
const infoResponse = await dispatchKdlRpcRequest(runtime, {
id: 3,
method: "getRobotInfo",
payload: [1]
});
expect(infoResponse.result).toMatchObject({
handle: 1,
robotId: "r1",
name: "simple6",
dof: 2,
jointNames: ["joint_1", "joint_2"]
});
const limitsResponse = await dispatchKdlRpcRequest(runtime, {
id: 4,
method: "getJointLimits",
payload: [1]
});
expect(limitsResponse.result).toEqual([
{ name: "joint_1", lower: -3.14, upper: 3.14, velocity: 2.5, acceleration: 5 },
{ name: "joint_2", lower: 0, upper: 0.4, velocity: 0.3, acceleration: 1.2 }
]);
const destroyResponse = await dispatchKdlRpcRequest(runtime, {
id: 5,
method: "destroyRobot",
payload: [1]
});
expect(destroyResponse.ok).toBe(true);
const afterDestroy = await dispatchKdlRpcRequest(runtime, {
id: 6,
method: "getRobotInfo",
payload: [1]
});
expect(afterDestroy).toMatchObject({
ok: false,
error: { code: "KDL_INVALID_HANDLE" }
});
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { postProcessAllBrands } from "../../src/grl/post/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import { compileSemanticProgram } from "../../src/grl/semantic/index.js";
const PROGRAM = `language grl 0.1
module PostDemo
post_hint abb
const speed vj = joint(50 %)
const speed vl = linear(200 mm/s)
const zone z10 = z(10 mm)
target home = joint_target { joints: [0 deg] }
target pick = pose_target { pose: pose(500 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg) }
target mid = pose_target { pose: pose(550 mm, 50 mm, 0 mm, 0 deg, 0 deg, 0 deg) }
target place = pose_target { pose: pose(600 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg) }
proc main()
set_speed vl
set_zone z10
movej home speed vj zone fine
movel pick speed vl zone z10
movec via mid target place speed vl zone fine
io.do[1] = true
wait io.di[1] == true timeout 1 s
pulse io.do[2] duration 100 ms
alarm DONE "done"
end
end
`;
function postAll() {
const ir = compileSemanticProgram(parseGrl(PROGRAM), {
startJoints: [0],
sampleTime: 0.004
});
return postProcessAllBrands(ir);
}
describe("GRL multi-brand postprocessor", () => {
it("emits stable ABB, FANUC, and KUKA golden text", () => {
const result = postAll();
expect(result.outputs.abb.text).toBe(`MODULE PostDemo
PROC main()
MoveJ home,v50,fine,tool0;
MoveL pick,v200,z10,tool0;
MoveC mid,place,v200,fine,tool0;
SetDO io.do[1],TRUE;
WaitUntil io . di [ 1 ] == true;
PulseDO io.do[2],0.100;
! unsupported ALARM
ENDPROC
ENDMODULE`);
expect(result.outputs.fanuc.text).toBe(`/PROG MAIN
/MN
1: J home 50% FINE ;
2: L pick 200mm/sec CNT10 ;
3: C mid place 200mm/sec FINE ;
4: DO[1]=TRUE ;
5: WAIT (io . di [ 1 ] == true) ;
6: PULSE DO[2] 100ms ;
7: ! unsupported ALARM ;
/END`);
expect(result.outputs.kuka.text).toBe(`DEF Main()
PTP home Vel=50%
LIN pick Vel=0.200m/s C_DIS
CIRC mid, place Vel=0.200m/s
$OUT[1] = TRUE
WAIT FOR io . di [ 1 ] == true
PULSE $OUT[2] 0.100
! unsupported ALARM
END`);
});
it("reports unsupported semantics and ignored brand hints", () => {
const result = postAll();
expect(result.outputs.abb.filename).toBe("PostDemo.mod");
expect(result.outputs.fanuc.filename).toBe("PostDemo.ls");
expect(result.outputs.kuka.filename).toBe("PostDemo.src");
expect(result.report).toEqual([
expect.objectContaining({ brand: "abb", code: "GRL_POST_UNSUPPORTED", message: expect.stringContaining("ALARM") }),
expect.objectContaining({ brand: "fanuc", code: "GRL_POST_HINT_IGNORED" }),
expect.objectContaining({ brand: "fanuc", code: "GRL_POST_UNSUPPORTED", message: expect.stringContaining("ALARM") }),
expect.objectContaining({ brand: "kuka", code: "GRL_POST_HINT_IGNORED" }),
expect.objectContaining({ brand: "kuka", code: "GRL_POST_UNSUPPORTED", message: expect.stringContaining("ALARM") })
]);
});
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"types": ["node", "vitest/globals"],
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["kdl-wasm/web/tests/**/*.test.ts"],
globals: true
}
});

1741
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "kdl-work",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run --config kdl-wasm/web/vitest.config.ts",
"typecheck": "tsc -p kdl-wasm/web/tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "^22.15.30",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"dependencies": {
"fast-xml-parser": "^5.9.3"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,530 @@
# 通用机器人项目主要实施步骤
版本0.1
日期2026-06-27
来源:`/home/meswork/kdl_work/work/working1`
用途:作为阅读参考,概览项目从 KDL WASM 计算接口到 GRL 编译与后处理的主要实施步骤。
## 1. 实施主线
项目实施分两条主线推进:
1. KDL WASM 计算接口线
- 任务编号:`KW-001``KW-013`
- 对标:`KDL_WASM计算接口设计.md`
- 目标:提供稳定的 Worker API、机器人模型、运动学、轨迹规划、Path 验证、诊断和性能能力。
2. GRL 编程语法线
- 任务编号:`KW-100``KW-112`
- 对标:`通用机器人编程语法规范.md`
- 目标:实现 GRL lexer/parser、AST、语义检查、IR、Path/Operation、IO/wait、后处理和自动生成。
两条线的集成点是GRL 的 `movej/movel/movec/path/operation` 编译为 KDL WASM 的 `MoveJRequest/MoveLRequest/MoveCRequest/PathPlanRequest`,由 KDL WASM 返回轨迹和诊断。
## 2. 推荐工程结构
```text
/home/meswork/kdl_work/
orocos_kinematics_dynamics/
orocos_kdl/
kdl-wasm/
CMakeLists.txt
bindings/
kdl_c_api.cpp
kdl_embind.cpp
web/
src/
kdl/
kdlClient.ts
kdl.worker.ts
rpc.ts
types.ts
robot/
urdfParser.ts
normalizedRobotModel.ts
grl/
lexer/
parser/
ast/
semantic/
ir/
generator/
post/
abb/
fanuc/
kuka/
tests/
kdl/
grl/
integration/
post/
```
## 3. 阶段 1基础工程、Worker RPC 和 GRL 词法骨架
关联任务:`KW-001``KW-100``KW-101`
目标:
1. 建立 `kdl-wasm` wrapper 工程。
2. 使用 Emscripten 编译 Orocos KDL生成 `kdl.js``kdl.wasm``kdl.d.ts`
3. 建立 `KdlRpcRequest/KdlRpcResponse``KdlWorkerClient`
4. KDL WASM 只在 Worker 中运行。
5. 建立 GRL lexer支持注释、标识符、字符串、数字、单位和保留关键字。
6. 建立 GRL parser 和 AST 骨架,支持 `language grl 0.1``module``import`、顶层声明。
输入:
1. Orocos KDL 源码。
2. GRL 源文件。
输出:
1. KDL WASM 构建产物。
2. Worker RPC 基础 API。
3. GRL Tokens 和 AST。
验收重点:
1. `init()` 返回 `KdlRuntimeInfo`
2. Worker 请求有唯一 id。
3. 错误返回 `{ code, message, diagnostics }`
4. GRL 最小文件可解析。
5. AST 保留 source range、注释位置、单位原文和规范化值。
## 4. 阶段 2机器人模型、GRL 数据声明和共享类型
关联任务:`KW-002``KW-102`
目标:
1. TypeScript 解析 URDF XML。
2. 检查 link/joint 连通性和单位。
3. 生成 `NormalizedRobotModel`
4. WASM 根据标准模型构造 KDL `Tree/Chain`
5. 创建 `RobotHandle` 并缓存求解器。
6. GRL 支持 `const/var/persistent`、基础类型、机器人类型。
7. GRL 支持 `tool/frame/load/joint_target/pose_target/pose/poseq/robot_config/ext_axis/speed/zone/offset/offset_in`
输入:
1. `robot.urdf`
2. GRL 中的 tool/frame/target/speed/zone 声明。
输出:
1. `NormalizedRobotModel`
2. `RobotHandle`
3. `JointTarget`
4. `PoseTarget`
5. `SpeedSpec`
6. `ZoneSpec`
7. `OffsetSpec`
验收重点:
1. URDF joint 顺序稳定。
2. base/tip 不连通返回 `KDL_INVALID_MODEL`
3. `getRobotInfo/getJointLimits` 正确。
4. GRL target 能编译为 KDL 共享数据结构。
5. speed/zone 能编译为 `SpeedSpec/ZoneSpec`
## 5. 阶段 3KDL 基础运动学和 GRL 运动指令
关联任务:`KW-003``KW-006``KW-103`
目标:
1. 实现 KDL `fk``fkAllLinks`
2. 实现 KDL `ik``ikBatch`
3. 实现 KDL `jacobian``checkSingularity``checkJointLimits``checkReachability``checkReachabilityBatch``checkVelocityLimits`
4. 实现 KDL `normalizePose``composePose``inversePose``applyToolAndFrame``applyOffset`
5. GRL 支持 `movej/movel/movec``set_tool/set_frame/set_speed/set_zone`
6. GRL 运动指令编译为 `MotionInstruction`,再映射为 KDL request。
输入:
1. `RobotHandle`
2. `JointTarget`
3. `PoseTarget`
4. `tool/frame/speed/zone`
5. GRL 运动语句。
输出:
1. FK/TCP 位姿。
2. IK 关节解。
3. Jacobian 和奇异性诊断。
4. `MotionInstruction`
5. `MoveJRequest/MoveLRequest/MoveCRequest`
验收重点:
1. FK 与 golden 数据或原生 KDL 对比在容差内。
2. IK 后 FK 回代误差小于容差。
3. `ikBatch` 返回顺序与输入顺序一致。
4. 奇异点返回 `KDL_SINGULARITY` warning。
5. `offset``offset_in tool``offset_in frame` 结果正确。
6. GRL 编译到 IR 前能解析确定的 tool、frame、speed、zone。
## 6. 阶段 4梯形速度和三类基础运动轨迹
关联任务:`KW-007``KW-010`
目标:
1. 实现 `makeTrapProfile``sampleTrapProfile`
2. 实现 `planMoveJ`
3. 实现 `planMoveL`
4. 实现 `planMoveC`
MOVEJ 重点:
1. `joint_target` 直接作为 `qEnd`
2. `pose_target` 先 IK 得到 `qEnd`
3. 各关节同起同停。
4. 每个采样点 FK 输出 TCP。
MOVEL 重点:
1. 起点由 `startJoints` FK 得到。
2. 目标点应用 tool/frame/offset。
3. 生成 TCP 直线采样。
4. 逐点 IKseed 使用上一采样点关节。
MOVEC 重点:
1. 起点由当前关节 FK 得到。
2. via 和 target 应用 tool/frame/offset。
3. 检查三点重合或近似共线。
4. 计算圆心、半径、法向、角度、弧长。
5. `TrajectoryResult.meta.circle` 包含 `CirclePlanMeta`
输入:
1. `MoveJRequest`
2. `MoveLRequest`
3. `MoveCRequest`
4. `TrapProfileOptions`
输出:
1. `TrapProfileResult`
2. `TrajectoryResult`
3. `MotionDiagnostic`
验收重点:
1. 梯形速度曲线长距离为 trapezoid短距离为 triangle。
2. 采样首点 `s=0`,末点 `s=1``s` 单调递增。
3. MOVEJ 关节同起同停。
4. MOVEL TCP 直线误差小于容差。
5. MOVEC 圆弧元数据正确。
6. 三点共线返回 `KDL_ARC_DEGENERATE`
7. P0 中非 fine zone 返回 `KDL_ZONE_APPROXIMATED`
## 7. 阶段 5Path、Operation 和批量路径验证
关联任务:`KW-011``KW-104``KW-105`
目标:
1. GRL 支持 `path/defaults/source/point/event/run_path`
2. GRL 支持 `operation/kind/path/process/start_action/end_action/run_operation`
3. Path 编译为 `PathPlanRequest`
4. Operation 展开为 start action + path + end action。
5. KDL 实现 `planPath``validatePath`
输入:
1. GRL Path。
2. GRL Operation。
3. `MotionSegmentRequest[]`
4. `PathPlanRequest`
输出:
1. `PathPlanResult`
2. `PathValidationResult`
3. Path source map。
4. Operation 展开结果。
验收重点:
1. 空 Path 报错。
2. 重复 point 名称报错。
3. `run_path` 可生成 `PathPlanRequest`
4. `planPath` 按 segment 顺序规划。
5. 上一段终点关节作为下一段起点。
6. 轨迹点合并后重新编号和更新时间。
7. 保留 `segmentId/targetId/sourceMap`
8. `run_operation` 展开后 KDL 只处理 motion segment。
## 8. 阶段 6IO、wait、pulse 和流程控制
关联任务:`KW-106``KW-107`
目标:
1. 支持 `io.di/do/ai/ao/gi/go/ri/ro`
2. 支持 `io.alias.*`
3. 支持 IO 赋值。
4. 支持 `wait` 条件、`timeout``on_timeout alarm/call`
5. 支持 `all/any/rising/falling/changed`
6. 支持 `pulse`
7. 支持 `if/elseif/else``while``for``switch/case/default`
8. 支持 `break/continue/label/jump`
输入:
1. IO map。
2. GRL IO/wait/pulse 语句。
3. GRL 流程控制语句。
输出:
1. `IoInstruction`
2. `WaitInstruction`
3. `BranchInstruction`
4. pulse IR。
验收重点:
1. IO 地址按 io_map 或允许范围校验。
2. wait 条件可编译。
3. pulse trace 必须包含置位和复位事件。
4. 条件表达式必须为 bool。
5. `break/continue` 位置合法。
6. `switch case` 为常量表达式且不重复。
7. `jump` 不能跳入非法块结构。
8. IO、wait、pulse 和流程控制不直接进入 KDL。
## 9. 阶段 7proc、func、异常、报警、中断和多任务语法
关联任务:`KW-108``KW-109`
目标:
1. 支持 `proc``func``call``return`
2. 支持参数方向 `in/out/inout`
3. 实现作用域和名称解析。
4. 对递归给出 warning 或 error。
5. 支持 `alarm``raise``try/catch/finally`
6. P1 语法保留 `trap/interrupt/enable/disable/task cycle`
输入:
1. GRL 子程序和函数。
2. GRL 异常和中断语法。
输出:
1. `CallInstruction`
2. `ReturnInstruction`
3. `AlarmInstruction`
4. 异常处理 IR。
5. P1 语法 AST。
验收重点:
1. `out` 参数所有正常返回路径赋值。
2. `inout/out` 实参必须为左值。
3. `func` 所有正常返回路径返回兼容类型。
4. `func` 默认不允许执行运动、wait、pulse、run_path、run_operation。
5. P0 支持 alarm/raise/try/catch 基础语义。
6. P1 未实现语义必须在后处理或运行时报明确诊断。
## 10. 阶段 8语义检查、IR、source map 和 KDL 集成
关联任务:`KW-110``KW-012`
目标:
1. 实现 Symbol Table。
2. 实现 Semantic Analyzer。
3. 生成 Executable IR。
4. 保留 source map。
5. 完成 GRL 到 KDL request 的编译桥接。
6. KDL 实现 `estimateCycleTime``resampleTrajectory`
7. 建立统一诊断分级error、warning、info。
必须检查:
1. 标识符重复或未声明。
2. 类型是否匹配。
3. 目标点类型是否适合运动指令。
4. `movec` 是否缺少 via 点。
5. 圆弧三点是否重合或共线。
6. 工具、坐标系、速度、过渡是否可解析。
7. 单位是否正确。
8. IO 地址是否存在。
9. 子程序参数数量和类型是否匹配。
10. `out` 参数是否赋值。
11. `func` 返回路径是否正确。
12. `break/continue/jump` 是否合法。
13. Path 是否为空或点名重复。
14. Operation 是否引用不存在的 Path。
15. 目标点是否可达。
16. 关节是否超限。
17. 后处理目标品牌是否支持所用语义。
输入:
1. AST。
2. Symbol Table。
3. `RobotHandle`
4. KDL 检查结果。
输出:
1. Executable IR。
2. KDL request。
3. `CycleTimeResult`
4. `MotionDiagnostic`
验收重点:
1. 完整 GRL 示例可编译为 IR。
2. IR 运动指令可映射到 KDL request。
3. source map 能定位 GRL 行列、path point、operation。
4. `estimateCycleTime` 只计算运动时间。
5. `resampleTrajectory` 时间和点序稳定。
6. 所有错误返回结构化诊断。
## 11. 阶段 9三品牌后处理原型
关联任务:`KW-111`
目标:
1. ABB RAPID 后处理。
2. FANUC LS/TP 风格后处理。
3. KUKA KRL 后处理。
4. 支持 `post_hint``@brand.*`
5. 生成后处理转换报告。
输入:
1. Executable IR。
2. target/tool/frame/speed/zone 数据。
3. post profile。
4. brand metadata。
输出:
1. ABB RAPID 程序。
2. FANUC LS/TP 风格文本。
3. KUKA KRL 程序。
4. 转换报告。
验收重点:
1. `movej/movel/movec` 三品牌 golden file 通过。
2. target/tool/frame/speed/zone 映射正确。
3. IO/wait 基础映射正确。
4. `post_hint``@brand.*` 只影响指定品牌。
5. 不支持语义进入转换报告,不能静默丢失。
## 12. 阶段 10自动生成、往返和性能优化
关联任务:`KW-112``KW-013`
目标:
1. 自动生成 GRL 时优先生成 target/path/operation。
2. 点名稳定。
3. path defaults 和单点 override 稳定。
4. source metadata 稳定。
5. 支持 compact/expanded 输出风格。
6. 生成 GRL 可再解析回等价对象。
7. KDL 底层导出稳定 C ABI。
8. 高频 FK/IK 增加 TypedArray 版本。
9. RobotHandle 缓存 FK、IK、Jacobian solver。
10. 长路径分块计算或提供进度。
输入:
1. 自动编程对象。
2. Path/Operation 数据。
3. KDL 批量计算输入。
输出:
1. 稳定 GRL 文本。
2. 可回读 AST/IR。
3. C ABI / Embind API。
4. TypedArray 批量接口。
5. 性能报告。
验收重点:
1. 同一输入重复生成结果一致。
2. 生成文本可 diff。
3. 生成文本可解析、语义检查并后处理。
4. 单机器人 6 轴初始化小于 1 秒。
5. 单次 FK 小于 1 ms。
6. 单次 IK 平均小于 10 ms。
7. 1000 个目标点批量可达性检查在可接受交互时间内完成。
8. 10 秒轨迹按 4 ms 采样约 2500 点可稳定生成和回放。
## 13. 推荐集成顺序
```text
1. KW-001 + KW-100 + KW-101
基础工程、Worker RPC、Lexer/Parser 骨架。
2. KW-002 + KW-102
URDF/标准模型和 GRL target/tool/frame/speed/zone。
3. KW-003 到 KW-006 + KW-103
运动指令编译到 KDL FK/IK/变换。
4. KW-007 到 KW-010
梯形速度、MOVEJ、MOVEL、MOVEC。
5. KW-104 + KW-011
Path 编译为 PathPlanRequestKDL 生成整条路径。
6. KW-105 + KW-011
Operation 展开后复用 Path 规划。
7. KW-106 到 KW-110
完成 P0 语义检查和 IR。
8. KW-111
三品牌后处理原型。
9. KW-112 + KW-013
自动生成、往返、性能和批量优化。
```
## 14. 常用验证命令
实际命令以工程 `package.json` 和 CMake 配置为准。每个阶段至少应提供等效命令:
```bash
npm run typecheck
npm run test -- grl
npm run test -- kdl
npm run test -- integration
npm run test -- post
npm run build
```
KDL WASM 构建:
```bash
cd /home/meswork/kdl_work
emcmake cmake -S kdl-wasm -B kdl-wasm/build-wasm \
-DCMAKE_BUILD_TYPE=Release \
-DKDL_SOURCE_DIR=/home/meswork/kdl_work/orocos_kinematics_dynamics/orocos_kdl
cmake --build kdl-wasm/build-wasm -j16
```
## 15. 阅读建议
1. 先读本文,理解项目主要阶段和集成顺序。
2. 再读 `通用机器人项目功能与数据流程图.md`,理解功能流和数据流。
3. 需要接口细节时读 `KDL_WASM计算接口设计.md`
4. 需要语言语法和后处理细节时读 `通用机器人编程语法规范.md`
5. 需要执行级任务和证据时读 `/home/meswork/kdl_work/work/working1` 下的实施文档。

View File

@@ -0,0 +1,58 @@
flowchart TD
A[项目资源] --> A1[robots/robot.urdf]
A --> A2[targets/*.json]
A --> A3[paths/*.json]
A --> A4[operations/*.json]
A --> A5[programs/*.grl]
A --> A6[io/io_map.json]
A --> A7[post/*.profile.json]
A5 --> B[GRL Lexer]
B --> C[GRL Parser]
C --> D[GRL AST]
D --> E[Symbol Table]
E --> F[Semantic Analyzer]
A1 --> G[URDF Parser]
G --> H[NormalizedRobotModel]
H --> I[KDL createRobotFromModel]
I --> J[RobotHandle]
F --> K[Executable IR]
K --> L{IR 指令类型}
L -->|MotionInstruction| M[生成 MotionSegmentRequest]
L -->|run_path| N[展开 Path points/events]
L -->|run_operation| O[展开 start_action + path + end_action]
L -->|IO / wait / pulse| P[虚拟控制器 IO Service]
L -->|if/for/switch/call/return| Q[虚拟控制器流程执行]
L -->|alarm / raise / try/catch| R[虚拟控制器报警与异常处理]
N --> M
O --> N
M --> S[KdlWorkerClient]
S --> T[kdl.worker.ts]
T --> U[KDL WASM API]
U --> U1[planMoveJ]
U --> U2[planMoveL]
U --> U3[planMoveC]
U --> U4[planPath]
U --> U5[validatePath]
U1 --> V[TrajectoryResult]
U2 --> V
U3 --> V
U4 --> W[PathPlanResult]
U5 --> X[PathValidationResult]
V --> Y[Motion Queue / 轨迹回放]
W --> Y
X --> Z[可达性与诊断报告]
K --> AA[Post Processor]
AA --> AA1[ABB RAPID]
AA --> AA2[FANUC LS/TP 风格文本]
AA --> AA3[KUKA KRL]
AA --> AA4[转换报告]

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

View File

@@ -0,0 +1,42 @@
flowchart TD
A[GRL Source .grl] --> B[Lexer]
B --> C[Tokens]
C --> D[Parser]
D --> E[AST]
E --> F[Symbol Table]
F --> G[Semantic Analyzer]
G --> G1[名称解析]
G --> G2[类型检查]
G --> G3[单位规范化]
G --> G4[tool/frame/speed/zone 解析]
G --> G5[Path/Operation 引用检查]
G --> G6[IO 地址检查]
G --> G7[运动目标可达性检查]
G --> G8[后处理能力检查]
G1 --> H[Executable IR]
G2 --> H
G3 --> H
G4 --> H
G5 --> H
G6 --> H
G7 --> H
G8 --> H
H --> I{IR}
I -->|MotionInstruction| J[Motion Request Builder]
I -->|WaitInstruction| K[Wait Registry]
I -->|IoInstruction| L[IO Image]
I -->|BranchInstruction| M[Program Counter]
I -->|CallInstruction| N[Call Stack]
I -->|AlarmInstruction| O[Alarm Queue]
I -->|ReturnInstruction| P[Scope/Call Stack]
J --> Q[KDL WASM]
Q --> R[TrajectoryResult / Diagnostics]
R --> S[Motion Queue]
H --> T[Post Processor]
T --> U[品牌程序]
T --> V[转换报告]

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

View File

@@ -0,0 +1,48 @@
flowchart TD
A[NormalizedRobotModel] --> B[createRobotFromModel]
B --> C[RobotHandle]
C --> D[KDL Chain / Solvers Cache]
E[Motion Request] --> F{motion}
F -->|MOVEJ| G[planMoveJ]
F -->|MOVEL| H[planMoveL]
F -->|MOVEC| I[planMoveC]
F -->|PATH| J[planPath / validatePath]
G --> G1[校验 startJoints 和限位]
G1 --> G2{target 类型}
G2 -->|joint_target| G3[qEnd = target.joints]
G2 -->|pose_target| G4[IK 求 qEnd]
G3 --> G5[关节差分]
G4 --> G5
G5 --> G6[梯形速度曲线]
G6 --> G7[采样关节位置/速度/加速度]
G7 --> G8[FK 输出 TCP]
G8 --> R[TrajectoryResult]
H --> H1[FK 得到起点 TCP]
H1 --> H2[applyToolAndFrame / applyOffset]
H2 --> H3[直线位置和姿态插补]
H3 --> H4[梯形速度曲线]
H4 --> H5[逐点 IK]
H5 --> H6[限位/速度/奇异性检查]
H6 --> R
I --> I1[FK 得到起点 TCP]
I1 --> I2[via/target 位姿变换]
I2 --> I3[三点退化检查]
I3 --> I4[圆心/半径/法向/弧长]
I4 --> I5[圆弧采样]
I5 --> I6[逐点 IK]
I6 --> I7[圆弧误差和限位检查]
I7 --> R
J --> J1[按 segment 顺序规划]
J1 --> J2[上一段终点关节作为下一段起点]
J2 --> J3[合并轨迹点和诊断]
J3 --> J4[保留 sourceMap]
J4 --> P[PathPlanResult / PathValidationResult]
R --> D1[MotionDiagnostic]
P --> D1
D1 --> D2[error / warning / info]

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

View File

@@ -0,0 +1,92 @@
flowchart LR
subgraph Project[项目输入数据]
A1[robot.urdf]
A2[main.grl]
A3[targets / paths / operations]
A4[io_map.json]
A5[post profile]
end
subgraph Compile[TypeScript 编译层]
B1[URDF Parser]
B2[GRL Lexer/Parser]
B3[AST]
B4[Symbol Table]
B5[Semantic Analyzer]
B6[Executable IR]
B7[Motion Request Builder]
end
subgraph KDL[KDL Worker / WASM]
C1[KdlRpcRequest]
C2[RobotHandle]
C3[KDL Solvers]
C4[FK / IK / Jacobian]
C5[Trap Profile]
C6[Motion Planner]
C7[KdlRpcResponse]
end
subgraph Runtime[虚拟控制器运行层]
D1[Program Counter]
D2[Call Stack]
D3[Scope Stack]
D4[Motion Queue]
D5[IO Image]
D6[Wait Registry]
D7[Alarm Queue]
D8[Trace Buffer]
end
subgraph Output[输出数据]
E1[TrajectoryResult]
E2[PathPlanResult]
E3[PathValidationResult]
E4[MotionDiagnostic]
E5[CycleTimeResult]
E6[ABB/FANUC/KUKA 程序]
E7[转换报告]
end
A1 --> B1
B1 -->|NormalizedRobotModel| C1
C1 --> C2
C2 --> C3
A2 --> B2
A3 --> B5
A4 --> B5
B2 --> B3
B3 --> B4
B4 --> B5
B5 --> B6
B6 -->|MotionInstruction| B7
B7 -->|MoveJRequest / MoveLRequest / MoveCRequest / PathPlanRequest| C1
C1 --> C4
C1 --> C5
C4 --> C6
C5 --> C6
C6 --> C7
C7 --> E1
C7 --> E2
C7 --> E3
C7 --> E4
C7 --> E5
B6 --> D1
B6 --> D2
B6 --> D3
E1 --> D4
E2 --> D4
B6 -->|IO / wait / pulse| D5
B6 -->|wait| D6
E4 --> D7
D4 --> D8
D5 --> D8
D6 --> D8
B6 -->|IR + post profile| A5
A5 --> E6
A5 --> E7

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

View File

@@ -0,0 +1,16 @@
flowchart TD
A[GRL Parser] -->|语法错误| D[Diagnostic]
B[Semantic Analyzer] -->|类型/单位/引用/IO/后处理错误| D
C[KDL WASM] -->|IK/限位/奇异/轨迹错误| D
E[Post Processor] -->|不支持或近似转换| D
D --> F{severity}
F -->|error| G[阻止编译或进入 alarm/hold]
F -->|warning| H[允许继续但写入报告]
F -->|info| I[写入 trace 或调试信息]
D --> J[sourceMap]
J --> J1[GRL file/line/column]
J --> J2[pathId/pathPointId]
J --> J3[operationId]
J --> J4[brandSource]

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View File

@@ -0,0 +1,357 @@
# 通用机器人项目功能与数据流程图
版本0.1
日期2026-06-27
对标文档:
1. `通用机器人编程语法规范.md`
2. `KDL_WASM计算接口设计.md`
## 1. 范围说明
本文描述 GRL 通用机器人程序语言和 KDL WASM 计算接口之间的总体功能流程和数据传递流程。
核心边界:
1. GRL 层负责程序文本、语法、语义、IR、Path、Operation、IO、wait、后处理。
2. KDL WASM 层负责机器人模型、位姿变换、FK、IK、Jacobian、梯形速度、MOVEJ、MOVEL、MOVEC、Path 轨迹和诊断。
3. KDL WASM 不解析 GRL不执行流程控制、变量、IO、wait、子程序和异常逻辑。
4. TypeScript 编译器和虚拟控制器把 GRL/IR 转换为 KDL 可执行的运动请求。
## 2. 总体功能流程图
```mermaid
flowchart TD
A[项目资源] --> A1[robots/robot.urdf]
A --> A2[targets/*.json]
A --> A3[paths/*.json]
A --> A4[operations/*.json]
A --> A5[programs/*.grl]
A --> A6[io/io_map.json]
A --> A7[post/*.profile.json]
A5 --> B[GRL Lexer]
B --> C[GRL Parser]
C --> D[GRL AST]
D --> E[Symbol Table]
E --> F[Semantic Analyzer]
A1 --> G[URDF Parser]
G --> H[NormalizedRobotModel]
H --> I[KDL createRobotFromModel]
I --> J[RobotHandle]
F --> K[Executable IR]
K --> L{IR 指令类型}
L -->|MotionInstruction| M[生成 MotionSegmentRequest]
L -->|run_path| N[展开 Path points/events]
L -->|run_operation| O[展开 start_action + path + end_action]
L -->|IO / wait / pulse| P[虚拟控制器 IO Service]
L -->|if/for/switch/call/return| Q[虚拟控制器流程执行]
L -->|alarm / raise / try/catch| R[虚拟控制器报警与异常处理]
N --> M
O --> N
M --> S[KdlWorkerClient]
S --> T[kdl.worker.ts]
T --> U[KDL WASM API]
U --> U1[planMoveJ]
U --> U2[planMoveL]
U --> U3[planMoveC]
U --> U4[planPath]
U --> U5[validatePath]
U1 --> V[TrajectoryResult]
U2 --> V
U3 --> V
U4 --> W[PathPlanResult]
U5 --> X[PathValidationResult]
V --> Y[Motion Queue / 轨迹回放]
W --> Y
X --> Z[可达性与诊断报告]
K --> AA[Post Processor]
AA --> AA1[ABB RAPID]
AA --> AA2[FANUC LS/TP 风格文本]
AA --> AA3[KUKA KRL]
AA --> AA4[转换报告]
```
## 3. GRL 编译与执行流程图
```mermaid
flowchart TD
A[GRL Source .grl] --> B[Lexer]
B --> C[Tokens]
C --> D[Parser]
D --> E[AST]
E --> F[Symbol Table]
F --> G[Semantic Analyzer]
G --> G1[名称解析]
G --> G2[类型检查]
G --> G3[单位规范化]
G --> G4[tool/frame/speed/zone 解析]
G --> G5[Path/Operation 引用检查]
G --> G6[IO 地址检查]
G --> G7[运动目标可达性检查]
G --> G8[后处理能力检查]
G1 --> H[Executable IR]
G2 --> H
G3 --> H
G4 --> H
G5 --> H
G6 --> H
G7 --> H
G8 --> H
H --> I{IR}
I -->|MotionInstruction| J[Motion Request Builder]
I -->|WaitInstruction| K[Wait Registry]
I -->|IoInstruction| L[IO Image]
I -->|BranchInstruction| M[Program Counter]
I -->|CallInstruction| N[Call Stack]
I -->|AlarmInstruction| O[Alarm Queue]
I -->|ReturnInstruction| P[Scope/Call Stack]
J --> Q[KDL WASM]
Q --> R[TrajectoryResult / Diagnostics]
R --> S[Motion Queue]
H --> T[Post Processor]
T --> U[品牌程序]
T --> V[转换报告]
```
## 4. KDL WASM 计算流程图
```mermaid
flowchart TD
A[NormalizedRobotModel] --> B[createRobotFromModel]
B --> C[RobotHandle]
C --> D[KDL Chain / Solvers Cache]
E[Motion Request] --> F{motion}
F -->|MOVEJ| G[planMoveJ]
F -->|MOVEL| H[planMoveL]
F -->|MOVEC| I[planMoveC]
F -->|PATH| J[planPath / validatePath]
G --> G1[校验 startJoints 和限位]
G1 --> G2{target 类型}
G2 -->|joint_target| G3[qEnd = target.joints]
G2 -->|pose_target| G4[IK 求 qEnd]
G3 --> G5[关节差分]
G4 --> G5
G5 --> G6[梯形速度曲线]
G6 --> G7[采样关节位置/速度/加速度]
G7 --> G8[FK 输出 TCP]
G8 --> R[TrajectoryResult]
H --> H1[FK 得到起点 TCP]
H1 --> H2[applyToolAndFrame / applyOffset]
H2 --> H3[直线位置和姿态插补]
H3 --> H4[梯形速度曲线]
H4 --> H5[逐点 IK]
H5 --> H6[限位/速度/奇异性检查]
H6 --> R
I --> I1[FK 得到起点 TCP]
I1 --> I2[via/target 位姿变换]
I2 --> I3[三点退化检查]
I3 --> I4[圆心/半径/法向/弧长]
I4 --> I5[圆弧采样]
I5 --> I6[逐点 IK]
I6 --> I7[圆弧误差和限位检查]
I7 --> R
J --> J1[按 segment 顺序规划]
J1 --> J2[上一段终点关节作为下一段起点]
J2 --> J3[合并轨迹点和诊断]
J3 --> J4[保留 sourceMap]
J4 --> P[PathPlanResult / PathValidationResult]
R --> D1[MotionDiagnostic]
P --> D1
D1 --> D2[error / warning / info]
```
## 5. 数据传递流程图
```mermaid
flowchart LR
subgraph Project[项目输入数据]
A1[robot.urdf]
A2[main.grl]
A3[targets / paths / operations]
A4[io_map.json]
A5[post profile]
end
subgraph Compile[TypeScript 编译层]
B1[URDF Parser]
B2[GRL Lexer/Parser]
B3[AST]
B4[Symbol Table]
B5[Semantic Analyzer]
B6[Executable IR]
B7[Motion Request Builder]
end
subgraph KDL[KDL Worker / WASM]
C1[KdlRpcRequest]
C2[RobotHandle]
C3[KDL Solvers]
C4[FK / IK / Jacobian]
C5[Trap Profile]
C6[Motion Planner]
C7[KdlRpcResponse]
end
subgraph Runtime[虚拟控制器运行层]
D1[Program Counter]
D2[Call Stack]
D3[Scope Stack]
D4[Motion Queue]
D5[IO Image]
D6[Wait Registry]
D7[Alarm Queue]
D8[Trace Buffer]
end
subgraph Output[输出数据]
E1[TrajectoryResult]
E2[PathPlanResult]
E3[PathValidationResult]
E4[MotionDiagnostic]
E5[CycleTimeResult]
E6[ABB/FANUC/KUKA 程序]
E7[转换报告]
end
A1 --> B1
B1 -->|NormalizedRobotModel| C1
C1 --> C2
C2 --> C3
A2 --> B2
A3 --> B5
A4 --> B5
B2 --> B3
B3 --> B4
B4 --> B5
B5 --> B6
B6 -->|MotionInstruction| B7
B7 -->|MoveJRequest / MoveLRequest / MoveCRequest / PathPlanRequest| C1
C1 --> C4
C1 --> C5
C4 --> C6
C5 --> C6
C6 --> C7
C7 --> E1
C7 --> E2
C7 --> E3
C7 --> E4
C7 --> E5
B6 --> D1
B6 --> D2
B6 --> D3
E1 --> D4
E2 --> D4
B6 -->|IO / wait / pulse| D5
B6 -->|wait| D6
E4 --> D7
D4 --> D8
D5 --> D8
D6 --> D8
B6 -->|IR + post profile| A5
A5 --> E6
A5 --> E7
```
## 6. 关键对象数据流
| 输入对象 | 产生阶段 | 传递到 | 输出对象 |
| --- | --- | --- | --- |
| `robot.urdf` | 项目资源 | TypeScript URDF Parser | `NormalizedRobotModel` |
| `NormalizedRobotModel` | TypeScript 编译层 | `createRobotFromModel` | `RobotHandle` |
| `tool/frame/target/speed/zone` | GRL Parser + Semantic Analyzer | IR、KDL request builder | `ToolRef``FrameRef``JointTarget``PoseTarget``SpeedSpec``ZoneSpec` |
| `movej` | GRL Parser | Semantic Analyzer | `MotionInstruction(joint)` |
| `movel` | GRL Parser | Semantic Analyzer | `MotionInstruction(linear)` |
| `movec` | GRL Parser | Semantic Analyzer | `MotionInstruction(circular)` |
| `path` | GRL Parser | Path compiler | `MotionSegmentRequest[]` |
| `operation` | GRL Parser | Operation compiler | start action + path + end action |
| `MotionInstruction` | IR | KDL request builder | `MoveJRequest``MoveLRequest``MoveCRequest` |
| `PathPlanRequest` | Path compiler | KDL WASM | `PathPlanResult` |
| `TrajectoryResult` | KDL WASM | Motion Queue、trace、报告 | 轨迹点、诊断、节拍输入 |
| `MotionDiagnostic` | GRL 语义检查或 KDL WASM | 编辑器、报警、报告、后处理 | error/warning/info |
| `Executable IR` | Semantic Analyzer | 虚拟控制器、后处理器 | 执行流、品牌程序 |
## 7. KDL API 与 GRL 语法映射
| GRL 语法 | TypeScript 编译结果 | KDL WASM 函数 |
| --- | --- | --- |
| `target home = joint_target` | `JointTarget` | `checkJointLimits` |
| `target pick = pose_target` | `PoseTarget` | `checkReachability` |
| `pick offset z 100 mm` | `OffsetSpec` | `applyOffset` |
| `movej home` | `MoveJRequest` | `planMoveJ` |
| `movel pick` | `MoveLRequest` | `planMoveL` |
| `movec via mid target end` | `MoveCRequest` | `planMoveC` |
| `run_path pick_path` | `PathPlanRequest` | `planPath` |
| Path 可达性检查 | `PathPlanRequest` | `validatePath` |
| 节拍估算 | `TrajectoryResult/PathPlanResult` | `estimateCycleTime` |
## 8. 不进入 KDL WASM 的数据流
以下 GRL 语义由 TypeScript 编译层或虚拟控制器执行,不传入 KDL WASM
| GRL 语义 | 执行位置 | 输出 |
| --- | --- | --- |
| `if/elseif/else` | Program Counter / BranchInstruction | 分支后的 IR 执行位置 |
| `while/for/switch` | Program Counter / BranchInstruction | 循环或选择后的 IR 执行位置 |
| `proc/func/call/return` | Call Stack / Scope Stack | 调用栈和变量作用域 |
| `io.do/di/ai/ao` | IO Image / IO Service | IO 事件和 trace |
| `wait/pulse/timer` | Wait Registry / IO Service | wait 状态、timeout、pulse trace |
| `alarm/raise/try/catch` | Alarm Queue / Exception Handler | 报警和异常处理结果 |
| `operation.process` | Operation compiler / 后处理器 | 工艺参数、转换报告 |
## 9. 诊断传递流程
```mermaid
flowchart TD
A[GRL Parser] -->|语法错误| D[Diagnostic]
B[Semantic Analyzer] -->|类型/单位/引用/IO/后处理错误| D
C[KDL WASM] -->|IK/限位/奇异/轨迹错误| D
E[Post Processor] -->|不支持或近似转换| D
D --> F{severity}
F -->|error| G[阻止编译或进入 alarm/hold]
F -->|warning| H[允许继续但写入报告]
F -->|info| I[写入 trace 或调试信息]
D --> J[sourceMap]
J --> J1[GRL file/line/column]
J --> J2[pathId/pathPointId]
J --> J3[operationId]
J --> J4[brandSource]
```
## 10. 总结
项目的数据流以 GRL IR 为中枢:
1. GRL 文本、Path、Operation、IO 和品牌扩展先进入 TypeScript 编译层。
2. 编译层完成语法、语义、单位、source map 和后处理能力检查。
3. 只有运动相关 IR 被转换为 KDL WASM request。
4. KDL WASM 返回轨迹、节拍和结构化诊断。
5. 虚拟控制器消费 IR 和轨迹,后处理器消费 IR 和品牌 profile。
6. 诊断贯穿 Parser、Semantic Analyzer、KDL WASM 和 Post Processor并通过 source map 回到 GRL、Path、Operation 或品牌源。