同步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