同步KDL工程源码到云仓库
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
kdl-wasm/build*/
|
||||
kdl_install/
|
||||
work/working1/
|
||||
git.txt
|
||||
*.log
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal 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
76
kdl-wasm/CMakeLists.txt
Normal 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
26
kdl-wasm/bindings/kdl.d.ts
vendored
Normal 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>;
|
||||
731
kdl-wasm/bindings/kdl_c_api.cpp
Normal file
731
kdl-wasm/bindings/kdl_c_api.cpp
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
239
kdl-wasm/web/src/grl/ast/ast.ts
Normal file
239
kdl-wasm/web/src/grl/ast/ast.ts
Normal 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;
|
||||
37
kdl-wasm/web/src/grl/ast/index.ts
Normal file
37
kdl-wasm/web/src/grl/ast/index.ts
Normal 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";
|
||||
210
kdl-wasm/web/src/grl/generator/generator.ts
Normal file
210
kdl-wasm/web/src/grl/generator/generator.ts
Normal 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);
|
||||
}
|
||||
10
kdl-wasm/web/src/grl/generator/index.ts
Normal file
10
kdl-wasm/web/src/grl/generator/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
generateGrlProgram,
|
||||
type GeneratedGrlProgram,
|
||||
type GeneratedOperationSpec,
|
||||
type GeneratedPathPointSpec,
|
||||
type GeneratedPathSpec,
|
||||
type GeneratedTargetSpec,
|
||||
type GrlGeneratorStyle,
|
||||
type GrlProgramGenerationSpec
|
||||
} from "./generator.js";
|
||||
61
kdl-wasm/web/src/grl/ir/index.ts
Normal file
61
kdl-wasm/web/src/grl/ir/index.ts
Normal 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";
|
||||
428
kdl-wasm/web/src/grl/ir/motion.ts
Normal file
428
kdl-wasm/web/src/grl/ir/motion.ts
Normal 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;
|
||||
}
|
||||
15
kdl-wasm/web/src/grl/lexer/index.ts
Normal file
15
kdl-wasm/web/src/grl/lexer/index.ts
Normal 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";
|
||||
95
kdl-wasm/web/src/grl/lexer/keywords.ts
Normal file
95
kdl-wasm/web/src/grl/lexer/keywords.ts
Normal 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);
|
||||
}
|
||||
429
kdl-wasm/web/src/grl/lexer/lexer.ts
Normal file
429
kdl-wasm/web/src/grl/lexer/lexer.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
86
kdl-wasm/web/src/grl/lexer/tokens.ts
Normal file
86
kdl-wasm/web/src/grl/lexer/tokens.ts
Normal 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;
|
||||
59
kdl-wasm/web/src/grl/lexer/units.ts
Normal file
59
kdl-wasm/web/src/grl/lexer/units.ts
Normal 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;
|
||||
}
|
||||
11
kdl-wasm/web/src/grl/parser/errors.ts
Normal file
11
kdl-wasm/web/src/grl/parser/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
351
kdl-wasm/web/src/grl/parser/expressionParser.ts
Normal file
351
kdl-wasm/web/src/grl/parser/expressionParser.ts
Normal 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
|
||||
};
|
||||
}
|
||||
3
kdl-wasm/web/src/grl/parser/index.ts
Normal file
3
kdl-wasm/web/src/grl/parser/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { GrlParseError } from "./errors.js";
|
||||
export { parseGrl } from "./parser.js";
|
||||
export { parseGrlExpression } from "./expressionParser.js";
|
||||
859
kdl-wasm/web/src/grl/parser/parser.ts
Normal file
859
kdl-wasm/web/src/grl/parser/parser.ts
Normal 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]!;
|
||||
}
|
||||
}
|
||||
8
kdl-wasm/web/src/grl/post/index.ts
Normal file
8
kdl-wasm/web/src/grl/post/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
postProcessAllBrands,
|
||||
postProcessBrand,
|
||||
type MultiBrandPostResult,
|
||||
type PostBrand,
|
||||
type PostIssue,
|
||||
type PostResult
|
||||
} from "./postProcessor.js";
|
||||
232
kdl-wasm/web/src/grl/post/postProcessor.ts
Normal file
232
kdl-wasm/web/src/grl/post/postProcessor.ts
Normal 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`;
|
||||
}
|
||||
576
kdl-wasm/web/src/grl/semantic/compileControlFlow.ts
Normal file
576
kdl-wasm/web/src/grl/semantic/compileControlFlow.ts
Normal 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
|
||||
);
|
||||
}
|
||||
296
kdl-wasm/web/src/grl/semantic/compileData.ts
Normal file
296
kdl-wasm/web/src/grl/semantic/compileData.ts
Normal 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);
|
||||
}
|
||||
321
kdl-wasm/web/src/grl/semantic/compileException.ts
Normal file
321
kdl-wasm/web/src/grl/semantic/compileException.ts
Normal 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)]);
|
||||
}
|
||||
380
kdl-wasm/web/src/grl/semantic/compileIo.ts
Normal file
380
kdl-wasm/web/src/grl/semantic/compileIo.ts
Normal 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);
|
||||
}
|
||||
978
kdl-wasm/web/src/grl/semantic/compileMotion.ts
Normal file
978
kdl-wasm/web/src/grl/semantic/compileMotion.ts
Normal 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);
|
||||
}
|
||||
647
kdl-wasm/web/src/grl/semantic/compileProcFunction.ts
Normal file
647
kdl-wasm/web/src/grl/semantic/compileProcFunction.ts
Normal 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
|
||||
);
|
||||
}
|
||||
549
kdl-wasm/web/src/grl/semantic/compileSemantic.ts
Normal file
549
kdl-wasm/web/src/grl/semantic/compileSemantic.ts
Normal 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 } : {})
|
||||
};
|
||||
}
|
||||
49
kdl-wasm/web/src/grl/semantic/index.ts
Normal file
49
kdl-wasm/web/src/grl/semantic/index.ts
Normal 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";
|
||||
16
kdl-wasm/web/src/kdl/kdl.worker.ts
Normal file
16
kdl-wasm/web/src/kdl/kdl.worker.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
318
kdl-wasm/web/src/kdl/kdlClient.ts
Normal file
318
kdl-wasm/web/src/kdl/kdlClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
118
kdl-wasm/web/src/kdl/nativeAbi.ts
Normal file
118
kdl-wasm/web/src/kdl/nativeAbi.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
58
kdl-wasm/web/src/kdl/nativeModule.ts
Normal file
58
kdl-wasm/web/src/kdl/nativeModule.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
256
kdl-wasm/web/src/kdl/performanceBaseline.ts
Normal file
256
kdl-wasm/web/src/kdl/performanceBaseline.ts
Normal 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]
|
||||
};
|
||||
}
|
||||
99
kdl-wasm/web/src/kdl/poseApi.ts
Normal file
99
kdl-wasm/web/src/kdl/poseApi.ts
Normal 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]!];
|
||||
}
|
||||
78
kdl-wasm/web/src/kdl/rpc.ts
Normal file
78
kdl-wasm/web/src/kdl/rpc.ts
Normal 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);
|
||||
}
|
||||
329
kdl-wasm/web/src/kdl/runtime.ts
Normal file
329
kdl-wasm/web/src/kdl/runtime.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
163
kdl-wasm/web/src/kdl/trajectoryUtils.ts
Normal file
163
kdl-wasm/web/src/kdl/trajectoryUtils.ts
Normal 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
|
||||
};
|
||||
}
|
||||
222
kdl-wasm/web/src/kdl/trapProfile.ts
Normal file
222
kdl-wasm/web/src/kdl/trapProfile.ts
Normal 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));
|
||||
}
|
||||
494
kdl-wasm/web/src/kdl/types.ts
Normal file
494
kdl-wasm/web/src/kdl/types.ts
Normal 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;
|
||||
49
kdl-wasm/web/src/kdl/workerRpc.ts
Normal file
49
kdl-wasm/web/src/kdl/workerRpc.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
238
kdl-wasm/web/src/math/poseMath.ts
Normal file
238
kdl-wasm/web/src/math/poseMath.ts
Normal 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];
|
||||
}
|
||||
2
kdl-wasm/web/src/robot/index.ts
Normal file
2
kdl-wasm/web/src/robot/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RobotModelRegistry, validateNormalizedRobotModel, type RobotRegistryRecord } from "./normalizedRobotModel.js";
|
||||
export { loadRobotFromUrdfModel } from "./urdfParser.js";
|
||||
1781
kdl-wasm/web/src/robot/normalizedRobotModel.ts
Normal file
1781
kdl-wasm/web/src/robot/normalizedRobotModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
277
kdl-wasm/web/src/robot/urdfParser.ts
Normal file
277
kdl-wasm/web/src/robot/urdfParser.ts
Normal 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);
|
||||
}
|
||||
197
kdl-wasm/web/tests/grl/controlFlow.test.ts
Normal file
197
kdl-wasm/web/tests/grl/controlFlow.test.ts
Normal 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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
151
kdl-wasm/web/tests/grl/dataDeclarations.test.ts
Normal file
151
kdl-wasm/web/tests/grl/dataDeclarations.test.ts
Normal 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]
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
125
kdl-wasm/web/tests/grl/exceptionCompile.test.ts
Normal file
125
kdl-wasm/web/tests/grl/exceptionCompile.test.ts
Normal 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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
149
kdl-wasm/web/tests/grl/generator/roundtrip.test.ts
Normal file
149
kdl-wasm/web/tests/grl/generator/roundtrip.test.ts
Normal 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()");
|
||||
});
|
||||
});
|
||||
205
kdl-wasm/web/tests/grl/ioCompile.test.ts
Normal file
205
kdl-wasm/web/tests/grl/ioCompile.test.ts
Normal 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
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
94
kdl-wasm/web/tests/grl/lexer.test.ts
Normal file
94
kdl-wasm/web/tests/grl/lexer.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
124
kdl-wasm/web/tests/grl/motionCompile.test.ts
Normal file
124
kdl-wasm/web/tests/grl/motionCompile.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
153
kdl-wasm/web/tests/grl/operationCompile.test.ts
Normal file
153
kdl-wasm/web/tests/grl/operationCompile.test.ts
Normal 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" }));
|
||||
});
|
||||
});
|
||||
103
kdl-wasm/web/tests/grl/parser.test.ts
Normal file
103
kdl-wasm/web/tests/grl/parser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
251
kdl-wasm/web/tests/grl/pathCompile.test.ts
Normal file
251
kdl-wasm/web/tests/grl/pathCompile.test.ts
Normal 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" }));
|
||||
});
|
||||
});
|
||||
201
kdl-wasm/web/tests/grl/procFunction.test.ts
Normal file
201
kdl-wasm/web/tests/grl/procFunction.test.ts
Normal 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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
148
kdl-wasm/web/tests/grl/semanticIr.test.ts
Normal file
148
kdl-wasm/web/tests/grl/semanticIr.test.ts
Normal 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" })
|
||||
]);
|
||||
});
|
||||
});
|
||||
224
kdl-wasm/web/tests/kdl/cAbi.test.ts
Normal file
224
kdl-wasm/web/tests/kdl/cAbi.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
178
kdl-wasm/web/tests/kdl/checks.test.ts
Normal file
178
kdl-wasm/web/tests/kdl/checks.test.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
198
kdl-wasm/web/tests/kdl/fk.test.ts
Normal file
198
kdl-wasm/web/tests/kdl/fk.test.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
166
kdl-wasm/web/tests/kdl/ik.test.ts
Normal file
166
kdl-wasm/web/tests/kdl/ik.test.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
167
kdl-wasm/web/tests/kdl/path.test.ts
Normal file
167
kdl-wasm/web/tests/kdl/path.test.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
23
kdl-wasm/web/tests/kdl/performance.test.ts
Normal file
23
kdl-wasm/web/tests/kdl/performance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
193
kdl-wasm/web/tests/kdl/planMoveC.test.ts
Normal file
193
kdl-wasm/web/tests/kdl/planMoveC.test.ts
Normal 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"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
192
kdl-wasm/web/tests/kdl/planMoveJ.test.ts
Normal file
192
kdl-wasm/web/tests/kdl/planMoveJ.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
175
kdl-wasm/web/tests/kdl/planMoveL.test.ts
Normal file
175
kdl-wasm/web/tests/kdl/planMoveL.test.ts
Normal 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"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
152
kdl-wasm/web/tests/kdl/poseApi.test.ts
Normal file
152
kdl-wasm/web/tests/kdl/poseApi.test.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
206
kdl-wasm/web/tests/kdl/rpc.test.ts
Normal file
206
kdl-wasm/web/tests/kdl/rpc.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
179
kdl-wasm/web/tests/kdl/trajectoryUtils.test.ts
Normal file
179
kdl-wasm/web/tests/kdl/trajectoryUtils.test.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
138
kdl-wasm/web/tests/kdl/trapProfile.test.ts
Normal file
138
kdl-wasm/web/tests/kdl/trapProfile.test.ts
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
182
kdl-wasm/web/tests/kdl/urdf.test.ts
Normal file
182
kdl-wasm/web/tests/kdl/urdf.test.ts
Normal 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" }
|
||||
});
|
||||
});
|
||||
});
|
||||
88
kdl-wasm/web/tests/post/postProcessor.test.ts
Normal file
88
kdl-wasm/web/tests/post/postProcessor.test.ts
Normal 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") })
|
||||
]);
|
||||
});
|
||||
});
|
||||
16
kdl-wasm/web/tsconfig.json
Normal file
16
kdl-wasm/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
9
kdl-wasm/web/vitest.config.ts
Normal file
9
kdl-wasm/web/vitest.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
1
orocos_kinematics_dynamics
Submodule
1
orocos_kinematics_dynamics
Submodule
Submodule orocos_kinematics_dynamics added at 5c787496c9
1741
package-lock.json
generated
Normal file
1741
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1032
work/doc/KDL_WASM计算接口设计.md
Normal file
1032
work/doc/KDL_WASM计算接口设计.md
Normal file
File diff suppressed because it is too large
Load Diff
3578
work/doc/通用机器人离线编程虚拟控制器技术方案.md
Normal file
3578
work/doc/通用机器人离线编程虚拟控制器技术方案.md
Normal file
File diff suppressed because it is too large
Load Diff
1541
work/doc/通用机器人编程语法规范.md
Normal file
1541
work/doc/通用机器人编程语法规范.md
Normal file
File diff suppressed because it is too large
Load Diff
530
work/doc/通用机器人项目主要实施步骤.md
Normal file
530
work/doc/通用机器人项目主要实施步骤.md
Normal 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. 阶段 3:KDL 基础运动学和 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. 逐点 IK,seed 使用上一采样点关节。
|
||||
|
||||
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. 阶段 5:Path、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. 阶段 6:IO、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. 阶段 7:proc、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 编译为 PathPlanRequest,KDL 生成整条路径。
|
||||
|
||||
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` 下的实施文档。
|
||||
58
work/doc/通用机器人项目功能与数据流程图-png/flow-01.mmd
Normal file
58
work/doc/通用机器人项目功能与数据流程图-png/flow-01.mmd
Normal 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[转换报告]
|
||||
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-01.png
Normal file
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 680 KiB |
42
work/doc/通用机器人项目功能与数据流程图-png/flow-02.mmd
Normal file
42
work/doc/通用机器人项目功能与数据流程图-png/flow-02.mmd
Normal 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[转换报告]
|
||||
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-02.png
Normal file
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 445 KiB |
48
work/doc/通用机器人项目功能与数据流程图-png/flow-03.mmd
Normal file
48
work/doc/通用机器人项目功能与数据流程图-png/flow-03.mmd
Normal 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]
|
||||
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-03.png
Normal file
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-03.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 489 KiB |
92
work/doc/通用机器人项目功能与数据流程图-png/flow-04.mmd
Normal file
92
work/doc/通用机器人项目功能与数据流程图-png/flow-04.mmd
Normal 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
|
||||
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-04.png
Normal file
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-04.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 636 KiB |
16
work/doc/通用机器人项目功能与数据流程图-png/flow-05.mmd
Normal file
16
work/doc/通用机器人项目功能与数据流程图-png/flow-05.mmd
Normal 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]
|
||||
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-05.png
Normal file
BIN
work/doc/通用机器人项目功能与数据流程图-png/flow-05.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
357
work/doc/通用机器人项目功能与数据流程图.md
Normal file
357
work/doc/通用机器人项目功能与数据流程图.md
Normal 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 或品牌源。
|
||||
Reference in New Issue
Block a user