同步KDL工程源码到云仓库

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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